feat(sw): offer a reload when a newer version is ready

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]>
This commit is contained in:
Lucas Winther
2026-08-17 18:55:08 +02:00
co-authored by Claude Opus 5
parent 7130a94b21
commit a4ab5aa36c
11 changed files with 804 additions and 20 deletions
+19
View File
@@ -269,6 +269,25 @@ A lane may now be a game the reader invented, so `gameMeta` is a context resolve
and total) rather than a direct lookup — a lane can outlive its game when an import carries an event and total) rather than a direct lookup — a lane can outlive its game when an import carries an event
whose game did not come with it. whose game did not come with it.
## Shipping a new version
The shell is cached cache-first, so a reader with the tab open keeps the bundle they first loaded.
An old app presented as current is the same failure as old events presented as current, so a waiting
version is disclosed and reloaded on a tap (PRD F14, `docs/ARCHITECTURE.md` § Shipping a new version
to an open page). Four things hold it up:
- **`sw.js` must not `skipWaiting()` on install.** It activates only on the `skip-waiting` message
the reader's tap sends. Claiming an open page unasked runs the old bundle against the new cache and
says nothing.
- **`__BUILD__` must stay in `sw.js`.** `scripts/build-static.ts` substitutes a hash of the built
shell for it, which is what makes a deploy's worker bytes differ and therefore detectable. It
throws if the placeholder is gone — do not "fix" that by dropping the substitution. There is no
`CACHE_VERSION` bump ritual any more; the cache name is a namespace, and per-build names would
discard the stored feed an offline reader is reading.
- **The feed is not part of the build id.** It changes twice a day and needs no reload; announcing it
as a new version teaches readers to dismiss the notice unread.
- **The app never reloads itself.** Someone may be mid-way through typing an event in.
## Conventions ## Conventions
- **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never - **Zod schemas are the single source of truth for types.** Derive with `z.infer<>`; never
+8
View File
@@ -190,6 +190,14 @@ offline is shown in the header and above the footer, because stale data must nev
It installs to a home screen as a standalone app. It installs to a home screen as a standalone app.
## Updates
Because the shell is cached, a tab left open keeps running the version it loaded — so when a newer
one has been fetched and is ready, the app says so and offers to reload. You choose the moment: the
only thing a reload costs is your place on the page. Everything you have marked, typed or ticked
lives in your browser, not in the bundle, so it survives untouched. Dismiss it and the offer comes
back next time you open the app.
## Conduct ## Conduct
Sources are community wikis, treated as a guest would: `robots.txt` honoured, a descriptive Sources are community wikis, treated as a guest would: `robots.txt` honoured, a descriptive
+48
View File
@@ -95,6 +95,7 @@ src/
Controls.tsx games, region, export/import(F4, F5, F6) Controls.tsx games, region, export/import(F4, F5, F6)
Welcome.tsx first-run game picker (F8) Welcome.tsx first-run game picker (F8)
Toast.tsx undo an ignore Toast.tsx undo an ignore
UpdateNotice a newer app is installed and waiting (F14)
Colophon.tsx credit, disclaimer, repo link Colophon.tsx credit, disclaimer, repo link
state/ state/
storage.ts namespaced, versioned localStorage storage.ts namespaced, versioned localStorage
@@ -104,9 +105,11 @@ src/
usePrefs.ts region, filters, focus, onboarding flags usePrefs.ts region, filters, focus, onboarding flags
sort.ts deadline order, or what you're partway through sort.ts deadline order, or what you're partway through
lens.ts who sees which rows — focus, outstanding, next-to-expire lens.ts who sees which rows — focus, outstanding, next-to-expire
useAppUpdate.ts is a newer build waiting, and taking it (F14)
serve.ts static server + /api/health ✓ built serve.ts static server + /api/health ✓ built
scripts/ scripts/
build-feed.ts fixtures → public/data/events.v1.json ✓ built build-feed.ts fixtures → public/data/events.v1.json ✓ built
build-static.ts shell + worker into public/, build-stamped ✓ built
parse-fixture.ts run one adapter offline ✓ built parse-fixture.ts run one adapter offline ✓ built
fixtures/<game>/ checked-in raw HTML + expected parse output fixtures/<game>/ checked-in raw HTML + expected parse output
``` ```
@@ -200,6 +203,51 @@ back to the last copy seen). Countdowns run off the device clock, so the app sta
network. Offline state is surfaced in the header and above the footer — stale data must never be network. Offline state is surfaced in the header and above the footer — stale data must never be
presented as current. presented as current.
### Shipping a new version to an open page
A cache-first shell is what makes the offline story work and what makes a deploy invisible: the
reader this app is built for leaves the tab open for days, so a new game, a repaired parser or a
corrected date reaches their device and then sits there unused. Presenting an old app as current is
the same failure as presenting old events as current, so it is disclosed the same way.
```
build:static ──► sw.js stamped with a hash of the built shell
browser byte-compares sw.js on navigation, hourly, and when the tab is
revealed (registration.update)
bytes differ ──► new worker installs, precaches, and WAITS
registration.waiting ≠ null and a controller exists
UpdateNotice: "A new version of Event Clock is ready." [Reload] [×]
Reload ──► postMessage {type:"skip-waiting"} ──► worker activates
──► controllerchange ──► location.reload()
```
Four properties this depends on, each of them load-bearing:
- **The worker never calls `skipWaiting()` on install.** Claiming an open page unasked leaves the
running bundle and the cached shell on two different builds, with nothing on screen saying so. It
activates only on the message the reader's tap sends. A first install has no worker to wait for and
activates immediately regardless — and is deliberately *not* announced, since nothing is being
replaced.
- **The build id is derived, not remembered.** `scripts/build-static.ts` hashes the built shell
(`index.html`, `main.js`, `styles.css`, `sw.js` source) and substitutes it for `__BUILD__` in the
worker, so any shell change alters the worker's bytes and is therefore offered. The predecessor was
a hand-bumped `CACHE_VERSION`, which had already been forgotten once. The substitution **throws**
if the placeholder is gone, because the failure mode is silent.
- **The feed is not part of the id.** It is rewritten twice a day and served network-first, so new
events reach an open page without a reload. Calling that a new version would teach readers to
dismiss the notice unread.
- **The cache name does not move with the build.** Everything in it is refetched (`cache: "reload"`,
since none of these URLs are fingerprinted) on install, so a per-build bucket would buy nothing and
would discard the stored feed — the copy an offline reader is reading.
`src/client/state/useAppUpdate.ts` holds the client half; `sw.js` and the hook cannot import each
other, so `test/update.test.tsx` pins both ends of the `skip-waiting` handshake and the placeholder.
## Deliberate non-choices ## Deliberate non-choices
- **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.ts`. The schema is six tables. - **No ORM.** `bun:sqlite` plus hand-written SQL in `queries.ts`. The schema is six tables.
+22 -1
View File
@@ -20,7 +20,9 @@ A single-page web app that answers three questions:
- Not an account system. There is no login, no profile, no cloud sync. - Not an account system. There is no login, no profile, no cloud sync.
- Not a wiki. It does not explain how to complete an event, only that it exists and when it ends. - Not a wiki. It does not explain how to complete an event, only that it exists and when it ends.
- Not a notification service. No push, no email, no background alerts. (A browser-local reminder - Not a notification service. No push, no email, no background alerts. (A browser-local reminder
is a plausible v2; it is out of scope for v1.) is a plausible v2; it is out of scope for v1.) F14 is not an exception to this: it is the open page
disclosing something about *itself*, in the tab, while the reader is looking at it — nothing is
delivered anywhere, and the app is never told to wake anybody up.
- Not a damage calculator, build planner, or pull tracker. - Not a damage calculator, build planner, or pull tracker.
## Users ## Users
@@ -163,6 +165,25 @@ The sources that compile these calendars, and the studios that make the games, a
screen as the data rather than one navigation step away. The page states plainly that it is screen as the data rather than one navigation step away. The page states plainly that it is
unofficial and unaffiliated, and that the source page is the authority when the two disagree. unofficial and unaffiliated, and that the source page is the authority when the two disagree.
**F14 — Say when a new version of the app is ready.**
F10 caches the shell so the app survives losing signal, and the same cache is why a reader who never
closes the tab keeps running the version they first loaded. A new game, a repaired parser or a
corrected date then reaches their device and sits there unused, with the page looking unchanged and
nothing saying why. **Presenting an old app as current is the same failure as presenting old events
as current** (F7), so it is disclosed the same way: a notice on any screen, with one action that
reloads into the new version.
It is an offer, not a swap. The app never reloads itself — doing so mid-sentence while someone types
in their own event (F13) would cost them work to save the app a tap. It says what a reload costs
(their place on the page) and what it does not (everything they have marked, typed or ticked lives in
`localStorage`, not in the bundle). Dismissing is free and the offer returns on the next load, which
is also why it need not nag.
A first install is not an update and is not announced — nothing is being replaced, and telling a
first-time reader a new version is available would be false. Neither is a feed refresh: new events
arrive without a reload, and calling that a new version would train readers to dismiss the notice
unread.
**F7 — Freshness disclosure.** **F7 — Freshness disclosure.**
The footer shows when the feed was last updated, per game. If a game's data is more than 48 hours The footer shows when the feed was last updated, per game. If a game's data is more than 48 hours
stale, its lane carries a warning badge. Never present stale data as current — the whole value stale, its lane carries a warning badge. Never present stale data as current — the whole value
+1 -1
View File
@@ -12,7 +12,7 @@
"build:js": "bun build src/client/main.tsx --outfile public/main.js --minify", "build:js": "bun build src/client/main.tsx --outfile public/main.js --minify",
"build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:static", "build": "bun run build:feed && bun run build:css && bun run build:js && bun run build:static",
"dev": "bun run build && bun run serve.ts", "dev": "bun run build && bun run serve.ts",
"build:static": "mkdir -p public && sed \"s|__BASE__|${BASE_PATH:-/}|g\" index.html > public/index.html && cp src/client/sw.js public/sw.js && cp src/client/manifest.webmanifest public/manifest.webmanifest && cp src/client/icon.svg public/icon.svg", "build:static": "bun run scripts/build-static.ts",
"serve": "bun run serve.ts" "serve": "bun run serve.ts"
}, },
"dependencies": { "dependencies": {
+107
View File
@@ -0,0 +1,107 @@
/**
* Stage the static half of the build into public/: the shell, the service
* worker, the manifest and the icon.
*
* This was a `sed` chain in package.json. It grew a job a shell one-liner
* cannot do: stamping the service worker with a hash of the built shell, so
* that a deploy is *detectable* by a browser that already has the app. The
* browser decides a worker is new by comparing its bytes, so an update the
* worker file does not mention is an update no reader is ever offered.
*
* Deriving that stamp from the built bytes rather than from a constant someone
* remembers to bump is the point: the previous scheme was a hand-edited
* `CACHE_VERSION`, and it had already been forgotten once and fixed in a
* follow-up commit.
*/
import { mkdir } from "node:fs/promises";
import { basename, resolve } from "node:path";
const ROOT = resolve(import.meta.dir, "..");
const OUT = resolve(ROOT, process.env.PUBLIC_DIR ?? "public");
/** Trailing slash matters: it is a `<base href>`, not a prefix. */
const BASE_PATH = process.env.BASE_PATH ?? "/";
/** The literal the service worker carries so this script has something to replace. */
export const BUILD_PLACEHOLDER = "__BUILD__";
/**
* A short, stable name for exactly these bytes.
*
* Deterministic on purpose: an identical rebuild produces an identical id, so
* nobody is told to reload for a deploy that changed nothing. A timestamp would
* have been easier and would have prompted every reader on every rebuild.
*/
export function buildId(parts: Array<string | Uint8Array>): string {
const hasher = new Bun.CryptoHasher("sha256");
for (const part of parts) hasher.update(part);
return hasher.digest("hex").slice(0, 12);
}
/**
* Stamp the build id into the worker source.
*
* Throws when the placeholder is gone. That is the failure this whole mechanism
* is prone to — an edit to sw.js drops the marker, the substitution quietly
* matches nothing, and readers stop being offered updates with nothing broken
* enough to notice. Better to fail the build.
*/
export function injectBuild(source: string, id: string): string {
if (!source.includes(BUILD_PLACEHOLDER)) {
throw new Error(
`sw.js no longer contains ${BUILD_PLACEHOLDER}; without it a deploy is undetectable by an installed app`,
);
}
return source.replaceAll(BUILD_PLACEHOLDER, id);
}
/** Bytes of a built asset, or null if this stage ran without it. */
async function bytesOf(path: string): Promise<Uint8Array | null> {
const file = Bun.file(path);
return (await file.exists()) ? new Uint8Array(await file.arrayBuffer()) : null;
}
async function main(): Promise<void> {
await mkdir(OUT, { recursive: true });
const shell = (await Bun.file(resolve(ROOT, "index.html")).text()).replaceAll(
"__BASE__",
BASE_PATH,
);
await Bun.write(resolve(OUT, "index.html"), shell);
const workerSource = await Bun.file(
resolve(ROOT, "src/client/sw.js"),
).text();
// Everything a reader would be reloading *for*. Not the feed: it is rewritten
// twice a day by the refresh and served network-first, so new events reach an
// open page without one — and calling that a new version of the app would
// train readers to ignore the notice.
const built = ["main.js", "styles.css"];
const assets: Array<string | Uint8Array> = [shell, workerSource];
for (const name of built) {
const bytes = await bytesOf(resolve(OUT, name));
if (bytes === null) {
console.warn(
`build-static: ${name} is missing from ${basename(OUT)}/ — run the full \`bun run build\``,
);
continue;
}
assets.push(bytes);
}
const id = buildId(assets);
await Bun.write(resolve(OUT, "sw.js"), injectBuild(workerSource, id));
for (const name of ["manifest.webmanifest", "icon.svg"]) {
await Bun.write(
resolve(OUT, name),
Bun.file(resolve(ROOT, "src/client", name)),
);
}
console.log(`build-static: staged ${basename(OUT)}/ at build ${id}`);
}
// Importable for its two pure halves without staging anything.
if (import.meta.main) await main();
+18
View File
@@ -11,7 +11,9 @@ import { Welcome } from "./components/Welcome.tsx";
import { Colophon } from "./components/Colophon.tsx"; import { Colophon } from "./components/Colophon.tsx";
import { Legend } from "./components/Legend.tsx"; import { Legend } from "./components/Legend.tsx";
import { Toast } from "./components/Toast.tsx"; import { Toast } from "./components/Toast.tsx";
import { UpdateNotice } from "./components/UpdateNotice.tsx";
import { KEYS } from "./state/storage.ts"; import { KEYS } from "./state/storage.ts";
import { useAppUpdate } from "./state/useAppUpdate.ts";
import { useMarkSet } from "./state/useMarkSet.ts"; import { useMarkSet } from "./state/useMarkSet.ts";
import { useProgress } from "./state/useProgress.ts"; import { useProgress } from "./state/useProgress.ts";
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
@@ -553,10 +555,26 @@ export function App() {
); );
} }
/**
* Every screen goes through here, which is why the update notice lives here
* rather than beside the list: a reader who is being told "events unavailable"
* or is still picking their games needs the offer at least as much as one
* reading a calendar — a bundle too old for the feed it just downloaded
* (`fetchFeed`'s schemaVersion refusal) lands on exactly that error screen, and
* a reload is the fix.
*/
function Shell({ children }: { children: React.ReactNode }) { function Shell({ children }: { children: React.ReactNode }) {
const update = useAppUpdate();
return ( return (
<div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x"> <div className="mx-auto min-h-full max-w-2xl border-hairline sm:border-x">
{children} {children}
{update.available && (
<UpdateNotice
applying={update.applying}
onApply={update.apply}
onDismiss={update.dismiss}
/>
)}
</div> </div>
); );
} }
+68
View File
@@ -0,0 +1,68 @@
/**
* "A new version is ready."
*
* The one thing this app sells is that what it shows you is current, and a
* cache-first shell quietly breaks that promise for its most loyal reader — the
* one who never closes the tab. This is the disclosure, in the same spirit as
* the offline banner: say what is true, then let them choose.
*
* It is an offer, never a swap. Reloading mid-sentence while someone types their
* own event in would be the app taking a decision that costs them work, so the
* reader picks the moment. Dismissing is free — nothing they have marked, typed
* or ticked lives in the bundle, and the offer comes back next load.
*
* Sits a toast's height up from the bottom edge so an undo toast can never land
* on top of it, and low enough to be reachable with a thumb.
*/
export function UpdateNotice({
applying,
onApply,
onDismiss,
}: {
applying: boolean;
onApply: () => void;
onDismiss: () => void;
}) {
return (
<div
role="status"
aria-live="polite"
className="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex justify-center px-4"
>
<div className="pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border border-hairline bg-raised px-4 py-3 shadow-lg">
<span aria-hidden className="size-1.5 shrink-0 rounded-full bg-near" />
<p className="min-w-0 flex-1 text-xs leading-relaxed text-muted">
A new version of Event Clock is ready.{" "}
<span className="text-faint">
Your marks and notes are kept reloading only loses your place on
the page.
</span>
</p>
<button
type="button"
onClick={onApply}
disabled={applying}
className="shrink-0 rounded-md px-2 py-1 text-xs font-semibold text-near transition-colors duration-150 hover:text-ink disabled:text-faint"
>
{applying ? "Reloading…" : "Reload"}
</button>
<button
type="button"
onClick={onDismiss}
aria-label="Not now"
className="shrink-0 text-faint transition-colors duration-150 hover:text-muted"
>
<svg viewBox="0 0 16 16" className="size-3.5" aria-hidden>
<path
d="M4 4l8 8M12 4l-8 8"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Whether a newer build of the app is sitting on the device, and how to take it.
*
* The service worker serves the shell cache-first (PRD F10), which is what makes
* this app work on a train — and also what makes a deploy invisible. Someone who
* leaves the tab open for a week keeps running the bundle they first loaded: a
* new game, a fixed parser or a corrected date reaches their device and then sits
* there, with nothing anywhere saying why the app looks unchanged. That is the
* same class of failure as presenting stale events as current.
*
* So the deal is: the worker installs quietly and waits, this hook notices it
* waiting, and the reader decides when to lose their scroll position. Nothing
* they have marked, typed or ticked lives in the bundle — it is all in
* localStorage — so reloading costs them nothing but the scroll, and dismissing
* costs them nothing at all, because the offer comes back on the next load.
*/
/** What a page sends a waiting worker to ask it to take over. Matches sw.js. */
export const SKIP_WAITING = "skip-waiting";
/** How often an open page asks the server whether a newer build exists. */
export const CHECK_INTERVAL_MS = 60 * 60 * 1000;
/**
* Is there a new version waiting *for this page*?
*
* A worker that finished installing with no controller in charge is a first
* install, not an update: nothing is being replaced, the reader is already
* looking at the newest thing there is, and telling them a new version is
* available would be a lie on their first visit.
*/
export function isUpdateReady(
registration: { waiting: object | null },
hasController: boolean,
): boolean {
return registration.waiting !== null && hasController;
}
/**
* Whether enough time has passed to ask again.
*
* Hourly, against our own origin — not a source wiki, so § Scraping conduct is
* not in play. The feed itself refreshes twice a day, so anything faster would
* be asking a question that cannot have a new answer.
*/
export function dueForCheck(
lastCheckedMs: number,
nowMs: number,
intervalMs = CHECK_INTERVAL_MS,
): boolean {
return nowMs - lastCheckedMs >= intervalMs;
}
export interface AppUpdate {
/** A newer version is installed, and the reader has not waved it away. */
available: boolean;
/** The reader asked for it; the reload is in flight. */
applying: boolean;
apply: () => void;
dismiss: () => void;
}
export function useAppUpdate(): AppUpdate {
const [ready, setReady] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [applying, setApplying] = useState(false);
/** The worker the Reload button is wired to. */
const waiting = useRef<ServiceWorker | null>(null);
const reloaded = useRef(false);
const reload = useCallback(() => {
if (reloaded.current) return;
reloaded.current = true;
location.reload();
}, []);
useEffect(() => {
if (!("serviceWorker" in navigator)) return;
if (!location.protocol.startsWith("http")) return;
let stopped = false;
let registration: ServiceWorkerRegistration | null = null;
let lastChecked = Date.now();
const offer = (worker: ServiceWorker | null) => {
if (stopped || worker === null) return;
if (!isUpdateReady({ waiting: worker }, navigator.serviceWorker.controller !== null)) return;
if (waiting.current === worker) return;
waiting.current = worker;
// A second, different version arriving re-opens an offer the reader
// dismissed: they declined *that* build, not every future one.
setDismissed(false);
setReady(true);
};
const watch = (worker: ServiceWorker) => {
const onState = () => {
if (worker.state === "installed") offer(worker);
// Superseded, or it failed to install. Drop the offer rather than
// leaving a Reload button wired to a worker that can never take over.
if (worker.state === "redundant" && waiting.current === worker) {
waiting.current = null;
setReady(false);
}
};
worker.addEventListener("statechange", onState);
};
const check = () => {
lastChecked = Date.now();
// A failure here is a reader who is offline or a server that is down.
// Both are normal and neither is worth a word on screen.
void registration?.update().catch(() => {});
};
const onVisible = () => {
if (document.visibilityState !== "visible") return;
if (!dueForCheck(lastChecked, Date.now())) return;
check();
};
const timer = setInterval(() => {
if (dueForCheck(lastChecked, Date.now())) check();
}, CHECK_INTERVAL_MS);
document.addEventListener("visibilitychange", onVisible);
// `ready` rather than registering here: main.tsx registers after load so the
// worker never delays first paint, and this resolves once that has happened.
void navigator.serviceWorker.ready.then((reg) => {
if (stopped) return;
registration = reg;
// A worker that finished installing during an earlier visit is already
// waiting when this page loads — a reload does not release it. Without
// this the reader is never told about a version already on their device.
offer(reg.waiting);
if (reg.installing !== null) watch(reg.installing);
reg.addEventListener("updatefound", () => {
if (reg.installing !== null) watch(reg.installing);
});
});
return () => {
stopped = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", onVisible);
};
}, []);
const apply = useCallback(() => {
const worker = waiting.current;
if (worker === null) return;
setApplying(true);
// Reload when the new worker takes over, not when the message is sent:
// reloading first would re-run the old bundle, because a plain reload does
// not release a waiting worker.
navigator.serviceWorker.addEventListener("controllerchange", reload, {
once: true,
});
worker.postMessage({ type: SKIP_WAITING });
// Belt and braces: if the handover never lands, reload anyway rather than
// leaving a button that visibly did nothing. Worst case they get the same
// version back and the notice returns, which is honest.
setTimeout(reload, 3000);
}, [reload]);
return {
available: ready && !dismissed,
applying,
apply,
dismiss: () => setDismissed(true),
};
}
+77 -18
View File
@@ -1,5 +1,6 @@
/* /*
* Service worker: keep the app usable without a network. * 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 * 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 * next?") is answered entirely by data already on the device, and countdowns
@@ -11,14 +12,37 @@
* feed (events.json) network-first with cache fallback — fresher is better, * feed (events.json) network-first with cache fallback — fresher is better,
* but stale events beat a blank screen * but stale events beat a blank screen
* *
* Bump CACHE_VERSION on any shell change; old caches are deleted on activate. * 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.
*/ */
// v2: reader-authored games and events (PRD F13) changed main.js. The shell is /*
// served cache-first, so without this bump a returning reader keeps the old * Replaced at build time with a hash of the built shell (scripts/build-static.ts).
// bundle and none of the new UI reaches them — the page looks unchanged and *
// nothing anywhere says why. * Its whole job is to make this file's bytes differ when the app differs: the
const CACHE_VERSION = "event-clock-v2"; * 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 // Paths are derived from the registration scope, so the same worker is
// correct at a domain root and under a subpath (GitHub Pages) alike. // correct at a domain root and under a subpath (GitHub Pages) alike.
const BASE = new URL("./", self.registration.scope); const BASE = new URL("./", self.registration.scope);
@@ -26,16 +50,15 @@ const at = (path) => new URL(path, BASE).toString();
const SHELL = ["", "index.html", "styles.css", "main.js"].map(at); const SHELL = ["", "index.html", "styles.css", "main.js"].map(at);
const FEED = new URL("data/events.v1.json", BASE).pathname; const FEED = new URL("data/events.v1.json", BASE).pathname;
const FONT_HOSTS = new Set(["fonts.googleapis.com", "fonts.gstatic.com"]); 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) => { self.addEventListener("install", (event) => {
event.waitUntil( // No skipWaiting here. Taking over an open page unasked means the running
caches // bundle and the cached shell come from two different builds, and the reader
.open(CACHE_VERSION) // is told nothing about either. A first install has no worker to wait for and
// addAll is atomic — one 404 would leave nothing cached, so failures are // activates immediately regardless.
// tolerated per-item and the fetch handler fills gaps later. event.waitUntil(precache());
.then((cache) => Promise.allSettled(SHELL.map((url) => cache.add(url))))
.then(() => self.skipWaiting()),
);
}); });
self.addEventListener("activate", (event) => { self.addEventListener("activate", (event) => {
@@ -44,13 +67,49 @@ self.addEventListener("activate", (event) => {
.keys() .keys()
.then((keys) => .then((keys) =>
Promise.all( Promise.all(
keys.filter((k) => k !== CACHE_VERSION).map((k) => caches.delete(k)), keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)),
), ),
) )
.then(() => self.clients.claim()), .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) => { self.addEventListener("fetch", (event) => {
const { request } = event; const { request } = event;
if (request.method !== "GET") return; if (request.method !== "GET") return;
@@ -80,7 +139,7 @@ self.addEventListener("fetch", (event) => {
* the freshest events we ever saw; a failure falls back to that copy. * the freshest events we ever saw; a failure falls back to that copy.
*/ */
async function feedFirst(request) { async function feedFirst(request) {
const cache = await caches.open(CACHE_VERSION); const cache = await caches.open(CACHE_NAME);
try { try {
const response = await fetch(request); const response = await fetch(request);
if (response.ok) await cache.put(request, response.clone()); if (response.ok) await cache.put(request, response.clone());
@@ -102,7 +161,7 @@ async function feedFirst(request) {
* next visit without ever blocking this one. * next visit without ever blocking this one.
*/ */
async function shellFirst(request) { async function shellFirst(request) {
const cache = await caches.open(CACHE_VERSION); const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request, { ignoreSearch: true }); const cached = await cache.match(request, { ignoreSearch: true });
const network = fetch(request) const network = fetch(request)
+262
View File
@@ -0,0 +1,262 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { UpdateNotice } from "../src/client/components/UpdateNotice.tsx";
import {
CHECK_INTERVAL_MS,
dueForCheck,
isUpdateReady,
SKIP_WAITING,
} from "../src/client/state/useAppUpdate.ts";
import {
BUILD_PLACEHOLDER,
buildId,
injectBuild,
} from "../scripts/build-static.ts";
/**
* Telling a reader that a new version exists.
*
* The mechanism spans three files that cannot import each other — the worker is
* a copied script, the hook is a module, the build stamps one from the other —
* so what is pinned here is each seam between them: the message the page sends
* is the message the worker answers, the placeholder the build replaces is the
* one the worker carries, and a worker that installs does not take over on its
* own.
*/
// ---------------------------------------------------------------------------
// A service worker global, enough of one to run src/client/sw.js against.
// ---------------------------------------------------------------------------
type Listener = (event: Record<string, unknown>) => void;
async function loadWorker(build = "abcdef123456") {
const source = injectBuild(
await Bun.file(new URL("../src/client/sw.js", import.meta.url)).text(),
build,
);
const listeners = new Map<string, Listener[]>();
const calls = { skipWaiting: 0, claim: 0 };
const opened: string[] = [];
const fetched: Request[] = [];
const stores = new Map<string, Map<string, Response>>();
const caches = {
open: async (name: string) => {
opened.push(name);
const store = stores.get(name) ?? new Map<string, Response>();
stores.set(name, store);
return {
put: async (request: Request | string, response: Response) => {
store.set(typeof request === "string" ? request : request.url, response);
},
match: async () => undefined,
};
},
keys: async () => [...stores.keys()],
delete: async (name: string) => stores.delete(name),
};
const self: Record<string, unknown> = {
registration: { scope: "https://example.test/app/" },
location: { origin: "https://example.test" },
addEventListener: (type: string, fn: Listener) => {
listeners.set(type, [...(listeners.get(type) ?? []), fn]);
},
skipWaiting: async () => {
calls.skipWaiting += 1;
},
clients: {
claim: async () => {
calls.claim += 1;
},
},
};
const fetchStub = async (request: Request) => {
fetched.push(request);
return new Response("ok", { status: 200 });
};
// sw.js is a classic script with no imports, so it evaluates against injected
// globals — which is the only way to exercise it offline.
new Function("self", "caches", "fetch", source)(self, caches, fetchStub);
/** Fire a worker lifecycle event and settle whatever it kept alive. */
const dispatch = async (type: string, event: Record<string, unknown> = {}) => {
const kept: Array<Promise<unknown>> = [];
const full = {
...event,
waitUntil: (p: Promise<unknown>) => kept.push(p),
};
for (const fn of listeners.get(type) ?? []) fn(full);
await Promise.all(kept);
};
return { dispatch, self, calls, opened, fetched, stores };
}
describe("the service worker's side of an update", () => {
test("installing does not take over the page on its own", async () => {
const w = await loadWorker();
await w.dispatch("install");
// The whole point: the reader is asked first. A worker that claims the page
// at install leaves the running bundle and the cached shell on two
// different builds and says nothing about either.
expect(w.calls.skipWaiting).toBe(0);
});
test("takes over when the page asks it to", async () => {
const w = await loadWorker();
await w.dispatch("message", { data: { type: SKIP_WAITING } });
expect(w.calls.skipWaiting).toBe(1);
});
test("ignores messages it does not understand", async () => {
const w = await loadWorker();
await w.dispatch("message", { data: { type: "something-else" } });
await w.dispatch("message", { data: "skip-waiting" });
await w.dispatch("message", { data: null });
expect(w.calls.skipWaiting).toBe(0);
});
test("refetches the shell rather than trusting the HTTP cache", async () => {
const w = await loadWorker();
await w.dispatch("install");
// main.js is main.js at every version, so an unqualified fetch can hand a
// brand new worker the previous deploy's bundle — and then the update the
// reader just accepted is the one they already had.
expect(w.fetched.length).toBeGreaterThan(0);
expect(w.fetched.every((r) => r.cache === "reload")).toBe(true);
expect(w.fetched.map((r) => r.url)).toContain(
"https://example.test/app/main.js",
);
});
test("caches under a name that does not move with the build", async () => {
const w = await loadWorker("deadbeef0000");
await w.dispatch("install");
// A per-build cache name would mean every deploy discards the stored feed —
// the copy an offline reader is reading. Everything in here is refetched on
// install, so a new bucket buys nothing and costs that.
expect(w.opened).not.toContain("event-clock-deadbeef0000");
expect(w.opened.every((name) => !name.includes("deadbeef"))).toBe(true);
expect([...w.stores.keys()]).toEqual(["event-clock-v2"]);
});
test("activating clears older caches and keeps the current one", async () => {
const w = await loadWorker();
await w.dispatch("install");
w.stores.set("event-clock-v1", new Map());
await w.dispatch("activate");
expect([...w.stores.keys()]).toEqual(["event-clock-v2"]);
expect(w.calls.claim).toBe(1);
});
test("exposes the build it was stamped with", async () => {
const w = await loadWorker("0123456789ab");
// Both a debugging aid and a guard: a constant nothing reads is one the next
// person deletes, and without it in sw.js a deploy is undetectable.
expect(w.self.BUILD).toBe("0123456789ab");
});
});
// ---------------------------------------------------------------------------
// The build stamp
// ---------------------------------------------------------------------------
describe("the build stamp", () => {
test("the worker carries the placeholder the build replaces", async () => {
const source = await Bun.file(
new URL("../src/client/sw.js", import.meta.url),
).text();
expect(source).toContain(BUILD_PLACEHOLDER);
});
test("identical bytes produce an identical id", () => {
// Otherwise a rebuild that changed nothing tells every reader to reload, and
// a notice that cries wolf is a notice they learn to dismiss unread.
expect(buildId(["shell", "worker"])).toBe(buildId(["shell", "worker"]));
});
test("a changed bundle produces a different id", () => {
const before = buildId(["shell", "worker", new Uint8Array([1, 2, 3])]);
const after = buildId(["shell", "worker", new Uint8Array([1, 2, 4])]);
expect(after).not.toBe(before);
});
test("substitution reaches every copy of the placeholder", () => {
const stamped = injectBuild(
`const BUILD = "${BUILD_PLACEHOLDER}"; log("${BUILD_PLACEHOLDER}");`,
"cafe12345678",
);
expect(stamped).not.toContain(BUILD_PLACEHOLDER);
expect(stamped).toContain('const BUILD = "cafe12345678"');
});
test("a worker with no placeholder fails the build", () => {
// The silent failure this mechanism is prone to: an edit drops the marker,
// the substitution matches nothing, and updates stop being offered with
// nothing visibly broken.
expect(() => injectBuild("const BUILD = \"fixed\";", "abc")).toThrow(
/undetectable/,
);
});
});
// ---------------------------------------------------------------------------
// When to say something
// ---------------------------------------------------------------------------
describe("deciding there is an update", () => {
test("a waiting worker with a page to replace is an update", () => {
expect(isUpdateReady({ waiting: {} }, true)).toBe(true);
});
test("a first install is not an update", () => {
// No controller means nothing is being replaced: the reader is looking at
// the newest thing there is, and "a new version is ready" would be a lie on
// their first visit.
expect(isUpdateReady({ waiting: {} }, false)).toBe(false);
});
test("nothing waiting is nothing to say", () => {
expect(isUpdateReady({ waiting: null }, true)).toBe(false);
});
test("checks are hourly, not per wake-up", () => {
const now = 1_760_000_000_000;
expect(dueForCheck(now, now)).toBe(false);
expect(dueForCheck(now - 60_000, now)).toBe(false);
expect(dueForCheck(now - CHECK_INTERVAL_MS, now)).toBe(true);
});
});
// ---------------------------------------------------------------------------
// What the reader sees
// ---------------------------------------------------------------------------
describe("UpdateNotice", () => {
const noop = () => {};
test("says a new version is ready and offers the reload", () => {
const html = renderToStaticMarkup(
<UpdateNotice applying={false} onApply={noop} onDismiss={noop} />,
);
expect(html).toContain("A new version");
expect(html).toContain("Reload");
// Reloading is a decision with a visible cost and an invisible one; only the
// visible one is real, and saying so is what makes the button safe to press.
expect(html).toContain("marks and notes are kept");
expect(html).toContain('aria-label="Not now"');
});
test("cannot be asked for twice while it is happening", () => {
const html = renderToStaticMarkup(
<UpdateNotice applying onApply={noop} onDismiss={noop} />,
);
expect(html).toContain("Reloading…");
expect(html).toContain("disabled");
});
});