/* Service Worker: lightweight caching for a static Astro site. - Navigations: network-first (avoid stale HTML indefinitely) - Site shell: pre-cache on install - Images: cache-first with bounded eviction */ // Bump this value on deploy to invalidate caches. const CACHE_VERSION = "v1"; const CACHE_SHELL = `shell-${CACHE_VERSION}`; const CACHE_PAGES = `pages-${CACHE_VERSION}`; const CACHE_MEDIA = `media-${CACHE_VERSION}`; const SHELL_ASSETS = ["/", "/styles/global.css", "/favicon.svg", "/favicon.ico", "/robots.txt"]; // Keep media cache bounded so we don't grow indefinitely. const MAX_MEDIA_ENTRIES = 80; const isGet = (request) => request && request.method === "GET"; const isNavigationRequest = (request) => request.mode === "navigate" || request.destination === "document"; const isImageRequest = (request, url) => { if (request.destination === "image") return true; const p = url.pathname.toLowerCase(); return ( p.endsWith(".png") || p.endsWith(".jpg") || p.endsWith(".jpeg") || p.endsWith(".webp") || p.endsWith(".gif") || p.endsWith(".avif") || p.endsWith(".svg") ); }; async function trimCache(cacheName, maxEntries) { const cache = await caches.open(cacheName); const keys = await cache.keys(); const extra = keys.length - maxEntries; if (extra <= 0) return; // Cache keys are returned in insertion order in practice; delete the oldest. for (let i = 0; i < extra; i += 1) { await cache.delete(keys[i]); } } async function cachePutSafe(cacheName, request, response) { // Only cache successful or opaque responses. Avoid caching 404/500 HTML. if (!response) return; if (response.type !== "opaque" && !response.ok) return; const cache = await caches.open(cacheName); await cache.put(request, response); } self.addEventListener("install", (event) => { event.waitUntil( (async () => { const cache = await caches.open(CACHE_SHELL); await cache.addAll(SHELL_ASSETS); // Activate new worker ASAP to pick up new caching rules. await self.skipWaiting(); })(), ); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { const keep = new Set([CACHE_SHELL, CACHE_PAGES, CACHE_MEDIA]); const keys = await caches.keys(); await Promise.all(keys.map((k) => (keep.has(k) ? Promise.resolve() : caches.delete(k)))); await self.clients.claim(); })(), ); }); self.addEventListener("fetch", (event) => { const { request } = event; if (!isGet(request)) return; const url = new URL(request.url); // Only handle http(s). if (url.protocol !== "http:" && url.protocol !== "https:") return; // Network-first for navigations (HTML documents). Cache as fallback only. if (isNavigationRequest(request)) { event.respondWith( (async () => { try { const fresh = await fetch(request); // Cache a clone so we can serve it when offline. await cachePutSafe(CACHE_PAGES, request, fresh.clone()); return fresh; } catch { const cached = await caches.match(request); if (cached) return cached; // Fallback: try cached homepage shell. const home = await caches.match("/"); if (home) return home; throw new Error("No cached navigation fallback."); } })(), ); return; } // Cache-first for images/media with bounded cache size. if (isImageRequest(request, url)) { event.respondWith( (async () => { const cached = await caches.match(request); if (cached) return cached; const res = await fetch(request); await cachePutSafe(CACHE_MEDIA, request, res.clone()); await trimCache(CACHE_MEDIA, MAX_MEDIA_ENTRIES); return res; })(), ); return; } // Stale-while-revalidate for styles/scripts/fonts from same-origin. if ( url.origin === self.location.origin && (request.destination === "style" || request.destination === "script" || request.destination === "font") ) { event.respondWith( (async () => { const cached = await caches.match(request); const networkPromise = fetch(request) .then(async (res) => { await cachePutSafe(CACHE_SHELL, request, res.clone()); return res; }) .catch(() => null); return cached || (await networkPromise) || fetch(request); })(), ); } });