diff --git a/frontend/src/app.html b/frontend/src/app.html index 6cdf730..3079735 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -38,6 +38,19 @@ // screen. Show a calm recovery card if the app hasn't mounted, and reload // once on a chunk/preload failure (e.g. a just-deployed hashed chunk). (function () { + // The service worker was removed (it risked first loads). Actively + // unregister any worker a returning visitor still has + wipe its caches, + // so nobody stays stuck on the old boot path. Runs on every load; cheap. + if ('serviceWorker' in navigator) { + try { + navigator.serviceWorker.getRegistrations() + .then(function (rs) { rs.forEach(function (r) { r.unregister(); }); }) + .catch(function () {}); + if (self.caches && caches.keys) { + caches.keys().then(function (ks) { ks.forEach(function (k) { caches.delete(k); }); }).catch(function () {}); + } + } catch (e) { /* best effort */ } + } var sent = false; function report(reason) { if (sent) return; sent = true; // one beacon per page diff --git a/frontend/src/service-worker.js b/frontend/src/service-worker.js deleted file mode 100644 index 0f233e7..0000000 --- a/frontend/src/service-worker.js +++ /dev/null @@ -1,114 +0,0 @@ -/// -// 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'; -} - -// Mutable files Caddy serves with `no-cache` — the browser's HTTP cache -// revalidates these correctly, but SW cache-as-you-go would pin them until the -// next SW version and silently defeat that policy. version.json is the big one -// (it's how the app detects a new deploy); stale word lists could drift from -// the server's validated answer pool. Let the network/browser cache own them. -function isMutablePath(p) { - return ( - p === '/service-worker.js' || - p === '/_app/version.json' || - p === '/manifest.webmanifest' || - p === '/words-5.json' || - p === '/words-6.json' || - p === '/favicon.svg' || - p === '/logo.svg' || - p === '/logo-email.png' || - p.startsWith('/icon-') - ); -} - -self.addEventListener('install', (event) => { - // Best-effort: grab the app shell as an offline fallback. No bulk precache. - // Deliberately NO skipWaiting(): a new worker installs quietly and waits, so - // it never seizes a page that's mid-boot. It takes control on the next - // navigation, when the old worker has no clients — the post-deploy first-load - // then completes under the stable old worker (with its cache intact) against - // the warmed edge, instead of having its cache yanked mid-load. - event.waitUntil(caches.open(CACHE).then((c) => c.add('/').catch(() => {}))); -}); - -self.addEventListener('activate', (event) => { - // Runs only when this worker actually activates (next navigation, never - // mid-boot), so deleting old version caches here is safe — and old immutable - // chunks live on at the origin for a 14-day grace window regardless. No - // clients.claim(): pages adopt this worker on their own next navigation. - event.waitUntil( - caches - .keys() - .then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) - ); -}); - -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) || isMutablePath(url.pathname)) return; // network/browser cache owns 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; - }); - }) - ); -}); diff --git a/frontend/static/service-worker.js b/frontend/static/service-worker.js new file mode 100644 index 0000000..1433c38 --- /dev/null +++ b/frontend/static/service-worker.js @@ -0,0 +1,31 @@ +// Kill switch — the app no longer uses a service worker. +// +// It was added for nice-to-have PWA/offline caching, but it sat in the boot +// path and put first loads at risk (post-deploy chunk stalls), which a young +// site with few visitors can't afford. Browser HTTP cache + the Cloudflare edge +// are enough for a news site. +// +// Existing visitors still have the OLD worker registered and controlling their +// pages. This replacement (served at /service-worker.js, no-cache) takes over, +// wipes the old caches, and unregisters itself. There is deliberately NO fetch +// handler, so every request goes straight to the network / browser HTTP cache. +// The app also unregisters any worker on load (see app.html) as a backstop. +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + try { + const keys = await caches.keys(); + await Promise.all(keys.map((k) => caches.delete(k))); + } catch (e) { + /* best effort */ + } + try { + await self.registration.unregister(); + } catch (e) { + /* best effort */ + } + })() + ); +});