/// /// /// /// // Production service worker for Docent. // // Behavior: // - Install: precache the SvelteKit shell (build + small static files). // - Activate: drop caches that don't match the current version. // - Fetch: // /api/* → network only (auth state must not be cached) // navigation → cached index.html as offline fallback // /audio /img → stale-while-revalidate from ASSET_CACHE // everything → cache-first from SHELL_CACHE // - Messages: // { type: 'precache-artists', urls: string[] } // Bulk-cache a list of URLs into ASSET_CACHE, posting progress back to // the sender via { type: 'precache-progress', done, total } and a final // { type: 'precache-done' } when finished. // // In dev (`serviceWorker.register: false` + the layout's dev-mode unregister), // this file is bundled but never registered. import { build, files, version } from '$service-worker'; const sw = self as unknown as ServiceWorkerGlobalScope; const SHELL_CACHE = `docent-shell-${version}`; const ASSET_CACHE = `docent-assets-v1`; // version-stable so audio survives shell upgrades const shellAssets = [ ...build, ...files.filter((f) => !f.startsWith('/audio/') && !f.startsWith('/images/')) ]; sw.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(SHELL_CACHE); await cache.addAll(shellAssets); await sw.skipWaiting(); })() ); }); sw.addEventListener('activate', (event) => { event.waitUntil( (async () => { const keys = await caches.keys(); await Promise.all( keys .filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE) .map((k) => caches.delete(k)) ); await sw.clients.claim(); })() ); }); sw.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== location.origin) return; // Never cache API responses — auth state must always be authoritative. if (url.pathname.startsWith('/api/')) return; const isContent = url.pathname.startsWith('/audio/') || url.pathname.startsWith('/images/'); event.respondWith( (async () => { if (request.mode === 'navigate') { const shell = await caches.open(SHELL_CACHE); const cached = (await shell.match('/')) || (await shell.match('/index.html')); if (cached) return cached; try { return await fetch(request); } catch (err) { return new Response('offline', { status: 503, statusText: 'offline' }); } } const cacheName = isContent ? ASSET_CACHE : SHELL_CACHE; const cache = await caches.open(cacheName); const cached = await cache.match(request); if (cached) { if (isContent) { event.waitUntil(refresh(cache, request)); } return cached; } try { const res = await fetch(request); if (res.ok && res.type === 'basic') { cache.put(request, res.clone()); } return res; } catch (err) { return new Response('offline', { status: 503, statusText: 'offline' }); } })() ); }); async function refresh(cache: Cache, request: Request) { try { const res = await fetch(request, { credentials: 'same-origin' }); if (res.ok && res.type === 'basic') await cache.put(request, res); } catch { // Offline; keep the cached version. } } sw.addEventListener('message', (event) => { const data = event.data; if (data?.type !== 'precache-artists' || !Array.isArray(data.urls)) return; const urls: string[] = data.urls; const source = event.source; event.waitUntil(precacheArtists(urls, source)); }); async function precacheArtists(urls: string[], source: Client | ServiceWorker | MessagePort | null) { const cache = await caches.open(ASSET_CACHE); const total = urls.length; let done = 0; let failed = 0; const queue = urls.slice(); const CONCURRENCY = 3; const MAX_ATTEMPTS = 3; const BACKOFF_MS = 800; function postProgress() { try { (source as Client | null)?.postMessage?.({ type: 'precache-progress', done, total, failed }); } catch {} } async function fetchWithRetry(url: string): Promise { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { const res = await fetch(url, { credentials: 'same-origin', cache: 'no-store' }); if (res.ok && res.type === 'basic') { await cache.put(url, res); return true; } if (res.status >= 400 && res.status < 500) return false; // permanent } catch {} if (attempt < MAX_ATTEMPTS) { await new Promise((r) => setTimeout(r, BACKOFF_MS * attempt)); } } return false; } async function worker() { while (queue.length > 0) { const url = queue.shift(); if (!url) break; try { const cached = await cache.match(url); if (!cached) { const ok = await fetchWithRetry(url); if (!ok) failed++; } } catch { failed++; } done++; postProgress(); } } await Promise.all(Array.from({ length: CONCURRENCY }, worker)); try { (source as Client | null)?.postMessage?.({ type: 'precache-done', done, total, failed }); } catch { // Sender went away. } }