// v24: an empty playlist payload no longer prunes. `assignments: []` is what the server sends for a // device between playlists AND for a snapshot that failed to parse, and treating it as "keep // nothing" wiped the panel's entire offline library on a message that means nothing of the sort. // v23: offline.cache is claimed only when a worker is actually IN CONTROL — a real BrightSign // widget exposes navigator.serviceWorker, refuses to register one, and was advertising the // capability to the fleet regardless. // v22: worker scope widened to '/' (it never controlled /player before) + prune-to-playlist, so a // replaced asset's superseded copy is reclaimed rather than waiting on the quota. // v21: chunked resumable content prefetch + revision-keyed media URLs — index.html gained // mediaUrl()/requestOfflineCache() and this worker gained the message handler, so an old shell // cache would pair a new worker with a player that never posts it a playlist. // v20: rc3 changed the fetch strategy AND the shipped player assets. The activate handler deletes // every cache whose name does not match, so leaving this at v19 kept the previous shell cache alive // — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat. // Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this // costs a small re-download and never re-fetches the playlist. const CACHE_NAME = 'rd-player-v24'; // Content lives in its own cache so the shell can be re-versioned (the activate handler deletes // every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still // perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist. const CONTENT_CACHE = 'rd-content-v1'; // Single source, shared with server/lib/player-cache-policy.js and its Node tests. A service worker // cannot require(), so this is importScripts against the route that serves that same file. importScripts('/player/cache-policy.js'); const POLICY = self.PlayerCachePolicy; // Install: skip waiting to activate immediately self.addEventListener('install', (event) => { self.skipWaiting(); }); // Activate: clean old caches (including old content cache), claim clients self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then(keys => Promise.all( // CONTENT_CACHE is spared deliberately: it holds media, not code, and dropping it on every // shell version bump would make each deploy re-download the whole playlist — over a link // that may be exactly what is broken. keys.filter(k => k !== CACHE_NAME && k !== CONTENT_CACHE).map(k => caches.delete(k)) )).then(() => self.clients.claim()) ); }); // Fetch handler — ONLY cache player page and static assets. // Content files (/uploads/content/) are NOT intercepted — the server sets // Cache-Control: public, max-age=2592000, immutable which lets the browser // cache them natively without SW complications (range requests, opaque // responses, video seeking, etc.) self.addEventListener('fetch', (event) => { // Only handle GET requests if (event.request.method !== 'GET') return; const url = new URL(event.request.url); // Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change // without the rev changing. ignoreSearch is deliberately NOT used here: the query string carries // the rev, and ignoring it would match a different revision's entry, which is the staleness we // are trying to remove. // // MEASURED LIMIT, do not read more into this branch than it delivers. The player mounts widgets in // an iframe sandboxed to `allow-scripts` with NO allow-same-origin (index.html, // renderWidgetBuffered), so that frame is an OPAQUE-origin client — and a service worker does not // control opaque-origin clients. Its navigation request never reaches this handler. Driven in // Chrome against a live server: a clock widget mounted five times over 25s and the shell cache // held zero widget entries, while a plain fetch() of the identical URL from the controlled page // was intercepted and stored. So this branch serves anything that reaches it — a same-origin // fetch, a future non-sandboxed mount — and today the player's own widgets are not that. // // What actually keeps widgets rendering offline right now is the HTTP cache plus the server's // `max-age=31536000, immutable` on a rev-pinned render (routes/widgets.js). That is sound in a // desktop browser and is NOT a documented-persistent store on BrightSign, which guarantees // survival across reboots for IndexedDB, localStorage and SQLite only — the same gap that made // content caching necessary. Closing it properly means routing the render through a same-origin // fetch and mounting it as srcdoc; granting the frame allow-same-origin instead would hand widget // scripts the player's origin, which is not a trade worth making for an offline nicety. if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) { event.respondWith( caches.match(event.request).then(cached => { if (cached) return cached; return fetch(event.request).then(response => { if (response.ok && response.type !== 'opaque') { const clone = response.clone(); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); } return response; }).catch(() => new Response( '', { status: 200, headers: { 'Content-Type': 'text/html' } } )); }) ); return; } // Player page and static assets: network-first, fall back to cache if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') { event.respondWith( fetch(event.request).then(response => { if (response.ok && response.type !== 'opaque') { const clone = response.clone(); caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); } return response; }).catch(() => caches.match(event.request, { ignoreSearch: true }).then(cached => cached || new Response('Offline', { status: 503, statusText: 'Service Unavailable', headers: { 'Content-Type': 'text/plain' } }) ) ) ); return; } // Content files: cache the bytes so a player that loses its server keeps playing. // // This used to be left to the browser's HTTP cache (the server sends // `Cache-Control: public, max-age=2592000, immutable`). That is fine on a desktop and is NOT a // documented-persistent store on BrightSign, which guarantees survival across reboots for // IndexedDB, localStorage and SQLite only. A panel could come back from a power cut with its // playlist intact (localStorage) and no media to play. // // Range requests are the reason this was avoided, and POLICY is what makes it safe: we only ever // STORE complete 200s, and slice them ourselves when a seeking video asks for a range. if (POLICY && POLICY.isCacheableContent(url, event.request.method)) { event.respondWith(handleContent(event.request)); return; } // Everything else (API calls, sockets, etc.): don't intercept. // Returning without event.respondWith lets the browser handle it natively. }); /* * PREFETCH — the half that makes the cache fill on a link that cannot carry a whole asset. * * handleContent below stores an asset when a single fetch() of it happens to succeed. On a good * link that is everything. On a marginal one (the one-bar 5G site this came from) a 200MB fetch * never completes, every retry starts from nothing, and the cache stays empty — so the panel has * nothing to fall back on the moment the uplink drops. The Android player had the identical bug and * was fixed by resuming; a service worker has no file handle to append to, so progress accumulates * as separate cache entries and is assembled once every piece is present. * * Driven by the player rather than by playback: it posts its current media URLs after each playlist * update, and this works through them ONE AT A TIME. Deliberately not started from the fetch * handler — that would put the accumulator in competition with the playing video for the same * scarce bandwidth, which is worse than either alone. */ const prefetching = new Set(); let prefetchChain = Promise.resolve(); self.addEventListener('message', (event) => { const data = event.data; if (!data || data.type !== 'st-cache-playlist' || !Array.isArray(data.urls)) return; // The player sends the COMPLETE set of media this display needs, so anything else in the content // cache is superseded and can go. Revision-keyed sweeping alone is not enough: replacing an asset // writes a new randomly-named file, so the old copy lives at a different PATH and nothing keyed // on the asset path can find it. Without this the cache only grows, and on a panel with a 1GB // widget quota a handful of replaced videos is the entire budget. // // An EMPTY list is never a prune instruction, and that distinction is the whole guard. "This // display needs nothing" and "the payload did not arrive intact" are the same message on the // wire, and the second one is not rare: buildPlaylistPayload() yields `assignments: []` for a // device between playlists, for a playlist that has never been published, AND for a // published_snapshot that fails to JSON.parse. Honouring it deleted every byte of media the panel // held — reproduced here: three cached assets, one empty payload, cache emptied — which is only // survivable while the uplink is up, i.e. exactly when this cache does not matter. A cache that is // kept too long costs disk the quota reclaims anyway; one dropped at the wrong moment is a dark // screen with no way back. if (data.prune && data.urls.length > 0) { prefetchChain = prefetchChain.then(() => pruneToPlaylist(data.urls)).catch(() => {}); } for (const url of data.urls) { if (typeof url !== 'string' || !POLICY || !POLICY.isCacheableContent(url, 'GET')) continue; if (prefetching.has(url)) continue; // single-flight: a 60s playlist sweep must not restart it prefetching.add(url); // Serialised. Three concurrent chunk streams on a link that cannot finish one is how you get // three unfinished downloads instead of one finished one. prefetchChain = prefetchChain .then(() => ensureCached(url)) .catch(() => {}) .then(() => { prefetching.delete(url); }); } }); /* * Fetch [url] into the content cache, one chunk at a time, resuming across calls. * * Returns when the asset is whole OR when a chunk fails — the caller does not retry, because the * player will ask again on its next playlist sweep and whatever landed is still on disk. That is * the entire point: attempts accumulate instead of restarting. */ async function ensureCached(url) { const cache = await caches.open(CONTENT_CACHE); if (await cache.match(url, { ignoreVary: true })) { await sweepOldRevisions(cache, url); return; } const metaKey = POLICY.chunkKey(url, 'meta'); let meta = null; const metaHit = await cache.match(metaKey); if (metaHit) { try { meta = await metaHit.json(); } catch (e) { meta = null; } } // Learn the size and validator from the first ranged request, or trust what a previous call // already learned. A server that answers 200 here has no range support: fall back to storing it // whole, which is the pre-existing behaviour and is correct, just not resumable. if (!meta) { const probe = await fetch(new Request(url, { headers: { Range: 'bytes=0-' + (POLICY.CHUNK_BYTES - 1) } })); if (probe.status === 200) { if (POLICY.isStorable(probe)) await storeContent(cache, new Request(url), probe); return; } const cr = POLICY.parseContentRange(probe.headers.get('Content-Range')); if (probe.status !== 206 || !cr || !(cr.total > 0)) return; meta = { total: cr.total, validator: POLICY.validatorOf(probe.headers), type: probe.headers.get('Content-Type') || '' }; if (!meta.validator) { // Nothing to detect a changed asset with, so a resume would be a guess. Store this one whole // if it happens to fit in a chunk; otherwise leave it to the fetch path. if (cr.total <= POLICY.CHUNK_BYTES) { await cache.put(new Request(url), new Response(await probe.blob(), { status: 200, headers: { 'Content-Type': meta.type, 'Content-Length': String(cr.total) } })); await sweepOldRevisions(cache, url); } return; } await cache.put(metaKey, new Response(JSON.stringify(meta), { headers: { 'Content-Type': 'application/json' } })); await cache.put(POLICY.chunkKey(url, 0), new Response(await probe.blob())); } const ranges = POLICY.chunkRanges(meta.total, POLICY.CHUNK_BYTES); for (const r of ranges) { const key = POLICY.chunkKey(url, r.start); if (await cache.match(key)) continue; // already have it — this is the resume let response; try { response = await fetch(new Request(url, { headers: { Range: 'bytes=' + r.start + '-' + r.end, 'If-Range': meta.validator } })); } catch (e) { return; // link died. Everything stored so far stays; the next sweep continues from here. } const verdict = POLICY.resumeVerdict( response.status, response.headers.get('Content-Range'), r.start, meta.total, meta.validator, POLICY.validatorOf(response.headers) ); if (verdict !== 'continue') { // 'restart' means the asset changed under us (If-Range declined) and 'discard' means we // cannot trust what came back. Either way the accumulated chunks describe a file that no // longer exists, and appending to them is the corruption this check exists to prevent. await dropChunks(cache, url); return; } await cache.put(key, new Response(await response.blob())); } // Every piece present: assemble once, atomically as far as the player is concerned — the full // entry appears only when it is genuinely whole, so a cache hit can never be a fragment. const parts = []; for (const r of ranges) { const hit = await cache.match(POLICY.chunkKey(url, r.start)); if (!hit) return; // evicted mid-assembly; try again later parts.push(await hit.blob()); } const whole = new Blob(parts, { type: meta.type || 'application/octet-stream' }); if (whole.size !== meta.total) { await dropChunks(cache, url); return; } await cache.put(new Request(url), new Response(whole, { status: 200, headers: { 'Content-Type': meta.type || 'application/octet-stream', 'Content-Length': String(meta.total) } })); await dropChunks(cache, url); await sweepOldRevisions(cache, url); } async function pruneToPlaylist(urls) { const cache = await caches.open(CONTENT_CACHE); const keep = new Set(urls); for (const key of await cache.keys()) { if (keep.has(key.url)) continue; // An in-flight transfer's bookkeeping belongs to a URL that IS in the keep set; deleting it // because the chunk key itself is not listed would restart that download on every sweep. if (POLICY.isInternalKey(key.url) && [...keep].some((u) => POLICY.assetKey(u) === POLICY.assetKey(key.url))) continue; await cache.delete(key); } } async function dropChunks(cache, url) { for (const key of await cache.keys()) { if (POLICY.isInternalKey(key.url) && POLICY.assetKey(key.url) === POLICY.assetKey(url) && key.url.indexOf(revOf(url)) !== -1) { await cache.delete(key); } } } /* * Delete entries for the SAME asset at a DIFFERENT revision. * * Replacing an asset changes the revision in its URL, which is what makes the new bytes a cache * miss everywhere — but it also means the superseded copy would sit there until the quota evicted * it. On a panel with a 1GB widget quota, a handful of replaced videos is the entire budget. */ async function sweepOldRevisions(cache, url) { const asset = POLICY.assetKey(url); const rev = revOf(url); for (const key of await cache.keys()) { if (POLICY.assetKey(key.url) !== asset) continue; if (revOf(key.url) === rev) continue; await cache.delete(key); } } function revOf(url) { try { return new URL(url, self.location.href).searchParams.get('rev') || ''; } catch (e) { return ''; } } async function handleContent(request) { const range = request.headers.get('range'); const cache = await caches.open(CONTENT_CACHE); // Keyed WITHOUT the range header (Cache API ignores request headers by default), so one stored // full body serves every range of that file rather than one entry per seek position. const cached = await cache.match(request, { ignoreVary: true }); if (cached) { if (!range) return cached; const sliced = await sliceCached(cached, range); if (sliced) return sliced; // Unsatisfiable against the cached copy: fall through to the network rather than inventing a // 416 that might be wrong if the cached copy is somehow stale. } try { // A ranged request goes to the network as-is; storing its 206 would corrupt the entry, so this // response is returned and deliberately NOT cached. The full copy arrives on a non-ranged // request (the player's preloader issues one) and that is what populates the cache. const response = await fetch(request); if (!range && POLICY.isStorable(response)) { const clone = response.clone(); // Not awaited: a slow write must not delay first frame. Failures are swallowed because a // cache miss is a performance problem, and a thrown error here is a black screen. storeContent(cache, request, clone).catch(() => {}); } return response; } catch (err) { // Offline with nothing cached. A 504 is more honest than a 200 with an empty body — the player // treats a failed media load as an item to skip, and an empty 200 would hang on a dead element. if (cached) return cached; return new Response('', { status: 504, statusText: 'Offline and not cached' }); } } /* Build a correct 206 from a stored full body. */ async function sliceCached(cached, rangeHeader) { const buf = await cached.arrayBuffer(); const parsed = POLICY.parseRange(rangeHeader, buf.byteLength); if (parsed === null) return new Response(buf, { status: 200, headers: cached.headers }); if (parsed === 'unsatisfiable') return null; const body = buf.slice(parsed.start, parsed.end + 1); return new Response(body, { status: 206, statusText: 'Partial Content', headers: POLICY.partialHeaders( parsed.start, parsed.end, buf.byteLength, cached.headers.get('content-type') ) }); } /* Store a full response, evicting oldest-first when the quota is close rather than waiting for a QuotaExceededError to land on whichever item happened to be next. */ async function storeContent(cache, request, response) { const len = Number(response.headers.get('content-length')) || 0; try { if (navigator.storage && navigator.storage.estimate) { const { usage, quota } = await navigator.storage.estimate(); if (POLICY.needsEviction(usage || 0, len, quota || 0)) await evictOldest(cache, len); } } catch (e) { /* estimate is unavailable on some builds; proceed and rely on the catch below */ } try { await cache.put(request, response); } catch (e) { // Quota exceeded despite the check (or no estimate available). Make room once and retry — but // only once, so a pathologically large item cannot spin evicting the whole cache. await evictOldest(cache, len); try { await cache.put(request, response); } catch (e2) { /* give up: playback still works live */ } } } /* Cache API preserves insertion order, so the front of keys() is the least recently ADDED. That is a rough proxy for least useful and is the only ordering the API exposes without tracking metadata ourselves. */ async function evictOldest(cache, needBytes) { const keys = await cache.keys(); let freed = 0; for (const key of keys) { const hit = await cache.match(key); const size = hit ? Number(hit.headers.get('content-length')) || 0 : 0; await cache.delete(key); freed += size; if (freed >= needBytes) break; } }