screentinker/frontend/sw-admin.js
ScreenTinker d4f71bbf3a fix(sw): stop the admin service worker from breaking video playback
sw-admin.js (scope '/') intercepted every non-API GET with clone+cache+respond.
Video requests are Range requests -> 206 Partial Content, which can't be cached;
cache.put threw and the handler errored ('ServiceWorker encountered an unexpected
error'), so .mp4s never loaded on any page this SW controls - including the web
player at /player, which then thrashed between items.

Now bypass (network-only) non-GET, Range requests, and /uploads//player/api/
socket.io; only cache same-origin 200s. CACHE bumped to v4 so clients pick up the
new SW + drop the stale bucket.
2026-06-08 23:08:13 -05:00

59 lines
2.8 KiB
JavaScript

// Service worker for the admin SPA. Strategy is network-first with offline
// fallback (the server sends Cache-Control: no-cache + ETag, so 304s stay fast).
// Cache name is bumped on each release that must invalidate stale client caches:
// v2 - first network-first version (replaced a cache-first SW that shipped stale JS)
// v3 - force returning clients to drop the old bucket so the "Add user" admin
// button (and any client still on a pre-v2 cache-first SW) lands.
// v4 - stop intercepting media/content/player + range requests. The old handler
// clone+cache+respond'd every non-API request; a video Range request gets a
// 206 (uncacheable) which broke the handler ("ServiceWorker encountered an
// unexpected error"), so videos never loaded on pages this SW controls
// (e.g. the web player, since this SW's scope is '/').
// Changing this string is what makes the browser detect a new SW + run activate,
// which deletes every cache key != CACHE below.
const CACHE = 'rd-admin-v4';
self.addEventListener('install', e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll([
'/', '/index.html', '/css/variables.css', '/css/reset.css', '/css/main.css',
'/js/app.js', '/js/api.js', '/js/socket.js', '/js/i18n.js',
'/js/components/toast.js'
])));
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))));
self.clients.claim();
});
self.addEventListener('fetch', e => {
const req = e.request;
// Only handle same-origin GET navigations/assets. Everything else hits the
// network unmediated:
// - non-GET: cache.put() rejects on them anyway.
// - Range requests (video seeking): the response is 206 Partial Content, which
// is uncacheable and breaks clone+cache+respond -> "ServiceWorker encountered
// an unexpected error", stalling video playback.
// - /uploads/ (content/media), /player (the web player), /api/, /socket.io/:
// not ours to cache; the player + server set their own cache headers.
if (req.method !== 'GET' || req.headers.has('range')) return;
const url = req.url;
if (url.includes('/api/') || url.includes('/socket.io/') ||
url.includes('/uploads/') || url.includes('/player')) return;
// Network-first: respect the server's Cache-Control: no-cache + ETag (304s stay
// fast); fall back to cache only when offline. Only cache full, same-origin 200s.
e.respondWith(
fetch(req)
.then(resp => {
if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone();
caches.open(CACHE).then(c => c.put(req, copy)).catch(() => {});
}
return resp;
})
.catch(() => caches.match(req))
);
});