Files
musicmouse/web/public/sw.js
Martin Bauer 3fea56d4e1 Stop cover art going stale in two caches at once
Shrinking the covers changed nothing on the device, twice over, because two layers were
independently serving the old bytes and each had the same wrong premise written into its
comment: that a cover is "content-addressed by album id". The id addresses which album
the art belongs to. The bytes behind the URL change whenever the art is reprocessed.

The backend sent `public, max-age=604800`, so every browser that had loaded the page in
the previous week kept decoding the 3000px original out of its own disk cache without
asking. It now sends `no-cache`, which does not mean "do not store" but "revalidate
before reusing" - the file stays cached and the usual answer is a 304.

The service worker cached covers cache-first on a URL that never changes, which means a
client could serve a stale cover for ever; bumping the cache name fixed today's covers
and would have had to be done again for the next batch. It now does
stale-while-revalidate: the cached copy is returned immediately, so a grid of album art
still never waits on the network, and a fresh copy is fetched behind the page for next
time. Neither layer needs a version bump when art is reprocessed again.

The revalidation is off the paint path entirely, which is what makes `no-cache`
affordable here: the service worker answers first, the network call happens behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 16:43:56 +02:00

102 lines
3.9 KiB
JavaScript

// A deliberately small service worker: it exists so iOS treats this as an installable
// app and so the shell survives a flaky wifi moment. It does NOT try to make the player
// work offline - the audio, the library and the state all live on the mouse, and a
// cached answer for any of those would be a lie.
const SHELL = "musikdelphin-shell-v1";
// v2: the backend now downscales cover art to a 640 px long edge on its way into the
// cache (MAX_COVER_PX in library/cache.py). The URL of a cover does not change when its
// contents do, and the handler below is cache-first, so every client that had ever
// loaded a cover went on serving the old 3000 px one from disk - a 9-megapixel decode
// per card, which a trace on musicdolphin showed costing ~1.5 s each and dwarfing
// everything else the page did. Renaming the cache is what retires them: `activate`
// deletes every cache that is not one of these two.
//
// So: change how covers are produced, bump this name. "Immutable per album id" is true
// of which album a cover belongs to, not of the bytes.
const COVERS = "musikdelphin-covers-v2";
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(SHELL)
.then((cache) =>
cache.addAll([
"/",
"/manifest.webmanifest",
"/dolphin-mascot.png",
"/fonts/nunito.css",
"/fonts/nunito-latin.woff2",
"/fonts/nunito-latin-ext.woff2",
]),
)
.then(() => self.skipWaiting()),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(names.filter((n) => n !== SHELL && n !== COVERS).map((n) => caches.delete(n))),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Cover art: stale-while-revalidate. The cached copy is served straight away, which
// is what keeps a grid of album art off the network entirely, and a fresh copy is
// fetched behind the page and put back for next time.
//
// It was plain cache-first, which is subtly wrong in a way that cost a long afternoon:
// the bytes behind a cover URL do change (the backend reprocesses art), and
// cache-first on a URL that never changes means a client can serve a stale cover for
// ever. Revalidating in the background is the cheap way to be both fast and eventually
// right, and it needs no version bump when art is reprocessed.
if (url.pathname.startsWith("/api/albums/")) {
event.respondWith(
caches.open(COVERS).then(async (cache) => {
const hit = await cache.match(request);
const fetching = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone());
return response;
})
// Offline, or the mouse is off: a cached cover is still better than none.
.catch(() => hit);
return hit ?? fetching;
}),
);
return;
}
// Everything else under /api is live state. Never cache it, never serve it stale.
if (url.pathname.startsWith("/api/")) return;
// The shell: network first so a rebuilt frontend is picked up on the next load,
// falling back to the cache when the mouse is off or out of range.
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(SHELL).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(async () => {
const hit = await caches.match(request);
// A client-side app: any in-scope navigation resolves to the one document.
return hit ?? (await caches.match("/")) ?? Response.error();
}),
);
});