A capable PWA

28 Mar 2024

Converting to a Progressive Web App

6 minutes reading time

Updated 02 Apr 2024

#Problems

I am not a fan of JavaScript. But I already started with some Service Worker examples from Mozilla some time ago, and PWAs have proven to be very effective. So, let's go.

#Static deployments

My first implementation was pulling cache lists from a dedicated page generated by the Zola template using a macro that pulls assets from taxonomies, pages, etc. But besides the need for filtering and discarding a lot of data, having a dedicated page for this is just ugly. That was the only way to make it work with fetch(). And I had to add an extra zola build as well.

Zola does not yet have the capability to populate non-HTML files, and I could not justify adding extra steps with NPM/etc. just for a single service worker event to function. So a new approach was needed.

#External libraries

Workbox or sw-tools libraries would resolve probably everything, but it's too easy. Since I would have to maintain JavaScript anyway, let's get on with it.

#Portability

Huh

#Service Worker

The solution is a cache-first service worker strategy with a fallback to offline mode. This feels like the most efficient approach. And it requires no external dependencies or extra steps. I could play with network requests, but timeout sounds too slow already, so maybe next time.

#Strategy

I decided to remove the hardcoded/dynamic cache list and install a fallback page instead.

oninstall = (event) => {
  event.waitUntil(
    (async () => {
      const cache = await caches.open(cacheName);
      await cache.add("/offline/");
      console.log("Service worker added offline page");
    })(),
  );
};

The rest is cached "as you go"—the service worker filters useful requests and writes them into the cache. This way, I save critical resources during the first page navigation, and there is no funny business. If the requests fail (no network), an offline page is served.

onfetch = (event) => {
  console.log("Service worker fetching", event.request.url);
  event.respondWith(
    caches.open(cacheName).then((cache) => {
      return cache
        .match(event.request)
        .then((response) => {
          if (response) {
            console.log("Service worker found response in cache:", response);
            return response;
          }

          console.log(
            "No response for %s found in service worker cache. Fetching " +
              "from network",
            event.request.url,
          );

          return fetch(event.request.clone()).then((response) => {
            console.log(
              "Service worker got response for %s from network: %O",
              event.request.url,
              response,
            );

            if (response.status < 400) {
              console.log("Caching the response to", event.request.url);
              cache.put(event.request, response.clone());
            } else {
              console.log("Service worker not caching the response to", event.request.url);
            }

            return response;
          }).catch(() => caches.match("/offline/"));
        })
        .catch((error) => {
          console.error("Error in service worker fetch handler:", error);
          throw error;
        });
    }),
  );
};

The site's static assets are hashed by my Zola theme, so the strategy fits perfectly.

#Cache

Housekeeping is done via cacheName - all previous (old) cache records are purged during the service worker's activation, maintaining a clean browser environment.

onactivate = (event) =>  {
  event.waitUntil(
    (async () => {
      const keys = await caches.keys();
      return keys.map(async (cache) => {
        if(cache !== cacheName) {
          console.log('Removing old service worker cache '+cache);
          return await caches.delete(cache);
        }
      })
    })()
  )
};

Although, I want to find a nice way to "expire" cache records, relying on a hardcoded cache name only might be an issue.

#Revalidation

To handle "expired" resources, I switched the fetch event to the stale-while-revalidate strategy:

onfetch = (event) => {
  console.log("Service worker fetching", event.request.url);
  event.respondWith(caches.open(cacheName).then((cache) => {
    return cache.match(event.request).then((cachedResponse) => {
      const fetchedResponse = fetch(event.request).then((networkResponse) => {
        if (networkResponse.status < 400) {
          console.log("Caching the response to", event.request.url);
          cache.put(event.request, networkResponse.clone());
        } else {
          console.log("Service worker not caching the response to", event.request.url);
        }

        return networkResponse;
      }).catch(() => caches.match("/offline/"));

      return cachedResponse || fetchedResponse;
    });
  }));
};

#Precache

After settling on the cache event, I wanted to properly support the offline mode. The standard approach for this is to use the background sync API. A quick examination suggests this is a picky solution, and support is very limited. That alone is enough to look for a workaround. I started from the ground.

First, I needed to generate the cache list, so I took my macro and applied its logic directly in the HTML <head> to use the output with a data-cache tag attribute while linking the service worker's loader script.

Second, I needed a way to get the cache list to "sync" with the service worker. The search got me the postMessage() service worker method that "sends a message to the worker". Bingo. To catch the message on the other side, one needs to implement the message event:

onmessage = (event) => {
  console.log("I am the service worker");
};

Now, what stops me from repeating what I have been doing during the service worker installation? I sent a message after the service worker's activation, checked the request type, and started to fill the cache using URLs from the message. Worked.

onmessage = (event) => {
  if (event.data.type === "PRECACHE") {
    const data = event.data.payload;
    console.log("Service worker started precache", data);
    event.waitUntil(
      (async () => {
        const cache = await caches.open(cacheName);
        await cache.addAll(data)
          .catch((error) => console.log("Service worker failed precache", error));
      })(),
    );
  }
};

The cache is full, all assets are included, and I had no issues mixing absolute/relative links (though maybe it's not a good "feature" after all). The hardcoded cache list with critical assets got reintroduced, along with the offline page, all to be cached during the installation. I also started requesting the precache only after the installation, to avoid redundant fetches:

const data = new String(document.currentScript.getAttribute('data-cache'));
const precacheList = data.split(" ");
const registerServiceWorker = async () => {
  if ("serviceWorker" in navigator) {
    try {
      const registration = await navigator.serviceWorker.register("/sw.js", {
        scope: "/",
      });

      if (registration.installing) {
        console.log("Service worker installing");
        navigator.serviceWorker.ready.then((registration) => {
          console.log("Service worker requesting precache");
          registration.active.postMessage({
            payload: precacheList,
            type: "PRECACHE",
          });
        });

      } else if (registration.waiting) {
        console.log("Service worker installed");
      } else if (registration.active) {
        console.log("Service worker active");
      }

    } catch (error) {
      console.error("Service worker registration failed", error);
    }
  }
};

registerServiceWorker();

This setup delivers a fully offline-ready site. The service worker deploys critical files during the installation, then precaches everything else.

#Conclusion

It looks like I'll do anything just to avoid touching CSS in Halve-Z. It was a nice exercise, though. I built a simple and capable PWA without jeopardizing the workflow or user experience. All code is available in theme's pull requests #22, #23, and #24.