/* * MCMA service worker — Tier 3 offline support: audio blob cache. * * It sits between the app and the network for audio-stream requests only * (`/streaming/tracks/`). The first time a track is streamed it's copied * into the Cache API (keyed by content id, token stripped); afterwards — or * whenever the backend is unreachable — playback is served straight from the * cache, so already-heard tracks play with no network at all. * * Pure helpers (range parsing, LRU, key normalization) live in sw-core.js so * they can be unit-tested; this file owns the side-effectful cache/network I/O. * Registered as a module worker ({ type: 'module' }), so it uses ES imports. */ import { AUDIO_CACHE, INDEX_URL, MAX_BYTES, COVER_CACHE, MAX_COVERS, trackIdFromUrl, cacheKeyFor, parseRangeHeader, selectEvictions, } from './sw-core.js'; self.addEventListener('install', () => { // Activate immediately on first install / update — no stale SW lingering. self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener('fetch', (event) => { const req = event.request; if (req.method !== 'GET') return; if (trackIdFromUrl(req.url)) { event.respondWith(handleAudio(event)); // audio stream → range-aware cache return; } if (req.destination === 'image') { event.respondWith(handleImage(event)); // cover art → stale-while-revalidate } }); async function handleAudio(event) { const req = event.request; const key = cacheKeyFor(req.url); const range = req.headers.get('range'); const cache = await caches.open(AUDIO_CACHE); // 1) Serve from cache when we have it (works fully offline). const cached = await cache.match(key); if (cached) { event.waitUntil(touch(key)); return range ? buildRangeResponse(cached, range) : cached.clone(); } // 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a // complete copy, then satisfy the original request (range-sliced if asked). try { const fullReq = new Request(req.url, { headers: withoutRange(req.headers) }); const resp = await fetch(fullReq); if (isCacheable(resp)) { event.waitUntil(storeInCache(key, resp.clone())); } return range ? buildRangeResponse(resp, range) : resp; } catch { // Offline and never cached — nothing we can do for this one. return new Response('Offline and not cached', { status: 504, statusText: 'Offline', }); } } /** * Cover art: stale-while-revalidate. Serve the cached image instantly (so the * library renders offline), refresh it in the background, and cache fresh hits. * Cover URLs are token-free and stable, and `` is happy with opaque * cross-origin responses — so unlike audio, these cache even cross-origin. */ async function handleImage(event) { const req = event.request; const cache = await caches.open(COVER_CACHE); const cached = await cache.match(req); const fromNetwork = fetch(req) .then((resp) => { if (resp && (resp.status === 200 || resp.type === 'opaque')) { return cache .put(req, resp.clone()) .then(() => trimCovers(cache)) .then(() => resp); } return resp; }) .catch(() => null); if (cached) { event.waitUntil(fromNetwork); // revalidate without blocking the response return cached; } const resp = await fromNetwork; return resp || new Response('', { status: 504, statusText: 'Offline' }); } /** Keep the cover cache bounded — drop the oldest entries past the cap. */ async function trimCovers(cache) { const keys = await cache.keys(); const excess = keys.length - MAX_COVERS; for (let i = 0; i < excess; i++) await cache.delete(keys[i]); } /** Only cache readable, complete 200 responses — opaque/partial are useless. */ function isCacheable(resp) { return ( resp.status === 200 && resp.type !== 'opaque' && resp.type !== 'opaqueredirect' ); } function withoutRange(headers) { const out = new Headers(); for (const [k, v] of headers.entries()) { if (k.toLowerCase() !== 'range') out.set(k, v); } return out; } /** Build a 206 Partial Content response by slicing a full cached/fetched body. */ async function buildRangeResponse(response, rangeHeader) { const buf = await response.clone().arrayBuffer(); const size = buf.byteLength; const r = parseRangeHeader(rangeHeader, size); const type = response.headers.get('content-type') || 'application/octet-stream'; if (!r) { return new Response(buf, { status: 200, headers: { 'content-type': type, 'content-length': String(size), 'accept-ranges': 'bytes', }, }); } const sliced = buf.slice(r.start, r.end + 1); return new Response(sliced, { status: 206, statusText: 'Partial Content', headers: { 'content-type': type, 'content-range': `bytes ${r.start}-${r.end}/${size}`, 'content-length': String(sliced.byteLength), 'accept-ranges': 'bytes', }, }); } async function responseSize(resp) { const len = resp.headers.get('content-length'); if (len) return Number(len); const blob = await resp.blob(); return blob.size; } // --- LRU index ------------------------------------------------------------- // The index lives as a JSON entry in the same cache. All mutations go through a // single serialized chain so concurrent fetch handlers can't clobber it. let indexChain = Promise.resolve(); async function readIndex(cache) { try { const res = await cache.match(INDEX_URL); if (!res) return {}; return (await res.json()) || {}; } catch { return {}; } } function writeIndex(mutator) { indexChain = indexChain .then(async () => { const cache = await caches.open(AUDIO_CACHE); const index = await readIndex(cache); await mutator(cache, index); await cache.put( INDEX_URL, new Response(JSON.stringify(index), { headers: { 'content-type': 'application/json' }, }), ); }) .catch(() => { /* keep the chain alive even if one write fails */ }); return indexChain; } async function storeInCache(key, resp) { const size = await responseSize(resp.clone()); await writeIndex(async (cache, index) => { for (const ek of selectEvictions(index, size, MAX_BYTES)) { await cache.delete(ek); delete index[ek]; } await cache.put(key, resp); index[key] = { size, lastAccess: Date.now() }; }); } function touch(key) { return writeIndex((_cache, index) => { if (index[key]) index[key].lastAccess = Date.now(); }); } // --- client messaging ------------------------------------------------------ // The app talks to the SW over a MessageChannel: it sends a port, we reply on // it. Used for the offline-cache stats/controls in the UI. self.addEventListener('message', (event) => { const data = event.data || {}; const reply = (result) => { if (event.ports && event.ports[0]) event.ports[0].postMessage(result); }; event.waitUntil( (async () => { try { const cache = await caches.open(AUDIO_CACHE); const index = await readIndex(cache); if (data.type === 'STATS') { const keys = Object.keys(index); const bytes = keys.reduce((s, k) => s + (index[k].size || 0), 0); reply({ count: keys.length, bytes, maxBytes: MAX_BYTES }); } else if (data.type === 'HAS') { reply({ cached: !!index[cacheKeyFor(data.url)] }); } else if (data.type === 'CLEAR') { await Promise.all([ caches.delete(AUDIO_CACHE), caches.delete(COVER_CACHE), ]); reply({ ok: true }); } else { reply({ error: 'unknown-message' }); } } catch (e) { reply({ error: String(e) }); } })(), ); });