// 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(); }), ); });