hyperhive/frontend/packages/swarm-ui/src/sw.js
iris 6b3840b6ac swarm-ui: content-hash main/theme/swarm-ui-css filenames for real cache-busting
Every redeploy currently changes the nix store path serving swarm-ui's
JS/CSS but never the URL the browser requested (main.js, main.css,
theme.css, swarm-ui.css are all fixed filenames) -- so a browser can get
stuck serving yesterday's bundle after a deploy until someone clears the
cache by hand.

build.mjs now hashes main.tsx's JS bundle + its companion CSS output,
plus theme.css and swarm-ui.css, via esbuild's own metafile (not a
guessed hash algorithm), and rewrites the real URLs into index.html and
sw.js after the build.

colors.css deliberately stays unhashed: nix/host-modules/swarm-ui.nix's
stylix theming swaps that exact fixed path via an `= /static/colors.css`
nginx location override with no npm/esbuild rebuild involved. Hashing it
would silently break that swap on a themed host.

sw.js's CACHE_VERSION is now derived from the real hashed URLs instead of
a hand-bumped literal, so any shell-asset change gets a fresh cache name
and the SW's own activate-time sweep evicts the previous one in full --
fulfilling a promise its own prior comment already made.

Scope: swarm-ui only, per mara's call on hyperhive#4283 ("this is about
swarm ui - everything else will eventually migrate over"). dashboard and
agent are untouched.
2026-09-12 13:04:01 +02:00

118 lines
4.8 KiB
JavaScript

// 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)),
);
});