// The app shell as a service worker, so an installed RecipesCam opens with no // network at all. Not bundled on purpose: through vite/rollup it would stop being // readable in DevTools, the one place a worker gets debugged. VERSION is the cache // name and the whole update story — bump it when the shell changes, and `activate` // drops every older cache. // // v2 is the bump that flushed a shell this worker had already pinned: before the // header went on in nginx, a browser was free to guess a freshness window for // index.html and answer a navigation with the previous build's shell — and its // hashed bundle with it, which the STATIC rule below then served cache-first // forever. Every navigation here now goes past the browser's own cache, so the // build a visitor gets is the one the server has. const VERSION = 'recipescam-v12'; // nginx answers all three routes with the same index.html (SPA fallback), so they // are one document under three keys: an offline navigation finds it whichever key // it asks. The manifest is not that document and is precached with them for the // same reason — it is what makes the installed app an app, and offline it is // asked for on every launch. const SHELL = ['/', '/app', '/library', '/manifest.json']; // Cache-first paths: vite hashes every filename in its own bundle, and the wasm, // the models and the icons never change under a given build. const STATIC = /^\/(assets|wasm|models|icons)\//; // The shell, read past the browser's cache on purpose: a `cache.add` of '/' // consults that cache like any other fetch, so a shell stored during a stale // window would be precached as the offline shell of the build that replaced it. const shellRequest = (url) => new Request(url, { cache: 'reload' }); // Only a complete same-origin 200 is worth keeping — an opaque or error body // stored here comes back as a failure on the next visit, and nothing in the worker // can tell the two apart by then. const cachedFetch = (request) => fetch(request).then((response) => { if (response.ok) { const copy = response.clone(); caches.open(VERSION).then((cache) => cache.put(request, copy)); } return response; }); // Offline, the route that was asked for if this browser has ever been on it, then // the studio the app is for, then the landing page for a build without /app. The // route first because that is the copy a visit keeps up to date: the other two may // be shells of a build the deploy has replaced. const offlineShell = async (request) => (await caches.match(new Request(new URL(request.url).pathname))) ?? (await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error(); // The files the shell is built out of, read out of the shell itself: the hashed // bundle, the stylesheet, the icon. A worker that precached only the document // would answer an offline launch with a page whose JavaScript this machine does // not have — and it never would, because the visit that first loads the app is // the visit this worker is not yet controlling, so its own STATIC rule below // never sees those requests. The shell is the one place their names are written. const shellFiles = (html) => [...html.matchAll(/(?:src|href)="(\/assets\/[^"]+)"/g)].map((m) => m[1]); self.addEventListener('install', (event) => { // Best effort, one file at a time: a deploy caught mid-flight must not leave the // worker uninstalled, so each one fails on its own. event.waitUntil( caches.open(VERSION) .then(async (cache) => { await Promise.all(SHELL.map((url) => cache.add(shellRequest(url)).catch(() => {}))); const shell = await cache.match('/app') ?? await cache.match('/'); if (!shell) return; for (const url of shellFiles(await shell.text())) { await cache.add(shellRequest(url)).catch(() => {}); } }) .then(() => self.skipWaiting()), ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys() .then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n)))) .then(() => self.clients.claim()), ); }); self.addEventListener('fetch', (event) => { const { request } = event; // A worker is a cache, not a proxy: a POST, the API, another origin's font — a // request that is not a plain same-origin read — goes straight out. if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return; // A navigation is network-first even when it is cached: a shell answered from the // cache while the network holds a newer build pins the visitor to the old one. It // is also the path an installed app opens through with no network. // `no-store` because a plain fetch is not the network: it may be answered by the // browser's own cache, which is the other place a stale shell hides. if (request.mode === 'navigate') { event.respondWith( fetch(request, { cache: 'no-store' }) .then((response) => { // The shell kept for offline is the one the server handed over last, not // the one this worker installed with: a navigation that fails — a deploy // recreating the container is the ordinary way one does — otherwise // answers with the build of the first visit, `activate` never runs to // replace it while this worker's bytes stay the same, and the browser // runs an old build until a reload happens to land after the server is // back. Stored under the route the navigation asked for, which is one of // the three SHELL keys — a navigation request is not a key a cache takes. if (response.ok) { const copy = response.clone(); const key = new Request(new URL(request.url).pathname); caches.open(VERSION).then((cache) => cache.put(key, copy)).catch(() => {}); } return response; }) .catch(() => offlineShell(request)) ); return; } if (STATIC.test(url.pathname)) { event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request))); return; } // Everything else — a font, the manifest, an uploaded photo — is network-first, // and answered from the cache only when that fails. event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error())); });