A DevTools trace recorded on musicdolphin put the whole question beyond argument: ImageDecodeTask 29,939 ms x20 (~1.5 s each) Decode LazyPixelRef 3,924 ms x20 Decode Image 3,530 ms x22 Paint 99.7 ms Layout 77.3 ms all app JavaScript <400 ms across 22 seconds Image decoding is not the largest cost on that page, it is very nearly the only one. The covers should already have been small - the backend downscales to 640 px now - but the page was still decoding them at 1920, 1600, 1400 px, with deliveryType "cache-storage" on every one. The service worker caches cover art cache-first, keyed on URL, on the premise that it is "immutable per album id". The id did not change; the bytes did. So every client that had ever loaded a cover kept serving the 3000 px original from its own disk and never asked the backend for the new one. Renaming the cache is the retirement mechanism the worker already has - `activate` deletes every cache that is not one of the two current names - so COVERS becomes v2, with a note saying that changing how covers are produced means bumping it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
3.3 KiB
JavaScript
92 lines
3.3 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 is cached aggressively - it is the only heavy thing here, and an album's
|
|
// art does not change from one load to the next. See the note on COVERS above for
|
|
// what to do when the way it is *produced* changes.
|
|
if (url.pathname.startsWith("/api/albums/")) {
|
|
event.respondWith(
|
|
caches.open(COVERS).then(async (cache) => {
|
|
const hit = await cache.match(request);
|
|
if (hit) return hit;
|
|
const response = await fetch(request);
|
|
if (response.ok) cache.put(request, response.clone());
|
|
return response;
|
|
}),
|
|
);
|
|
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();
|
|
}),
|
|
);
|
|
});
|