/* Announcements signage app-shell worker. * * Scope is deliberately narrow: this worker makes the /display application * shell available for a future offline start. Slide bytes are NOT its job — * those are owned explicitly by the display asset manager in Cache Storage, so * the two caches never compete for the same responsibility. */ const SHELL_CACHE = "announcements-display-shell-v1"; const SHELL_FALLBACK = "/display"; // A kiosk must reach its slides quickly. When a usable shell is already // cached, a slow or dead origin is never allowed to delay startup: a reverse // proxy whose backend is down can otherwise hold a navigation open for its // full connect timeout, leaving the screen blank the whole time. const SHELL_NETWORK_TIMEOUT_MS = 3_000; // Paths the worker must never handle. Media is the asset manager's; the API and // socket are live server state and must never be answered from a cache. const BYPASS_PREFIXES = ["/api/", "/socket.io/", "/media/", "/health"]; function isBypassed(url) { return BYPASS_PREFIXES.some( (prefix) => url.pathname === prefix.replace(/\/$/, "") || url.pathname.startsWith(prefix), ); } self.addEventListener("install", (event) => { event.waitUntil( (async () => { const cache = await caches.open(SHELL_CACHE); try { await cache.add(new Request(SHELL_FALLBACK, { cache: "reload" })); } catch { // A first install while the network is unavailable still succeeds; the // shell is captured on the next successful navigation instead. } // A kiosk must never be trapped on a stale application version. await self.skipWaiting(); })(), ); }); self.addEventListener("activate", (event) => { event.waitUntil( (async () => { for (const name of await caches.keys()) { // Only this worker's own shell generations are removed. The display // asset cache is left strictly alone. if (name.startsWith("announcements-display-shell-") && name !== SHELL_CACHE) { await caches.delete(name); } } await self.clients.claim(); })(), ); }); async function networkFirst(request) { const cache = await caches.open(SHELL_CACHE); const cached = (await cache.match(SHELL_FALLBACK)) || (await cache.match("/index.html")); // Without a cached shell there is nothing to fall back to, so wait normally. if (!cached) { const response = await fetch(request); if (response && response.ok) await cache.put(SHELL_FALLBACK, response.clone()); return response; } const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), SHELL_NETWORK_TIMEOUT_MS); try { const response = await fetch(request, { signal: abort.signal }); clearTimeout(timer); if (response && response.ok) { // Keep the shell fresh so an online kiosk always updates. await cache.put(SHELL_FALLBACK, response.clone()); return response; } // A production kiosk reaches this origin through a reverse proxy, so an // outage behind it arrives as 502/503/504 rather than a refused // connection. That is still an outage: serve the cached shell. return cached; } catch { clearTimeout(timer); return cached; } } async function cacheFirst(request) { const cache = await caches.open(SHELL_CACHE); const cached = await cache.match(request); if (cached) return cached; const response = await fetch(request); if (response && response.ok) await cache.put(request, response.clone()); return response; } self.addEventListener("fetch", (event) => { const { request } = event; if (request.method !== "GET") return; const url = new URL(request.url); if (url.origin !== self.location.origin) return; if (isBypassed(url)) return; if (request.mode === "navigate") { // Online kiosks always get the newest application; offline ones get the // last known good shell. event.respondWith(networkFirst(request)); return; } // Vite emits content-hashed asset filenames, so these are safely immutable. if (url.pathname.startsWith("/assets/")) { event.respondWith(cacheFirst(request)); } });