182 lines
5.9 KiB
TypeScript
182 lines
5.9 KiB
TypeScript
/// <reference lib="webworker" />
|
|
/// <reference types="@serwist/next/typings" />
|
|
|
|
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);
|
|
}),
|
|
);
|
|
});
|