mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
Same fault as Android, in both other players, and my earlier read of them was wrong: I assumed they rebuilt the iframe each cycle so could not go stale. They do rebuild — but only after the update survives a change check, and both change checks key on IDENTITY: web content_id|widget_id|remote_url|filepath|filename|schedules|transition tizen [content_id, widget_id, remote_url, mime_type, schedules, transition] A widget's identity does not change when it is edited, so an edit produced an identical signature, the update was discarded as "unchanged", and the old render stayed up. widget_rev now sits in both, alongside schedules and transition, which are there for exactly this reason. The render URL carries the rev on both players as well. In the zone path the web player was picking up `item.widget_rev` inside a loop whose variable is `a` — that would have been undefined on every zone; it now reads the zone assignment's own rev. Caching, which is the reason this is worth doing properly rather than just busting the URL: a URL carrying ?rev=<updated_at> is content-addressed, so those bytes cannot change without the URL changing. The render endpoint now returns immutable caching for a pinned URL and keeps no-store for a bare one, and the service worker serves pinned renders cache-first (CACHE_NAME v18). That closes a real gap. no-store meant widgets were the ONE thing the player's offline cache could never hold, so a display that lost its uplink lost its widgets — while its images and video kept playing. Offline resilience is the point of that cache. Old players sending no rev are unaffected: they still get no-store, because without a rev nothing distinguishes one render from the next. Verified live: bare URL -> no-store; ?rev=123 -> public, max-age=31536000, immutable. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
78 lines
3.1 KiB
JavaScript
78 lines
3.1 KiB
JavaScript
const CACHE_NAME = 'rd-player-v18';
|
|
|
|
// 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(
|
|
keys.filter(k => k !== CACHE_NAME).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;
|
|
}
|
|
|
|
// Everything else (content files, API calls, etc.): don't intercept.
|
|
// Returning without event.respondWith lets the browser handle it natively.
|
|
});
|