A page older than its script is loaded again, not run with it (#449)

Safari on a Mac threw at load on 2026-11-10: the script wired #sleep,
which the page it ran in did not have. #sleep and its script came in
together on 11-08, so the page was older than the script. The service
worker answers a navigation it cannot fetch with the page it kept last,
here one from before 11-08, and that page asked for its script by the old
hash; by then the network answered, and Caddy, which ignores ?v=, sent
today's app.js. The worker even kept it under the old name.

Every ?v= is the first 12 hex digits of the file's SHA-256, so the worker
now checks what it fetched against the name. On a mismatch it does not
keep or give the file, and loads the page again from the network, at most
once in 30 seconds so a page still unreachable is not reloaded round and
round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 03:46:49 +00:00
parent abee943bf5
commit 373f862588
3 changed files with 47 additions and 3 deletions

View File

@@ -48,7 +48,7 @@ sw.addEventListener('fetch', e => {
}
if (req.mode === 'navigate') e.respondWith(page(req));
// Named by a hash of their contents: what is kept is what the network would send.
else if (url.searchParams.has('v') && !url.pathname.startsWith('/api/')) e.respondWith(hashed(req));
else if (url.searchParams.has('v') && !url.pathname.startsWith('/api/')) e.respondWith(hashed(req, e.clientId));
else e.respondWith(networkFirst(req, url.pathname.startsWith('/api/') ? API : PAGE));
});
@@ -70,12 +70,21 @@ async function page(req: Request): Promise<Response> {
}
}
async function hashed(req: Request): Promise<Response> {
async function hashed(req: Request, client: string): Promise<Response> {
const kept = await caches.match(req, { cacheName: PAGE });
if (kept) return kept;
const r = await fetch(req);
if (r.ok) {
const c = await caches.open(PAGE), path = new URL(req.url).pathname;
const url = new URL(req.url);
// The server sends what it has now, whatever version the name asks for. A page kept from
// before a deploy, shown on waking before the network was back, got the new script under its
// old name, died at the first element it lacked (#449), and the new script was kept under the
// old name. Such a page is stale: it is loaded again, from the network, and not given the file.
if (await sha12(await r.clone().arrayBuffer()) !== url.searchParams.get('v')) {
void reloadStale(client);
return Response.error();
}
const c = await caches.open(PAGE), path = url.pathname;
// The version before this one is no use once this one is here.
for (const k of await c.keys()) if (new URL(k.url).pathname === path) await c.delete(k);
await c.put(req, r.clone());
@@ -83,6 +92,23 @@ async function hashed(req: Request): Promise<Response> {
return r;
}
/// What web/build.mts names a file by: the first 12 hex digits of its SHA-256.
async function sha12(body: ArrayBuffer): Promise<string> {
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', body));
return Array.from(d.slice(0, 6), b => b.toString(16).padStart(2, '0')).join('');
}
// Once in a while at most: if the page itself still cannot be had from the network, the kept one
// comes back, asks for its old files again, and would be reloaded round and round.
let reloadedAt = 0;
async function reloadStale(id: string) {
if (Date.now() - reloadedAt < 30_000) return;
const c = await sw.clients.get(id);
if (!c || !('navigate' in c)) return;
reloadedAt = Date.now();
await (c as WindowClient).navigate(c.url);
}
async function networkFirst(req: Request, name: string): Promise<Response> {
try {
const r = await fetch(req);