screentinker/server/player/sw.js
ScreenTinker 7233466030 A stale bridge must not kill the heartbeat, and rc3 must invalidate the shell
Caught on hardware immediately after deploying rc3 to alpha: the player kept
playing content while reporting nothing at all, throwing every 15 seconds.

    Uncaught TypeError: BS.telemetrySnapshot is not a function

The page was rc3 and the bridge it ran was older. Two causes, both fixed.

CACHE_NAME stayed at rd-player-v19 across a release that changed both the
service worker's fetch strategy and the shipped /player assets. The activate
handler deletes every cache whose name does not match, so keeping the name kept
the previous shell cache alive — including a stale st-bridge.js. Bumped to v20.
Content lives in its own cache, so this costs a small shell re-download and never
re-fetches a playlist.

The deeper defect is that the call site treated an optional bridge method as
guaranteed. It was the ONLY unguarded BS.* call in the player; every other one
checks or wraps. The bridge and the page are halves of one contract but are
fetched separately, so version skew is a normal condition, not an anomaly — it
must degrade, not throw. Now guarded on typeof, so a skewed pair reports the
fields it can and keeps heartbeating.

Worth naming the failure shape: the display looked perfectly healthy. Content
played, the socket connected, the device showed online — and telemetry silently
stopped. Anything that reports health through the same path it is breaking will
fail this way.

1056 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 12:18:44 -05:00

202 lines
9.1 KiB
JavaScript

// 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-v20';
// 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. This is what lets a widget keep rendering when the network is gone —
// previously the server sent no-store for every render, so widgets were the one thing the
// player's offline cache could never hold, and a display that lost its uplink lost them.
// 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.
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(
'<!DOCTYPE html><body style="margin:0;background:#000"></body>',
{ 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.
});
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;
}
}