/// // Lives in src/, not static/, so SvelteKit builds it and injects the manifest // below. That matters: `build` and `files` are generated from the actual output, // so the precache list can't drift, and `version` changes every deploy, so cache // names rotate automatically instead of needing a hand-edited version string. import { build, files, version } from '$service-worker'; const CACHE = `atlas-of-skills-${version}`; // Content-hashed build output + everything in static/. Hashed assets are // immutable, so once cached they never need revalidating. const PRECACHE = [...build, ...files]; self.addEventListener('install', (event) => { event.waitUntil( (async () => { const cache = await caches.open(CACHE); // Individually, not cache.addAll(): addAll is atomic, so a single 404 // failed the whole install and left the site with no offline support at // all — silently. const results = await Promise.allSettled( PRECACHE.map((asset) => cache.add(new Request(asset, { cache: 'reload' }))) ); const failed = results.filter((r) => r.status === 'rejected').length; if (failed) { console.warn(`[sw] ${failed}/${PRECACHE.length} assets failed to precache`); } })() ); }); self.addEventListener('activate', (event) => { event.waitUntil( (async () => { // Drop every previous version's cache. Because the name embeds `version`, // this now actually prunes on each deploy instead of accumulating stale // hashed chunks forever. const keys = await caches.keys(); await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))); await self.clients.claim(); })() ); }); /** Assets emitted by the build are content-hashed, so cache-first is safe. */ const PRECACHED = new Set(PRECACHE); self.addEventListener('fetch', (event) => { const { request } = event; // Never touch non-GET (cache.put throws on them) or cross-origin. if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; event.respondWith(handle(event, url)); }); async function handle(event, url) { const { request } = event; const cache = await caches.open(CACHE); // Content-hashed build output: cache-first, it can never go stale. if (PRECACHED.has(url.pathname) && build.includes(url.pathname)) { const hit = await cache.match(url.pathname); if (hit) return hit; } // Everything else — navigations and unhashed static files (icons, headshot, // the résumé PDF) — is network-first. The old worker served these cache-first // with no expiry, which meant replacing the résumé at the same URL never // reached anyone who had already visited. try { const response = await fetch(request); // Only cache real successes. A 404/500 body used to get written and then // served as the offline fallback. Opaque/redirected responses throw on // cache.put, so they're excluded too. if (response.status === 200 && response.type === 'basic' && !response.redirected) { // waitUntil so the write survives worker termination. event.waitUntil(cache.put(request, response.clone())); } return response; } catch { const hit = await cache.match(request); if (hit) return hit; // Navigations fall back to the cached shell. if (request.mode === 'navigate') { const shell = await cache.match('/'); if (shell) return shell; } // Every other failure must still resolve to a Response. Returning // undefined here (the previous behaviour for non-image assets) makes // respondWith throw a TypeError and turns a soft failure into a hard one. if (request.destination === 'image') { return new Response( '' + '' + 'Offline' + '', { status: 503, headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' } } ); } return new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' } }); } } // The page posts this after the user accepts an update prompt. Deliberately no // skipWaiting() in install: taking over a page whose JS came from the previous // deploy is how you get version skew. self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') self.skipWaiting(); });