// sw.js — service worker (upgrade #7). // // Two strategies: // - app shell (html/css/js/hls.js/icons): cache-first → instant repeat loads, offline guide UI // - iptv-org JSON proxied via the relay (…/relay?url=…/*.json): stale-while-revalidate // → instant paint from cache, silent refresh in the background // // Live stream manifests + segments (the rest of /relay) and /probe, /metrics are // NEVER cached — the relay marks them no-store and we bypass them here, so the SW // is the sole freshness authority for metadata only. const SHELL = 'orb-shell-v1'; const DATA = 'orb-data-v1'; const SHELL_ASSETS = [ '/', '/index.html', '/styles.css', '/app.js', '/player.js', '/zapper.js', '/guide.js', '/ambient.js', '/lexicon.js', '/memory.js', '/health.js', '/epg-worker.js', '/lib/hls.min.js', '/lib/xmltv-parse.js', '/providers/source-provider.js', '/providers/mock.js', '/providers/iptv-org.js', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', ]; self.addEventListener('install', (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(SHELL_ASSETS)).then(() => self.skipWaiting())); }); self.addEventListener('activate', (e) => { e.waitUntil( caches.keys() .then((keys) => Promise.all(keys.filter((k) => ![SHELL, DATA].includes(k)).map((k) => caches.delete(k)))) .then(() => self.clients.claim()), ); }); self.addEventListener('fetch', (e) => { const req = e.request; if (req.method !== 'GET') return; const url = new URL(req.url); if (url.origin !== self.location.origin) return; // same-origin only if (url.pathname === '/probe' || url.pathname === '/metrics') return; if (url.pathname === '/relay') { const upstream = url.searchParams.get('url') || ''; // Only the JSON metadata is cacheable; stream manifests/segments are not. if (/\.json(\?|$)/i.test(upstream)) { e.respondWith(staleWhileRevalidate(req, DATA)); } return; // streams/segments → straight to the relay, uncached } // App shell / static assets → cache-first. if (SHELL_ASSETS.includes(url.pathname) || url.pathname.startsWith('/icons/') || url.pathname.startsWith('/providers/') || url.pathname.startsWith('/lib/')) { e.respondWith(cacheFirst(req, SHELL)); } }); async function cacheFirst(req, cacheName) { const cache = await caches.open(cacheName); const hit = await cache.match(req); if (hit) return hit; try { const res = await fetch(req); if (res.ok) cache.put(req, res.clone()); return res; } catch { if (req.mode === 'navigate') { const idx = await cache.match('/index.html'); if (idx) return idx; } return new Response('offline', { status: 503, statusText: 'offline' }); } } async function staleWhileRevalidate(req, cacheName) { const cache = await caches.open(cacheName); const hit = await cache.match(req); const network = fetch(req) .then((res) => { if (res.ok) cache.put(req, res.clone()); return res; }) .catch(() => hit || new Response('offline', { status: 503 })); return hit || network; }