// Service worker TEMPLATE — deploy_pages.mjs stamps e2d0257 and // ["/","/about.html","/board.html","/manifest.webmanifest","/icon-192.png","/assets/main-sLgfbjfE.js","/assets/modulepreload-polyfill-B5Qt9EMX.js","/assets/preload-helper-BSrbm-kb.js","/assets/models-DPWHLyww.js","/assets/main-CNSay7C8.css","/assets/about-BRw9WSHl.css","/assets/board-sIXKcNqU.js","/assets/board-DFM1bO4t.css","/assets/playfair-vietnamese-Cabi7G8-.woff2","/assets/playfair-latin-BOwq7MWX.woff2"] and writes the result to dist/sw.js. Never served in dev. // // Cache layers (all same-origin GET only): // moxhi-shell- app shell: precached index + hashed /assets/* // (cache-first — names are content-hashed), replaced // wholesale on each deploy. // moxhi-data-v1 small mutable files (/model/*.json, weights // manifest.json, manifest.webmanifest, icons): // network-first, cache is the offline fallback. // weights shards (.bin) are NOT touched here — src/engine/weights.js owns // them in its own Cache Storage bucket (content-addressed names). /* global self, caches, fetch, location */ // 20260830092302 (yyyymmddHHMMSS, stamped by deploy_pages.mjs) makes shell // generations SORTABLE by name — activate keeps the newest previous // generation instead of guessing which cache an old tab still needs. const SHELL = 'moxhi-shell-20260830092302-e2d0257'; const DATA = 'moxhi-data-v1'; const PRECACHE = ["/","/about.html","/board.html","/manifest.webmanifest","/icon-192.png","/assets/main-sLgfbjfE.js","/assets/modulepreload-polyfill-B5Qt9EMX.js","/assets/preload-helper-BSrbm-kb.js","/assets/models-DPWHLyww.js","/assets/main-CNSay7C8.css","/assets/about-BRw9WSHl.css","/assets/board-sIXKcNqU.js","/assets/board-DFM1bO4t.css","/assets/playfair-vietnamese-Cabi7G8-.woff2","/assets/playfair-latin-BOwq7MWX.woff2"]; // Model/data files the app fetches during startup — often BEFORE this SW has // claimed the page on its very first visit, so runtime caching would miss // them and the first offline reload would fail engine init. Precache them // here (best-effort; /model/* is immutable-cached so this is usually free). const DATA_PRECACHE = ["/weights/manifest.json","/model/moxhi/tokenizer.json?v=v4.0.3","/model/moxhi/tokenizer_config.json?v=v4.0.3","/weights/hachimi60/manifest.json","/model/hachimi60/tokenizer.json?v=v4.0.3","/model/hachimi60/tokenizer_config.json?v=v4.0.3"]; // cache:'no-cache' on install + network-first fetches: go to the server // (ETag revalidation) instead of reading through the browser HTTP cache. // Without it, /model/*.json — immutable-cached for a year under a FIXED // name — stayed "fresh" locally, so install and networkFirst both kept // re-serving the pre-audit tokenizer through every deploy. const REVALIDATE = { cache: 'no-cache' }; self.addEventListener('install', (event) => { event.waitUntil((async () => { const shell = await caches.open(SHELL); // Not addAll: Pages 308-redirects pretty URLs (/about.html -> /about), // and a cached redirected:true response is rejected when served to a // navigation. Re-wrap each response clean; Promise.all keeps install // all-or-nothing like addAll did. await Promise.all(PRECACHE.map(async (url) => { const res = await fetch(url, REVALIDATE); if (!res.ok) throw new Error(`precache ${url}: ${res.status}`); const body = await res.blob(); await shell.put(url, new Response(body, { status: 200, headers: res.headers })); })); const data = await caches.open(DATA); await Promise.allSettled(DATA_PRECACHE.map(async (url) => { const res = await fetch(url, REVALIDATE); if (res.ok) await data.put(url, res); })); // Activate only after the all-or-nothing shell precache succeeds. This // also lets clients escape an older worker whose HF root navigation was // stranded by the host's / -> /index.html redirect. await self.skipWaiting(); })()); }); self.addEventListener('activate', (event) => { event.waitUntil((async () => { // Keep the newest PREVIOUS shell generation alongside the current one. // skipWaiting + claim means tabs running the old build are now controlled // by THIS worker — their lazy chunks (entity panel, hanviet/names data) // carry old hashes the server no longer serves after a deploy, so wiping // every old cache turned the first panel-open after a deploy into a // ChunkLoadError. Generations older than one are deleted; legacy // un-timestamped caches sort oldest. const gen = (k) => k.match(/^moxhi-shell-(\d{14})-/)?.[1] ?? ''; const shells = (await caches.keys()) .filter((k) => k.startsWith('moxhi-shell-') && k !== SHELL) .sort((a, b) => gen(b).localeCompare(gen(a))); for (const key of shells.slice(1)) await caches.delete(key); await self.clients.claim(); })()); }); // ignoreVary everywhere: entries are keyed by content-hashed (or // version-checked) same-origin URLs, but servers may stamp `Vary: Origin` // (vite preview does) — and module/CSS requests carry an Origin header while // install-time fetches don't, so honoring Vary would miss every hit offline. const MATCH_OPTS = { ignoreVary: true }; // An old tab's lazy import after a deploy: its hashed chunk is not in the // CURRENT shell and 404s on the server — but the previous generation kept by // activate may still hold it. async function matchOlderShell(req) { for (const key of await caches.keys()) { if (!key.startsWith('moxhi-shell-') || key === SHELL) continue; const hit = await (await caches.open(key)).match(req, MATCH_OPTS); if (hit) return hit; } return null; } async function cacheFirst(req) { const cache = await caches.open(SHELL); const hit = await cache.match(req, MATCH_OPTS); if (hit) return hit; try { const res = await fetch(req); if (res.ok) { await cache.put(req, res.clone()); return res; } return (await matchOlderShell(req)) ?? res; } catch (err) { const stale = await matchOlderShell(req); if (stale) return stale; throw err; } } async function networkFirst(req, cacheName, fallbackUrl) { const cache = await caches.open(cacheName); // Pages serves pretty URLs for their .html files (308) — an offline // navigation to the pretty URL must still find the precached .html key. const cachedFallback = async () => { const htmlAlias = `${new URL(req.url).pathname.replace(/\/$/, '')}.html`; return (await cache.match(req, MATCH_OPTS)) ?? (await cache.match(htmlAlias, MATCH_OPTS)) ?? (fallbackUrl ? await cache.match(fallbackUrl, MATCH_OPTS) : null) ?? null; }; try { // fetch(req, init) throws on navigate-mode Requests — pass the URL then. let res = await fetch(req.mode === 'navigate' ? req.url : req, REVALIDATE); // HF Static Spaces redirects / to /index.html. A service worker cannot // return that redirected Response to a controlled navigation: Chrome // rejects it with net::ERR_FAILED. Re-wrap the final 200 body so the // response is no longer marked redirected before caching/returning it. if (req.mode === 'navigate' && res.redirected) { const body = await res.blob(); res = new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers, }); } if (res.ok) { await cache.put(req, res.clone()); return res; } // 5xx (origin hiccup): a stale copy beats an error page. 404 falls // through the same way — a miss in cache just returns the response. return (await cachedFallback()) ?? res; } catch (err) { const hit = await cachedFallback(); if (hit) return hit; throw err; } } self.addEventListener('fetch', (event) => { const req = event.request; if (req.method !== 'GET') return; const url = new URL(req.url); if (url.origin !== location.origin) return; // API responses must never come from cache: a cached /api/board silently // serves a stale leaderboard offline, and POSTs are non-GET anyway. if (url.pathname.startsWith('/api/')) return; if (url.pathname.endsWith('.bin')) return; // weights loader's own cache if (req.mode === 'navigate') { event.respondWith(networkFirst(req, SHELL, '/')); } else if (url.pathname.startsWith('/assets/') || PRECACHE.includes(url.pathname)) { // PRECACHE membership routes to the SHELL cache the install actually // filled — manifest.webmanifest/icon were precached there but previously // served through the data cache, so the first offline load missed them. // (Navigations match the branch above; this only carries subresources.) event.respondWith(cacheFirst(req)); } else { event.respondWith(networkFirst(req, DATA)); } });