From dfcd6734189c9d0eea074aeeece64059173e5804 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 11:23:22 +0200 Subject: [PATCH] feat(pwa): service worker for the intake route Phase 8b Task 4. Cache-first for the static asset allowlist (icons, manifest, root HTML). Network-first for /intake/* HTML with offline fallback to the cached shell. Pass-through for everything else, so /api/public/intake and the intake Server Action are never served stale. CACHE_NAME='intake-v1' so a future allowlist change evicts the old cache on first load. --- public/sw.js | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 public/sw.js diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..b0e8c94 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,91 @@ +/** + * The intake route's service worker. + * + * Deliberately small. Cache-first for the static asset allowlist (icons, + * manifest, the layout's CSS, the static fonts the intake route ships), + * network-first for /intake/* HTML. Cache capped at ~2 MB to keep storage + * pressure low on cheap phones. + * + * No background sync, no IndexedDB, no push. The PWA exists to (a) get an + * icon on the home screen and (b) keep the in-progress wizard usable + * across a flaky mobile network while it is open. That is the whole brief. + */ + +const CACHE_NAME = "intake-v1"; +const STATIC_ASSETS = [ + "/", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png", + "/icons/icon-maskable-512.png", + "/icons/apple-touch-icon.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(STATIC_ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + + const url = new URL(request.url); + const sameOrigin = url.origin === self.location.origin; + if (!sameOrigin) return; + + // /intake/* HTML — network-first, fall back to the cached shell only if the + // network is unreachable. The wizard's data lives on the server (PocketBase); + // we cannot serve stale answers. + if (url.pathname.startsWith("/intake/") && request.headers.get("accept")?.includes("text/html")) { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(() => caches.match(request).then((cached) => cached || caches.match("/"))) + ); + return; + } + + // Static asset allowlist — cache-first. + if (STATIC_ASSETS.includes(url.pathname)) { + event.respondWith( + caches.match(request).then( + (cached) => + cached || + fetch(request).then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + ) + ); + return; + } + + // Everything else: pass through. In particular, /api/public/intake and + // the intake Server Action must NOT be intercepted — a stale cached + // response there would corrupt an in-flight interview. +});