The service worker that serves flows

The service worker that serves flows

No service worker, no PWA: the browser will never offer to “install the app,” and an offline open ends on an error page. In our diary the SW makes the app installable and opens /app with no network. The static caching exists to serve exactly that.

Checking it takes ten seconds. Turn on airplane mode, tap the icon on the home screen — the shell, the app’s HTML page with its styles and scripts, opens from cache, and the diary reads from the IndexedDB the previous part covered. The only thing that admits you’re offline is the yellow capsule in the header.

The platform is unusual here: sw.js and manifest.webmanifest don’t exist as files on the server — both are served by flows.

The manifest and the SW as flows

The route GET /manifest.webmanifest is bound to a flow named site_manifest with a single action — a respond_json node holding the ready-made JSON object, content_type: "application/manifest+json", and 300 seconds of cache. The manifest inside is ordinary: start_url: "/app", scope: "/", display: "standalone", plus three icons — 192, 512, and a maskable 512. The maskable one is different from the rest: it has padding around the edges, because Android fits the icon into a circle or a rounded square, and an ordinary full-canvas icon loses its edges in the process.

The two flows cache for different lengths of time, each for its own reason. The manifest changes only together with the icons and the name, so 300 seconds never bother it. /sw.js carries a TTL of 0, because the browser checks for SW updates through the HTTP cache, and a service worker frozen in that cache would delay every app update by the cache’s lifetime.

The body of the site_sw flow is the JS source as a raw string, with no Liquid processing, content_type: "application/javascript", and TTL 0. The original lives in the repository (static/sw.js) and is baked into the flow at publish time.

The /sw.js path was chosen deliberately. A service worker’s scope defaults to everything up to the last slash in the script’s URL, so /sw.js at the root gets scope / — the whole site — without a single extra header. Put it at /static/sw.js and the scope shrinks to /static/, forcing us to send Service-Worker-Allowed: /. That’s how it ended up at the root.

Registering the SW takes five lines in the shell template:

<script type="module" src="/static/app.js?v={{ site.version }}"></script>
<script>
if ('serviceWorker' in navigator) {
  window.addEventListener('load', function () {
    navigator.serviceWorker.register('/sw.js').catch(function () {});
  });
}
</script>

The fetch handler

We intercept GET only. POST requests (saving an entry, the profile) go straight to the network and have their own queue in IndexedDB, the one the previous part covered.

/api/* we don’t intercept at all — an early return with no respondWith, so to the browser everything looks as if the SW weren’t there. Member-zone data must not be cached: an offline answer from the cache would show wrong calories for today. The data itself already sits in IndexedDB.

Navigation to /app is network-first. When the network is up, we serve the fresh shell and refresh the cache in passing. When it isn’t, we fall back to the cached copy:

if (req.mode === 'navigate' && url.pathname === APP_PATH) {
  const cache = await caches.open(CACHE);
  try {
    const res = await fetch(req);
    if (res && res.ok) {
      await cache.put(APP_PATH, res.clone());
      event.waitUntil(precacheShellStatics()); // shell updated → precache the statics
    }
    return res;
  } catch (err) {
    const cached = await cache.match(APP_PATH);
    if (cached) return cached;
    throw err;
  }
}

The last case is /static/* — stale-while-revalidate. The answer goes out instantly from cache while the network refresh happens in the background, so the user isn’t left waiting on CSS.

The shell and its statics

A shell cached without its statics is useless offline — the HTML opens with no styles and no scripts. So at install, and after every shell update, the SW parses the cached HTML, extracts every src/href pointing to /static/ (with ?v= included), and fetches each file with cache: 'reload', bypassing the browser’s HTTP cache so the new version is guaranteed to download.

One subtlety: the HTML contains no db.js or api.js — app.js imports them dynamically. The SW appends them to the list itself, inheriting the same ?v=:

if (url.startsWith('/static/app.js')) {
  const v = new URL(url, self.location.origin).searchParams.get('v');
  const q = v ? `?v=${encodeURIComponent(v)}` : '';
  urls.add(`/static/db.js${q}`);
  urls.add(`/static/api.js${q}`);
}

At activation we clean house: old kbzhu-shell-* caches are deleted, and the current one sheds any statics the fresh shell doesn’t reference (expired ?v= values, for example). skipWaiting on install and clients.claim on activate let the new SW get to work immediately, without waiting for every tab to close and without asking anyone to reload the page. The cache, meanwhile, doesn’t grow forever.

The app shell in light and dark themes — what the SW caches

Gotchas: the module cache that kept the old db.js

The sneakiest problem of the whole cycle hid in the imports. The template versions the CSS and the entry point — ?v={{ site.version }} changes on every release, and the browser downloads fresh files. A module’s internal imports (import './db.js') live under different rules: they carry no version, and the browser keeps such a module in its cache for up to an hour.

The symptom looked odd. The site had been updated, and the console threw TypeError: db.pruneFoods is not a function — the new app.js had arrived, while db.js was still the previous version’s. The function exists in the source and is missing from the loaded module. An automated test caught this even on a “fresh” site with nothing to update.

The cure is a dynamic import with a version suffix taken from the URL of app.js itself:

const __v = new URL(import.meta.url).searchParams.get('v');
const __sib = (name) => `./${name}${__v ? `?v=${__v}` : ''}`;
const db = await import(__sib('db.js'));
const api = await import(__sib('api.js'));

Now db.js and api.js get the same ?v= as the entry point: the shell updated, and they updated with it. The precache pass from the previous section fetches them into the SW cache with those same versions, so shell and statics stay in step offline too.

The VERSION constant in the SW has stayed kbzhu-v1 since day one. Static updates don’t need it to change: every publish changes ?v= and the shell, the SW notices the new shell on its network-first pass, and refetches the files on its own. Bumping VERSION would mean waiting for exactly the right moment — the old SW and the new cache coexist until activation, and inside that window it’s easy to strip a live app of its statics.

The weak spot in this scheme is any file whose URL carries no version: one such import, and the browser mixes an old module into the updated set. Ours sat twenty lines below the document’s head.

What’s next. This closes out the client side — the offline queue works, the shell is cached. The next part is “Liquid Gotchas and the Dynamic Schema”: what happens when the backend is written as Liquid flows and the query library refused GraphQL with Liquid inserts.


All posts