After a deploy a browser could keep the old entry document, which named a bundle that no longer exists, and the missing script was answered with the entry document, so the page was blank with only a MIME-type error to say why. The hashed bundle is immutable and a missing file is a 404; the entry document, the worker, the manifest, the icons and the theme's colors.css are revalidated. The pages carry a content security policy. The service worker fetches its shell past the HTTP cache, logs a failed install, refreshes the cached entry document on each navigation and holds cache writes open with waitUntil. The proxy test pins the cache rules, the 404s and the policy; docs/swarm/ui.md says what the browser keeps.
161 lines
6.4 KiB
JavaScript
161 lines
6.4 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",
|
|
];
|
|
|
|
// Every shell asset, fetched past the HTTP cache. `cache.addAll` goes through
|
|
// it, so it could store a stale copy of the entry document under the new
|
|
// version, and it rejects as a whole with nothing said when one asset fails.
|
|
// Here a failure is logged and the install fails with it, which leaves the
|
|
// previous worker in charge. A redirect counts as a failure: it is the login
|
|
// page answering for an ended session, and must not be cached as the shell.
|
|
async function precacheShell() {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
await Promise.all(
|
|
SHELL_ASSETS.map(async (url) => {
|
|
const response = await fetch(url, { cache: "reload" });
|
|
if (!response.ok || response.redirected) {
|
|
throw new Error(
|
|
`${url}: status ${response.status}${response.redirected ? " after a redirect" : ""}`,
|
|
);
|
|
}
|
|
await cache.put(url, response);
|
|
}),
|
|
);
|
|
}
|
|
|
|
// Keep the cached entry document as fresh as the last navigation that
|
|
// reached the network, so the offline fallback is not stuck on install time.
|
|
function refreshShell(response) {
|
|
return caches
|
|
.open(CACHE_NAME)
|
|
.then((cache) => cache.put(SHELL_DOCUMENT, response));
|
|
}
|
|
|
|
self.addEventListener("install", (event) => {
|
|
event.waitUntil(
|
|
precacheShell().catch((err) => {
|
|
console.error("swarm-ui: service worker install failed", err);
|
|
throw err;
|
|
}),
|
|
);
|
|
// 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)
|
|
.then((response) => {
|
|
if (response.ok && !response.redirected) {
|
|
event.waitUntil(refreshShell(response.clone()));
|
|
}
|
|
return response;
|
|
})
|
|
.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();
|
|
// Held open with `waitUntil`: the worker can be stopped as soon as
|
|
// it answers, which would drop a write still in flight.
|
|
event.waitUntil(
|
|
caches
|
|
.open(CACHE_NAME)
|
|
.then((cache) => cache.put(event.request, copy)),
|
|
);
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => caches.match(event.request)),
|
|
);
|
|
});
|