The shell is served cache-first, which is what makes the app work on a train and also what makes a deploy invisible: a reader with the tab open — the reader this app is built for — keeps running the bundle they first loaded, so a new game or a corrected date reaches their device and sits there with nothing saying why the page looks unchanged. An old app shown as current is the same failure as old events shown as current. So the worker now installs quietly and waits instead of calling skipWaiting(), the page notices it waiting and says so, and the reader's tap sends the skip-waiting message and reloads on controllerchange. The app never reloads itself: someone may be mid-way through typing in one of their own events, and the notice says what a reload costs (their place on the page) and what it does not (marks and notes live in localStorage). Detection is derived rather than remembered. build:static grew into a script that stamps sw.js with a hash of the built shell, because the browser only offers a worker whose bytes differ, and the predecessor — a hand-bumped CACHE_VERSION — had already been forgotten once. The feed is deliberately not part of that hash: it changes twice a day, needs no reload, and announcing it would teach readers to dismiss the notice unread. The cache name stays put for the same reason a per-build one would be wrong — it holds the feed an offline reader is reading. A first install is not an update and stays silent. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
191 lines
7.0 KiB
JavaScript
191 lines
7.0 KiB
JavaScript
/*
|
|
* Service worker: keep the app usable without a network, and hand a reader the
|
|
* next version when there is one.
|
|
*
|
|
* This app is a good offline candidate — the reader's question ("what expires
|
|
* next?") is answered entirely by data already on the device, and countdowns
|
|
* tick from the local clock. Losing signal on a train should not lose the app.
|
|
*
|
|
* Two strategies, chosen per resource:
|
|
*
|
|
* shell (html/css/js) cache-first — it changes only on deploy
|
|
* feed (events.json) network-first with cache fallback — fresher is better,
|
|
* but stale events beat a blank screen
|
|
*
|
|
* Serving the shell cache-first is also what makes a deploy invisible: the
|
|
* reader this app is built for leaves the tab open for days, so without a
|
|
* deliberate handshake they keep running the bundle they first loaded. So this
|
|
* worker installs quietly, waits, and steps in only when the page asks — see
|
|
* `message` below and src/client/state/useAppUpdate.ts.
|
|
*/
|
|
|
|
/*
|
|
* Replaced at build time with a hash of the built shell (scripts/build-static.ts).
|
|
*
|
|
* Its whole job is to make this file's bytes differ when the app differs: the
|
|
* browser decides an update exists by byte-comparing sw.js, so a deploy that
|
|
* left this file untouched would never be offered to anyone. Left literal in an
|
|
* unbuilt copy, where it is a harmless constant.
|
|
*/
|
|
const BUILD = "__BUILD__";
|
|
/*
|
|
* Exposed rather than merely declared, for two reasons: it is the quickest way
|
|
* to see which build a device is actually running (devtools → Application →
|
|
* Service Workers → inspect), and a constant nothing reads is a constant the
|
|
* next person deletes as dead code — which would silently end update detection.
|
|
*/
|
|
self.BUILD = BUILD;
|
|
/*
|
|
* The cache's name, not the app's version — and deliberately *not* derived from
|
|
* BUILD. Everything in here is refetched on install, so a deploy does not need
|
|
* a new bucket; giving it one would throw away the cached feed, which is the
|
|
* copy an offline reader is reading. Bump it only to abandon a cache whose
|
|
* shape changed.
|
|
*/
|
|
const CACHE_NAME = "event-clock-v2";
|
|
// Paths are derived from the registration scope, so the same worker is
|
|
// correct at a domain root and under a subpath (GitHub Pages) alike.
|
|
const BASE = new URL("./", self.registration.scope);
|
|
const at = (path) => new URL(path, BASE).toString();
|
|
const SHELL = ["", "index.html", "styles.css", "main.js"].map(at);
|
|
const FEED = new URL("data/events.v1.json", BASE).pathname;
|
|
const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
|
|
/** What a page sends to ask a waiting worker to take over now. */
|
|
const SKIP_WAITING = "skip-waiting";
|
|
|
|
self.addEventListener("install", (event) => {
|
|
// No skipWaiting here. Taking over an open page unasked means the running
|
|
// bundle and the cached shell come from two different builds, and the reader
|
|
// is told nothing about either. A first install has no worker to wait for and
|
|
// activates immediately regardless.
|
|
event.waitUntil(precache());
|
|
});
|
|
|
|
self.addEventListener("activate", (event) => {
|
|
event.waitUntil(
|
|
caches
|
|
.keys()
|
|
.then((keys) =>
|
|
Promise.all(
|
|
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)),
|
|
),
|
|
)
|
|
.then(() => self.clients.claim()),
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Store the shell this worker was built with.
|
|
*
|
|
* Not `cache.addAll`: it is atomic, so one 404 would leave nothing cached at
|
|
* all. Each item is allowed to fail on its own and the fetch handler fills the
|
|
* gap later.
|
|
*
|
|
* `cache: "reload"` because none of these URLs are fingerprinted — main.js is
|
|
* main.js at every version, and the HTTP cache would happily hand this brand
|
|
* new worker the previous deploy's copy of it.
|
|
*/
|
|
async function precache() {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
await Promise.allSettled(
|
|
SHELL.map(async (url) => {
|
|
const response = await fetch(new Request(url, { cache: "reload" }));
|
|
if (response.ok) await cache.put(url, response);
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The one thing a page can ask of a worker that is waiting: step in now.
|
|
*
|
|
* Sent when the reader taps Reload on the update notice. The page reloads on
|
|
* `controllerchange` rather than on send, so this message is the whole
|
|
* handshake — and it exists only because the reader asked, which is why
|
|
* `install` does not do it unprompted.
|
|
*/
|
|
self.addEventListener("message", (event) => {
|
|
const data = event.data;
|
|
if (typeof data === "object" && data !== null && data.type === SKIP_WAITING) {
|
|
void self.skipWaiting();
|
|
}
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const { request } = event;
|
|
if (request.method !== "GET") return;
|
|
|
|
const url = new URL(request.url);
|
|
|
|
// Webfonts are cross-origin but part of the shell: without them an offline
|
|
// load silently falls back to system faces and the whole thing changes
|
|
// character. Opaque responses cache fine for this purpose.
|
|
if (FONT_HOSTS.has(url.host)) {
|
|
event.respondWith(shellFirst(request));
|
|
return;
|
|
}
|
|
|
|
if (url.origin !== self.location.origin) return;
|
|
|
|
if (url.pathname === FEED) {
|
|
event.respondWith(feedFirst(request));
|
|
return;
|
|
}
|
|
|
|
event.respondWith(shellFirst(request));
|
|
});
|
|
|
|
/**
|
|
* Network first. A successful response is cached so the next offline load has
|
|
* the freshest events we ever saw; a failure falls back to that copy.
|
|
*/
|
|
async function feedFirst(request) {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) await cache.put(request, response.clone());
|
|
return response;
|
|
} catch {
|
|
const cached = await cache.match(request);
|
|
if (cached !== undefined) return cached;
|
|
// No network and nothing cached: say so in the feed's own shape, so the
|
|
// client renders its error state rather than failing to parse.
|
|
return new Response(
|
|
JSON.stringify({ error: "offline", message: "No events stored yet." }),
|
|
{ status: 503, headers: { "content-type": "application/json" } },
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache first, revalidating in the background so a deploy is picked up on the
|
|
* next visit without ever blocking this one.
|
|
*/
|
|
async function shellFirst(request) {
|
|
const cache = await caches.open(CACHE_NAME);
|
|
const cached = await cache.match(request, { ignoreSearch: true });
|
|
|
|
const network = fetch(request)
|
|
.then((response) => {
|
|
// Opaque cross-origin font responses report ok === false but are still
|
|
// worth storing — they render fine from cache.
|
|
if (response.ok || response.type === "opaque") {
|
|
void cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
if (cached !== undefined) return cached;
|
|
|
|
const response = await network;
|
|
if (response !== undefined) return response;
|
|
|
|
// A navigation with no cache and no network still gets the app shell if we
|
|
// have it — the client then shows its own offline message.
|
|
if (request.mode === "navigate") {
|
|
const shell = await cache.match("/index.html");
|
|
if (shell !== undefined) return shell;
|
|
}
|
|
return new Response("Offline", { status: 503 });
|
|
}
|