0b761f684b
Server (relay/SSRF): - Bound connect+TLS+request under CONNECT_TIMEOUT and body download under BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely - Add inbound header_read_timeout to defeat slow-loris connections - Allowlist relayed Content-Type (fall back to octet-stream) to block content-confusion from attacker-controlled stream origins - Reuse one DNS resolver process-wide instead of rebuilding it per request (restores hickory's DNS cache; drops per-segment resolver setup) - Probe cache: random per-process hash seed (DefaultHasher was deterministic) + bounded growth with expired-entry eviction - Real Ctrl-C handler for a clean shutdown (honours the banner) - Collapse the wrap_raw no-op alias into wrap Frontend: - Guide: visible focus ring tracking arrow-key navigation - Player: buffering/tuning indicator while <video> is stalled - prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA) - Service worker: date-stamped cache names so shell updates propagate Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
3.3 KiB
JavaScript
90 lines
3.3 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.
|
|
|
|
// Bump these on every shell change so returning visitors actually receive the
|
|
// new assets — a static 'v1' name would serve stale JS/CSS indefinitely. The
|
|
// activate handler deletes any cache whose name isn't in this current pair.
|
|
const SHELL = 'orb-shell-20260615';
|
|
const DATA = 'orb-data-20260615';
|
|
|
|
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;
|
|
}
|