Files
pondering-orb/web/sw.js
T
Diablo_Rain a1e3e6852f feat: Phase 1.5 — health prober, Oracle's Memory, off-thread EPG, PWA
Resilience + scale pass on top of the v1 core.

Stream health prober:
- New SSRF-validated /probe endpoint (HEAD -> ranged-GET fallback, 6s timeout,
  5-min TTL cache, concurrency-capped, no-store) + orb_probe_total metrics
- Front-end health.js: lazy bounded-concurrency probing (current + sampled
  channels, not the whole catalog), LIVE/DEGRADED/DEAD scoring, guide skull
  glyph, instant no-signal on zap to a known-dead channel

Oracle's Memory (memory.js):
- Session resurrection (last channel, volume, mute, theater/dim/ambient)
- Exponential-decay per-channel uptime history; pinning; dead-channel auto-skip

Off-thread XMLTV:
- lib/xmltv-parse.js (pure, hand-rolled scanner, no DOMParser/regex backtracking)
  run in epg-worker.js module worker; wired into the iptv-org provider
- Unit-tested in node: entity decode, TZ offsets, 20k programmes off-thread

Service worker / PWA:
- manifest.webmanifest + orb icons; sw.js (cache-first shell + hls.js,
  stale-while-revalidate for JSON-over-relay, never caches streams)
- CSP gains worker-src + manifest-src; relay sends no-store on /relay + /probe
  so the SW is the sole metadata freshness authority; webmanifest MIME fix

Docs: README de-emojified (title only) and reframed as a peer parallel build.

Verified: SSRF probes blocked, legit probe + cache, new headers present,
PWA assets served, parser tests 8/8, JS syntax clean, zero Rust warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:25:55 -04:00

87 lines
3.0 KiB
JavaScript

// 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;
}