// Minimal service worker — satisfies PWA installability so swarm-ui can // actually be "Add to Home Screen"d (design-guide.md's "Layout & // viewport" section already asserted this as fact before this service // worker existed to make it true). NOT a data cache: scoped to the app // shell only (the SPA's own HTML/JS/CSS/manifest), and explicitly never // touches `/api/*` — an ops dashboard silently showing stale status from // a cached fetch would be worse than showing nothing (mara: "scope looks // good" — approving that as a hard rule, not a short-TTL middle ground). // // Network-first for the shell, not cache-first: most assets below are // content-hashed (a fresh URL per deploy, so a normal visit always gets // the current bundle regardless of this SW), but `colors.css` stays // deliberately unhashed for the host-side stylix theme swap, so this // SW's own cached copy of it can only ever refresh via a real network // round-trip. This SW only ever serves its cache on a FAILED fetch // (offline/flaky network), never in preference to a live response. // // Plain JS, not TypeScript, deliberately — the DOM lib swarm-ui's own // tsconfig uses and the WebWorker lib a service worker's globals need // (`self`, `ExtendableEvent`, `FetchEvent`, `caches`, ...) are mutually // exclusive in one tsc program, and this file is small enough that a // second tsconfig just to typecheck it isn't worth the config surface. // Same treatment `build.mjs` already gets in this package. // // CACHE_VERSION is filled in by build.mjs from a hash of every hashed // shell-asset URL below, so any real content change gets a fresh cache // name and `activate`'s old-cache sweep evicts the previous one in // full. This literal placeholder is never served; the build always // replaces it. const CACHE_VERSION = "__BUILD_VERSION__"; const CACHE_NAME = `swarm-ui-shell-${CACHE_VERSION}`; // Every navigation (any client-side route wouter handles — /agents, // /hives, ...) falls back to this single cached document when offline, // same as nginx's own `try_files $uri /index.html` does online: the SPA // only ever has one real HTML document regardless of path. const SHELL_DOCUMENT = "/"; const SHELL_ASSETS = [ SHELL_DOCUMENT, "/static/main.js", "/static/main.css", "/static/colors.css", "/static/theme.css", "/static/swarm-ui.css", "/manifest.webmanifest", ]; self.addEventListener("install", (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS)), ); // Take over from any previous SW as soon as this one's installed — an // operator installing the PWA for the first time (or after a SW // update) shouldn't need a second reload before it's in control. self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil( caches .keys() .then((names) => Promise.all( names .filter((name) => name !== CACHE_NAME) .map((name) => caches.delete(name)), ), ) .then(() => self.clients.claim()), ); }); self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); // Same-origin only, and never `/api/*` — that's swarm-controller's // live data, not the static shell this SW is scoped to. Letting the // event fall through (no `respondWith`) means the browser handles it // exactly as if this SW didn't exist. if (url.origin !== self.location.origin || url.pathname.startsWith("/api/")) { return; } // Any navigation (full-page load or wouter's client-side route change // reaching the network) — network-first, falling back to the cached // shell document on failure regardless of which path was requested, // since every route renders the same SPA shell. if (event.request.mode === "navigate") { event.respondWith( fetch(event.request).catch(() => caches.match(SHELL_DOCUMENT)), ); return; } // A specific shell asset — network-first, cache the fresh response for // next time, fall back to whatever's cached on failure. Anything not // in this list (a future asset this SW doesn't know about yet) passes // straight through untouched rather than silently going uncached. if (!SHELL_ASSETS.includes(url.pathname)) { return; } event.respondWith( fetch(event.request) .then((response) => { // Only a genuinely good response is worth caching — an error // response (a 404/500, or a proxy hiccup wearing a 2xx-adjacent // status) would otherwise get served back as if it were the real // asset on the next offline/failed fetch. if (response.ok) { const copy = response.clone(); caches .open(CACHE_NAME) .then((cache) => cache.put(event.request, copy)); } return response; }) .catch(() => caches.match(event.request)), ); });