notes.dt.in.th

tl;dr: Do not cache faulty responses in your service worker.

In trying to make my 1hz app an installable PWA in the most simple way possible, I tried using Jeremy Keith's "Minimal viable service worker". The app loads water.css from jsDelivr's CDN. Unfortunately, this caused service worker to fail to fetch and the page is displayed without a style sheet.

On the Console, I see:

A Chrome developer console showing a lot of errors. Text version follows this image.

Text version
The FetchEvent for "https://cdn.jsdelivr.net/npm/water.css@2.0.0/out/dark.min.css" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors
serviceworker.js:22 Uncaught (in promise) TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported
    at serviceworker.js:22
The FetchEvent for "https://cdn.jsdelivr.net/npm/water.css@2.0.0/out/dark.min.css" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors
Promise.then (async)
(anonymous) @ serviceworker.js:16
dark.min.css:1 Failed to load resource: net::ERR_FAILED
serviceworker.js:22 Uncaught (in promise) TypeError: Failed to execute 'put' on 'Cache': Request scheme 'chrome-extension' is unsupported
    at serviceworker.js:22
(anonymous) @ serviceworker.js:22
DevTools failed to load SourceMap: Could not load content for https://1hz.glitch.me/dark.min.css.map: HTTP error: status code 404, net::ERR_HTTP_RESPONSE_CODE_FAILURE

Even worse, this happens intermittently. Sometimes it works, and sometimes it fails1. I thought maybe this is a race condition issue... or maybe not. Anyways, I was in a hurry, so I decided to replace the minimum viable service workers with the tried-and-true Workbox (but using the same logic):

/* global workbox */
importScripts(
  'https://storage.googleapis.com/workbox-cdn/releases/5.0.0/workbox-sw.js'
)

const { registerRoute } = workbox.routing
const { StaleWhileRevalidate, NetworkFirst } = workbox.strategies

// HTML pages
registerRoute(
  ({ request }) => request.headers.get('Accept').includes('text/html'),
  new NetworkFirst()
)

// Anything else
registerRoute(() => true, new StaleWhileRevalidate())

Nope, still failed, intermittently. The FetchEvent for ".../dark.min.css" resulted in a network error response: an "opaque" response was used for a request whose type is not no-cors and even Workbox cannot deal with that???? Argh!! Guess I'll have to dig in and fix the service worker then.

This led me to this StackOverflow question: Can a service worker fetch and cache cross-origin assets? The answer tells me to make sure to "call clone() before the final return response executes." Maybe it's a race condition. So I made the change:

-    const fetchPromise = fetch(request);
+    const splittedPromise = originalFetchPromise.then(response => ({
+      original: response,
+      copy: response.clone(),
+    }));
+    const fetchPromise = splittedPromise.then(({ original }) => original);
+    const responseCopyPromise = splittedPromise.then(({ copy }) => copy);
+    const originalFetchPromise = fetch(request);
     fetchEvent.waitUntil(async function() {
-      const responseFromFetch = await fetchPromise;
-      const responseCopy = responseFromFetch.clone();
+      const responseCopy = await responseCopyPromise;
       const myCache = await caches.open(cacheName);
       return myCache.put(request, responseCopy);
     }());

...to no avail. Still erroring out, and page still loads without external CSS applied.

Next, I found this Smashing Magazine article: Leonardo Losoviz (2017). “Implementing A Service Worker For Single-Page App WordPress Sites”, and I quote (emphasis mine):

Whenever the resource originates from the website’s domain, it can always be handled using service workers. Whenever not, it can still be fetched but we must use no-cors fetch mode. This type of request will result in an opaque response, so we won’t be able to check whether the request was successful; however, we can still precache these resources and allow the website to be browsable offline.

+    const fetchOptions =
+      (new URL(request.url)).origin === self.location.origin
+        ? {}
+        : {mode: 'no-cors'};
-    const fetchPromise = fetch(request);
+    const fetchPromise = fetch(request, fetchOptions);

Again it doesn't help. Worse, it makes more requests fail. The app is more broken.

It's time for my special attack: console.log() the heck out of this service worker.

Console.log showing errors. Among the logging lines it shows that the service worker is caching a response with ok: false

What's that? We're putting faulty responses into the cache?

     fetchEvent.waitUntil(async function() {
       const responseFromFetch = await fetchPromise;
       const responseCopy = responseFromFetch.clone();
+      if (!responseCopy.ok) return;
       const myCache = await caches.open(cacheName);
       return myCache.put(request, responseCopy);
     }());

...and the white unstyled pages are nowhere to be seen again. Service workers are indeed rocket science.

Footnotes

  1. In hindsight, I reckon that it fails around 20% of the time, making debugging this extremely frustrating.