/// // Calm, lightweight service worker. It does NOT bulk-precache on install — the // browser already caches the year-immutable assets on its own, and a cold // precache storm right after a deploy hammers the (residential) origin and slows // first loads. Instead: cache the shell for an offline fallback, and cache other // assets lazily as they're actually used. Live data (API + server-rendered pages) // is always fetched fresh. import { version } from '$service-worker'; const CACHE = `upbeat-${version}`; // How long a navigation may wait on the network before the cached shell is // served instead. Long enough for a healthy fetch, short enough that a stalled // cellular/origin hop never reads as a broken site. const NAV_TIMEOUT_MS = 2500; // Paths the FastAPI server owns — the SW must NOT intercept or cache these. function isServerPath(p) { if (p.startsWith('/api/') || p.startsWith('/a/') || p.startsWith('/docs')) return true; return p === '/openapi.json' || p === '/healthz' || p === '/today' || p === '/sitemap.xml'; } self.addEventListener('install', (event) => { // Best-effort: grab the app shell as an offline fallback. No bulk precache. event.waitUntil( caches.open(CACHE).then((c) => c.add('/').catch(() => {})).then(() => self.skipWaiting()) ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches .keys() .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) .then(() => self.clients.claim()) ); }); self.addEventListener('fetch', (event) => { const { request } = event; if (request.method !== 'GET') return; const url = new URL(request.url); if (url.origin !== location.origin) return; if (isServerPath(url.pathname)) return; // let the network/server handle these // Navigations: network-first, but a SLOW network must not mean a white screen — // "slow" and "failed" both fall back to the cached shell. We race the fetch // against a short grace timer: network wins → freshest HTML as usual; timer // wins (or 5xx/failure) → serve the cached shell instantly while the network // response still lands in the cache for next time. A slightly stale shell is // safe: deploys keep old immutable chunks for a 14-day grace window. if (request.mode === 'navigate') { event.respondWith( (async () => { const cache = await caches.open(CACHE); const cached = await cache.match('/'); const network = fetch(request) .then((res) => { if (res && res.ok && (res.headers.get('content-type') || '').includes('text/html')) { cache.put('/', res.clone()).catch(() => {}); } return res; }) .catch(() => null); if (!cached) return (await network) || Response.error(); // first visit: network only const winner = await Promise.race([ network, new Promise((resolve) => setTimeout(() => resolve('slow'), NAV_TIMEOUT_MS)), ]); return winner && winner !== 'slow' && winner.ok ? winner : cached; })() ); return; } // Static assets: serve from cache if present, else fetch and cache for next time // (cache-as-you-go — no install storm). Only cache successful same-origin GETs. event.respondWith( caches.match(request).then((cached) => { if (cached) return cached; return fetch(request).then((res) => { if (res && res.ok && res.type === 'basic') { const copy = res.clone(); caches.open(CACHE).then((c) => c.put(request, copy)).catch(() => {}); } return res; }); }) ); });