Reputation: 3725
I have a React application with a ServiceWorker that has the purpose of intercepting certain requests (API calls and resource fetching) and adding an Authorization
header with a proper token. In most of the cases it works just fine, but there are some where the service worker doesn't handle the fetch event, resulting in 401 responses.
React side โ๏ธ
We have the following function to register the service worker and wait for it to be active:
function initializeServiceWorker() {
return new Promise((resolve, reject) => {
navigator
.serviceWorker
.register("/tom.js")
.then((registration) => {
// checks the state of the service worker until it's activated
// and move on with the then-chain
return pollUntil(
(registration) => registration.active?.state === "activated",
POLL_EVERY, // 40ms
registration,
POLL_TIMEOUT // 6000ms
);
})
.then((registration) => {
// here we send the authentication token to the service worker
// to be stored in the IndexedDB overriding the old one
// and persist across termination/reactivation
registration.postMessage(<NewAuthToken>{ type: "AUTH_TOKEN", token });
})
.catch(reject);
});
}
Service Worker side ๐ค
Our service worker sets handlers for the active, install, message and fetch events as the following:
// eslint-disable-next-line no-restricted-globals
const worker = self as any as ServiceWorkerGlobalScope;
// NOTE: we refer to self as `worker` after performing a couple of type casts
worker.addEventListener("install", () => {
// this ensure that updates to the underlying service worker take effect
// immediately for both the current client and all other active clients
worker.skipWaiting();
});
worker.addEventListener("activate", async (event: ExtendableEvent) => {
// https://developers.google.com/web/fundamentals/primers/service-workers/lifecycle#activate
// The first activation does not connect / intercept already running clients.
// "claim" claims any already running clients.
event.waitUntil(worker.clients.claim());
});
worker.addEventListener("message", (event: ExtendableMessageEvent) => {
// here we store the token to IndexedDB
});
worker.addEventListener("fetch", (event: FetchEvent) => {
event.respondWith(
(async function respond() {
try {
const token = await DB.getToken();
// no token - no party
// just send the request as is
if (!token) return fetch(event.request);
// going somewhere else?
// no need to add the token to the request
const sourceHost = new URL(worker.location.origin).host;
const destinationHost = new URL(event.request.url).host;
if (destinationHost !== sourceHost) {
return fetch(event.request);
}
// already authorized?
// ready to go as it is
if (event.request.headers.get("Authorization")) {
return fetch(event.request);
}
/**
* When the `destination` property of the request is empty, it means that the
* request is an API call, otherwise it would have contained the DOM element
* that initialized the request (image, script, style, etc.)
*
* In this case, we can smoothly add the token in the Headers.
*/
if (event.request.destination === "") {
const headers = new Headers(event.request.headers);
headers.append("Authorization", token);
const enhancedRequest = new Request(event.request, { headers });
return await fetch(enhancedRequest);
}
// when this get executed it means the browser wants
// the content of an <img> tag or some script or styling
// ... but this is not the point of this SO question ...
return requestResource(event.request, token);
} catch (error) {
// something horrible happen?
// let the original request to reach it's destination freely
return fetch(event.request);
}
})()
);
});
So, what's the issue? ๐ค
We await the initializeServiceWorker
method before performing our first API call, like this:
// in an async function...
await initializeServiceWorker();
const homeModel = await api.environment.getClientSettings();
In certain cases (thankfully a small percentage like <5%), the getClientSettings
api call ends up in 401, not having the Authorization header added.
So, how is this possible? How is it possible the the initializeServiceWorker
promise resolves before calling the api AND the token is still not there?
Does this initialization/service worker code make sense? Do you see any huge or subtle mistakes that slipped through 6 eyes already? Are we doing something kinda crazy here? ๐คทโโ๏ธ
Let me know ๐ค
Strange stuff you may wonder about ๐คจ
Why do you wait until the service worker is activated?
We noticed that, sometimes, the activation takes a little more time than necessary and that we really needed to wait it to be ready before moving on with our code.
--
How do you know that it's not intercepting at all?
To figure out whether we weren't adding the Authorization
header or we weren'd intercepting the requests at all, we decided to always add an additional X-SW
header and track on the backend the relation between the failure and the presence of this new "marker" header.
We noticed that when we receive a 401, there isn't the X-SW
marker header, so the service worker is not touching those requests at all.
ATTENTION: the addition of the marking header has been removed from the previous code snippet for clarity.
--
Can it be a race condition between the storage of the token in IndexedDB and the first API call?
While that can be an issue, but it's not the case here because requests doesn't even have the X-SW
marker header. Let's suppose that the token was not yet around in the service worker side of things, the request should anyway have the marker header, but we tracked that and it's not the case...
--
How do you know that initializeServiceWorker
is resolving at all?
That call, as well as the api call, are in a try catch block that differentiates what the user sees in case of failure, depending on the failure. We know, then, that the initialization went well because we see the 401 related error message.
Upvotes: 4
Views: 1143
Reputation: 56154
As far as I can tell, there are two race conditions in your code.
First, the logic in the first part of your initializeServiceWorker()
isn't waiting for the right thing. You're polling in an attempt to find out when the registered service worker has been activated, but even if you call clients.claim()
, there's a gap of time between when a service worker is activated and when it takes control of all the clients in scope.
What you should be waiting for, if you want to make sure that all network requests will trigger the service worker's fetch
handler, is for there to be a service worker in control of the current page. There's more background in this issue, and it includes the following snippet you can use:
const controlledPromise = new Promise((resolve) => {
if (navigator.serviceWorker.controller) {
resolve();
} else {
navigator.serviceWorker.addEventListener(
'controllerchange', () => resolve());
}
});
Once controlledPromise
resolves, you'll know that network requests from the current page will trigger the fetch
handler of the service worker that's in control.
The second issue is that your postMessage()
call completes once the message
event is fired on the service worker, but before the IndexedDB operation actually completes.
You need to send a message back from the service worker to the client page indicating that the IndexedDB write has completed. You can do this with "vanilla" JavaScript using MessagePort
s, but I'd recommend using the Comlink library instead, as this will wrap up the details of using postMessage()
and coordinating MessagePort
s in a nicer, promise-based interface.
Upvotes: 6