0xc14m1z
0xc14m1z

Reputation: 3725

Active ServiceWorker doesn't ALWAYS intercept requests

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

Answers (1)

Jeff Posnick
Jeff Posnick

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 MessagePorts, but I'd recommend using the Comlink library instead, as this will wrap up the details of using postMessage() and coordinating MessagePorts in a nicer, promise-based interface.

Upvotes: 6

Related Questions