diff --git a/server/player/index.html b/server/player/index.html index 0268e14..3e3031c 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -4103,7 +4103,12 @@ // with. Registration succeeded there and then controlled nothing: no shell cache, no content // cache, no offline playback, and no error to notice. The server sends // Service-Worker-Allowed so this wider scope is permitted. - navigator.serviceWorker.register('/player/sw.js', { scope: '/' }).then(reg => { + // Registered from the ROOT, where the default scope already covers the whole origin. The + // previous form asked for a wider-than-default scope and relied on a Service-Worker-Allowed + // header reaching the browser — which Cloudflare withheld from a cached response across a + // deploy, so registration failed and the player ran with no worker at all. A rejected + // registration is worse than a narrow one, and nothing about it is visible from here. + navigator.serviceWorker.register('/sw.js').then(reg => { console.log('Service Worker registered'); // When a new SW activates, reload so the fresh code takes effect immediately reg.addEventListener('updatefound', () => { diff --git a/server/server.js b/server/server.js index b3bccae..4f550fb 100644 --- a/server/server.js +++ b/server/server.js @@ -298,6 +298,28 @@ app.get('/player/schedule-eval.js', (req, res) => { // tests via require — one source, so the range arithmetic the player depends on cannot drift from // the arithmetic that is actually tested. A service worker cannot require(), which is why this is // a served file rather than a bundled one. +/* + * The service worker, at the ROOT. + * + * A worker's scope defaults to its own directory, so /player/sw.js can only control /player/ and + * below — not /player itself, which is the URL the dashboard shows. The fix for that was to ask for + * a wider scope and permit it with a Service-Worker-Allowed header, and it works... until something + * in front of the origin does not pass the header on. Cloudflare served a CACHED response for that + * path across a deploy, headers and all, and the registration failed outright: worse than the + * narrow scope it replaced, because a rejected registration means no worker at all. + * + * Served from / instead, the default scope IS the whole origin and no header is required. That + * removes the dependency on a custom response header surviving every CDN, proxy and cache between + * us and a display — including the ones self-hosters run and we will never see. + * + * /player/sw.js keeps working for players still asking for it. + */ +app.get('/sw.js', (req, res) => { + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Service-Worker-Allowed', '/'); // belt: harmless, and correct where it survives + res.sendFile(path.join(__dirname, 'player', 'sw.js')); +}); + app.get('/player/cache-policy.js', (req, res) => { res.type('application/javascript').setHeader('Cache-Control', 'no-cache'); res.sendFile(path.join(__dirname, 'lib', 'player-cache-policy.js')); diff --git a/server/test/player-sw-scope.test.js b/server/test/player-sw-scope.test.js index b4c7c46..8e4e353 100644 --- a/server/test/player-sw-scope.test.js +++ b/server/test/player-sw-scope.test.js @@ -24,12 +24,16 @@ process.env.NODE_ENV = 'test'; const { test } = require('node:test'); const assert = require('node:assert/strict'); -test('the worker is registered with an explicit root scope', () => { - // Without {scope:'/'} the registration silently narrows to /player/ and the page at /player is - // left uncontrolled. +test('the worker is registered from the root, so its DEFAULT scope covers the player', () => { + // Asking for a wider-than-default scope works only if Service-Worker-Allowed reaches the browser. + // Cloudflare withheld it from a cached response across a deploy and registration failed outright + // — no worker at all, which is worse than the narrow scope it replaced. Served from /, the + // default scope is already the whole origin and no header has to survive the trip. const html = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); - assert.match(html, /navigator\.serviceWorker\.register\('\/player\/sw\.js',\s*\{\s*scope:\s*'\/'\s*\}/, - "register() must ask for a scope wider than the worker's own directory"); + assert.match(html, /navigator\.serviceWorker\.register\('\/sw\.js'\)/, + 'register the worker from the root rather than relying on a header'); + const server = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); + assert.match(server, /app\.get\('\/sw\.js'/, 'and the server must serve it there'); }); test('the server permits that scope, or the registration is rejected outright', async () => {