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>
102 lines
3.9 KiB
JavaScript
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();
|
|
}),
|
|
);
|
|
});
|