/// /// import { Serwist, NetworkFirst, CacheFirst, ExpirationPlugin, type RuntimeCaching, } from 'serwist'; declare const self: ServiceWorkerGlobalScope & { __SW_MANIFEST: (string | { url: string; revision: string | null })[]; }; /** * VYNDR service worker (Session 27 — deployment-aware rewrite). * * The PWA stays — it powers push, offline, fast assets, and installs. * The bug was the CACHE POLICY, not the SW. Sports data is time- * sensitive: a 4-hour-old score is wrong, not stale. So pages + API + * everything-dynamic are NetworkFirst (always fresh, cache only as an * offline fallback). Only content-hashed static assets (which change * URL every build) are CacheFirst. * * skipWaiting + clientsClaim mean a new build takes over on the very * next navigation — no "close all tabs and hard-refresh" ritual. */ const OFFLINE_URL = '/offline'; // The complete set of caches this SW version owns. The activate handler // below deletes anything else (the old defaultCache buckets: // start-url, next-data, apis, pages-rsc, static-js-assets, …) so a // returning user isn't served by a stale bucket from a prior version. const CURRENT_CACHES = [ 'pages', 'api-responses', 'next-static', 'static-media', 'fallback', 'offline-fallback', ]; // Navigation NetworkFirst, with a last-resort offline page when both the // network AND the runtime cache miss (e.g. first visit to a never-cached // route while offline). const offlineFallbackPlugin = { handlerDidError: async () => (await caches.match(OFFLINE_URL)) || Response.error(), }; const runtimeCaching: RuntimeCaching[] = [ // API responses — ALWAYS network-first. Schedule, game lines, odds, // streaks must be fresh; the cache is only an offline courtesy. { matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/api/'), handler: new NetworkFirst({ cacheName: 'api-responses', networkTimeoutSeconds: 5, plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 })], }), }, // HTML navigations — network-first so scores/copy are never stale. { matcher: ({ request }) => request.mode === 'navigate', handler: new NetworkFirst({ cacheName: 'pages', networkTimeoutSeconds: 5, plugins: [ new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }), offlineFallbackPlugin, ], }), }, // Next.js content-hashed static JS/CSS — cache-first is safe AND fast: // a new build changes the URL, so old URLs are simply never requested. { matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/_next/static/'), handler: new CacheFirst({ cacheName: 'next-static', plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 })], }), }, // Images + fonts — cache-first (slow to change, expensive to refetch). { matcher: ({ url }) => url.pathname.startsWith('/images/') || url.pathname.startsWith('/icons/') || /\.(?:png|jpe?g|gif|svg|webp|ico|woff2?)$/.test(url.pathname), handler: new CacheFirst({ cacheName: 'static-media', plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 })], }), }, // Everything else (incl. RSC / _next/data payloads) — network-first so // dynamic content stays fresh; cache is offline insurance only. { matcher: () => true, handler: new NetworkFirst({ cacheName: 'fallback', networkTimeoutSeconds: 5, plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 })], }), }, ]; const serwist = new Serwist({ precacheEntries: self.__SW_MANIFEST, skipWaiting: true, clientsClaim: true, navigationPreload: true, runtimeCaching, }); serwist.addEventListeners(); // Pre-cache the offline page so the navigation fallback always has it. self.addEventListener('install', (event) => { event.waitUntil( caches.open('offline-fallback').then((cache) => cache.add(OFFLINE_URL)).catch(() => {}), ); }); // On activation, delete cache buckets from prior SW versions. We keep our // CURRENT_CACHES and anything Serwist manages (its precache cache is // prefixed `serwist`), and drop the rest — clearing the legacy buckets a // previous deploy left behind so users never get served from them. self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((names) => Promise.all( names .filter((name) => !CURRENT_CACHES.includes(name) && !name.startsWith('serwist')) .map((name) => { console.log('[SW] deleting stale cache:', name); return caches.delete(name); }), ), ), ); }); // ---- Web Push (Session 27 keeps the existing handlers) ---- // Pushes are emitted server-side by src/services/distribution/webPush.js. self.addEventListener('push', (event) => { if (!event.data) return; let payload: { title?: string; body?: string; icon?: string; url?: string; tag?: string }; try { payload = event.data.json(); } catch { payload = { title: 'VYNDR', body: event.data.text() }; } const { title = 'VYNDR', body = '', icon = '/icons/icon-192.png', url = '/', tag = 'vyndr-notification', } = payload; event.waitUntil( self.registration.showNotification(title, { body, icon, badge: '/icons/icon-192.png', tag, data: { url }, }), ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); const url = (event.notification.data as { url?: string } | undefined)?.url ?? '/'; event.waitUntil( self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { const existing = clients.find((c) => c.url.endsWith(url)); if (existing) return existing.focus(); return self.clients.openWindow(url); }), ); });