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>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user