mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
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
202 lines
9.1 KiB
JavaScript
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;
|
|
}
|
|
}
|