As of
Est. 

PWA: Manifest and Service Worker

Make your web app look and feel a little more like a native application.

When I started looking into progressive web techniques, I wanted to find out if PWAs were suitable for creating applications for my cell phone.

A PWA can install all its files locally on a target device. After that, it can run offline. That is, as long as it doesn't require remote resources.

For a static website like this blog, PWA features don't seem to be very useful. The main argument would be that the blog can be read offline after installation. Another important feature would be push notifications, but support varies between browsers. At least today, you can't rely on them to work in all browsers.

Progressive Ingredients

What are the typical extensions that turn a web app into a progressive web app?

You could say that a progressive web app is something that is recognized as such by Google Lighthouse. This test involves checking the following characteristics:

You could say that a progressive web app is something that Google Lighthouse approves as such. This test includes checks for the following features: PWA approvedPWA approved

  • The app must be served via HTTPS
  • The app defines a web manifest with appropriate icons
  • The app defines a service worker
  • The app has a splash screen icon
  • The page has a <meta name="viewport"> tag with width or initial-scale
  • The page has a <meta name="theme-color"> tag with content="..."
  • The page has a <link rel="apple-touch-icon"> tag with href set to a 180x180 or 192x192 square png.

HTTPS

Browsers support PWA on localhost over http in development mode. But in production, installing a PWA will only work over HTTPS. Fortunately, we already have HTTPS covered.

Header Entries

Most of the points above can be covered by entries in the HTML header sections of the app as follows:

html
<html lang="en-US">
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials">
    <script src="/registerSW.js"></script>
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
    <link rel="mask-icon" href="/e3.svg" color="grey">
    <meta name="theme-color" content="grey">
  </head>
<html lang="en-US">
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="manifest" href="/site.webmanifest" crossorigin="use-credentials">
    <script src="/registerSW.js"></script>
    <link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
    <link rel="mask-icon" href="/e3.svg" color="grey">
    <meta name="theme-color" content="grey">
  </head>

I add those entries to every page of my site by adding them to the VitePress configuration.

Web Manifest

The Manifest file tells the installer how to place an app link on the home screen of the target device. Here is the manifest of this web site.

Service Worker

The /registerSW.js script registers the service worker. It also defines a listener for messages from the service worker. I originally introduced this to avoid strange behavior after new installations.

js
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register(`/service-worker.js`).then(() => {
    navigator.serviceWorker.addEventListener("message", (event) => {...});
  });
}
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register(`/service-worker.js`).then(() => {
    navigator.serviceWorker.addEventListener("message", (event) => {...});
  });
}

The service worker defines event listeners for install & activate and one for fetch. A typical PWA responds to the install event by downloading all pages, scripts, and style files to a local cache. The activate event then clears the cache entries from previous deployments. The main point of interaction is the handler for fetch events. Depending on the strategy (cache first or online first), it serves the requests through a mix of delegations to the server and lookups from the cache.

Service Worker Use Case: Offline Availability

As of the 9/1/22 release, I completely cache all resources on browsers that support service workers.

js
self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches
      .match(event.request)
      .then((res) => {
        if (res) return res;
        return fetch(event.request);
      })
      .catch(() => fetch(event.request))
  );
});
self.addEventListener("fetch", function (event) {
  event.respondWith(
    caches
      .match(event.request)
      .then((res) => {
        if (res) return res;
        return fetch(event.request);
      })
      .catch(() => fetch(event.request))
  );
});

The fetch event listener looks up the resource in the service worker's cache. If it's not found, the request is delegated to the server.

The files loaded into the service worker's cache when the install event triggers:

js
self.addEventListener("install", function (event) {
  event.waitUntil(
    caches
      .open(`V_${VERSION}`)
      .then(async (cache) => {
        await cache.addAll(files);
        self.skipWaiting();
      })
      .catch(function (error) {
        send("Pre-fetching failed: " + error);
}));});
self.addEventListener("install", function (event) {
  event.waitUntil(
    caches
      .open(`V_${VERSION}`)
      .then(async (cache) => {
        await cache.addAll(files);
        self.skipWaiting();
      })
      .catch(function (error) {
        send("Pre-fetching failed: " + error);
}));});

The list of files that are loaded is generated during the build. This is basically a list of all the files that are generated by VitePress under the dist directory of the build. As the Jotter is hosted on Cloudflare Pages, .html extensions are removed and .../index is shortened to .../.

The listener for the activate event drops old cache versions that are not used anymore.

js
self.addEventListener("activate", (event) => {
  self.clients.claim();
  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== `V_${VERSION}`) {
            return caches.delete(cacheName);
}}))));});
self.addEventListener("activate", (event) => {
  self.clients.claim();
  event.waitUntil(
    caches.keys().then((cacheNames) =>
      Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== `V_${VERSION}`) {
            return caches.delete(cacheName);
}}))));});

Service Worker Use Case: Fixing Rebuild Errors

Up to the 9/1/22 release, I had no caching service worker, but I used a service worker to detect rebuild errors

I simply delegated all requests to the server and only intercept when files were not found.

js
self.addEventListener("fetch", (event) => {
  event.respondWith(
    (async () => {
      try {
        const response = await fetch(event.request);
        if (response.status === 404) {
          send("reload!", event.clientId);
        }
        return response;
      } catch (error) {
        return new Response("Service Unavailable", {
          status: 503,
          headers: { "Content-Type": "text/plain" },
});}})());});

self.addEventListener("install", () => {
  self.skipWaiting();
});

self.addEventListener("activate", () => {
  self.clients.claim();
});
self.addEventListener("fetch", (event) => {
  event.respondWith(
    (async () => {
      try {
        const response = await fetch(event.request);
        if (response.status === 404) {
          send("reload!", event.clientId);
        }
        return response;
      } catch (error) {
        return new Response("Service Unavailable", {
          status: 503,
          headers: { "Content-Type": "text/plain" },
});}})());});

self.addEventListener("install", () => {
  self.skipWaiting();
});

self.addEventListener("activate", () => {
  self.clients.claim();
});

Since I check for broken links during build, all files should be found at runtime. Missing files are mainly a sign of load errors caused by rebuilds. So when I find a 404 (file not found), I send a message to the main task to force a reload of the current page.

Because I can't rely on service workers, this site also uses a more general approach to page reloads.

Reception

Support for PWA techniques varies between browsers. The support for PWAs is strongest in Chrome. Chrome supports installation of apps from the address bar, service workers and notifications. Support is weaker in Firefox, which does not really support app installation. Firefox also ignores service workers while in private mode. Weakest support exists on iOS, regardless of which browser is used.

Consequently, PWA features can be an optional add-on for a website, but may not be the technique of choice for an important main functionality.