diff --git a/CLAUDE.md b/CLAUDE.md index 431c919..be0f117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,12 +95,32 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f "Update available keeps showing" bug. - `BUILD_TAG` hashes **every** file under `./public` recursively. Don't reduce it to a file subset; a change to an unlisted shell file would stop busting caches. -- The update banner only shows when a waiting SW exists **and the page already - has a controller** — a first install (fresh visit, or after Settings → Force - refresh unregisters) passes through `waiting` transiently and must not banner. -- "Refresh UI" (`frontend/sw-update.js`): if no worker is waiting yet (banner came - from the `/api/version` poll), it calls `reg.update()`, waits for `installed`, - posts SKIP_WAITING, waits for `controllerchange`, then reloads once. +- **The banner has ONE rule** (`maybeShowUpdateBanner` in app.js): show it only + when the build this page runs differs from `/api/version`. The server stamps + the running build into index.html (``, at request + time like sw.js's BUILD_TAG); the SW caches that index.html with the rest of the + shell, so the meta always describes the code in the tab. The poll, a waiting + worker and the SW_UPDATE_AVAILABLE broadcast are only *prompts to re-check*. + This loop was "fixed" three times by chasing individual triggers — don't add + a trigger that calls `showUpdateBanner()` directly. +- **Refresh UI** (`frontend/sw-update.js`): `refreshShellInPlace()` re-downloads + every file the shell caches hold — cache-busted (`?__ytpfresh=`) so even an + OLD worker's cache-first handler can't answer from its cache, 3 tries per file, + all-or-nothing — and writes them into every versioned shell cache; then it + activates a waiting worker (SKIP_WAITING) if any and reloads once. A failed + download shows an error toast and re-offers later; it never reloads into the + old shell. `checkUpdateOutcome()` verifies after the reload (sessionStorage, + max 3 attempts) instead of looping. +- **Why it kept looping on prod:** the VPS→homelab link is slow and drops + requests. The SW install (`cache.addAll`, all-or-nothing) failed, Refresh UI + reloaded into the old cached shell, and the partial `ytplayer-` cache + left by the failed install later made activate broadcast "update available" + to pages that were already current. sw.js now precaches with + `cache: 'reload'`, retries each file, and deletes its partial cache on failure. +- Reproduce with the throttling/stalling proxy approach: WebKit (Playwright on + the Windows side) or Chrome over CDP against a scratch copy of the server, + stalling every Nth shell request after "deploying" v2. A fast local link never + shows the bug. ## Cache layout & offline thumbnails - Three cache families, and the split matters on activate: the **versioned shell diff --git a/frontend/app.js b/frontend/app.js index 3887fab..53c911d 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -925,6 +925,7 @@ function toast(msg, { duration = 2200 } = {}) { first.classList.add('toast-exit'); setTimeout(() => first.remove(), 280); } + return t; } function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); } @@ -5081,24 +5082,41 @@ function importBackup(e) { // live and we surface the existing update banner so the user can reload. // ============================================================================ +// The build this page is actually running: the server stamps it into +// index.html (), and the service worker caches that +// index.html together with the rest of the shell, so it always describes the +// code in this tab. Null when served without the server (static test serve). +const RUNNING_BUILD = (() => { + const v = document.querySelector('meta[name="ytp-build"]')?.content || ''; + return v && !v.startsWith('__') ? v : null; +})(); + +// Fallback baseline for pages without the meta tag: the first tag seen. let _knownBuildTag = null; -async function checkBuildTag() { - try { - const res = await fetch('/api/version', { cache: 'no-store' }); - if (!res.ok) return; - const data = await res.json(); - const tag = data.buildTag; - if (!tag) return; - if (!_knownBuildTag) { - _knownBuildTag = tag; // baseline on first successful fetch - return; - } - if (tag !== _knownBuildTag) { - _knownBuildTag = tag; // update so we don't show the banner twice - showUpdateBanner(); - } - } catch { /* network error — try again next interval */ } +async function fetchServerBuild() { + const res = await fetch('/api/version', { cache: 'no-store' }); + if (!res.ok) return null; + const data = await res.json(); + return data.buildTag || null; +} + +// The ONLY way the update banner opens: the server runs a different build +// than this page. Every trigger (the poll, a waiting worker, the activate +// broadcast) funnels through here, so none of them can re-open the banner +// once the page is current — which is what kept happening before. +async function maybeShowUpdateBanner() { + if (_updateBannerShown || _updating) return; + let server = null; + try { server = await fetchServerBuild(); } catch { return; /* offline — next poll */ } + if (!server) return; + const running = RUNNING_BUILD || _knownBuildTag; + if (!running) { _knownBuildTag = server; return; } + if (server !== running) showUpdateBanner(server); +} + +function checkBuildTag() { + return maybeShowUpdateBanner(); } function pollBuildTag() { @@ -5134,7 +5152,9 @@ async function hardReloadUI() { } let _updateBannerShown = false; -function showUpdateBanner() { +let _updating = false; +let _updateToast = null; +function showUpdateBanner(target) { // Only show the dialog once per page load if (_updateBannerShown) return; _updateBannerShown = true; @@ -5146,7 +5166,7 @@ function showUpdateBanner() { { label: 'Later', onClick: closeModal }, { label: 'Refresh UI', primary: true, onClick: async () => { closeModal(); - await applyUpdate(); + await applyUpdate(target); }}, ]); } @@ -5155,16 +5175,85 @@ function showUpdateBanner() { // waiting worker without re-querying getRegistration(). let _swReg = null; -// Applies a pending SW update in place (see sw-update.js for the full -// rationale — this used to call hardReloadUI(), which caused the "Update -// ready" banner to reappear right after being applied). -async function applyUpdate() { - const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null); - await window.SwUpdate.applyUpdate({ - reg, - container: navigator.serviceWorker, - reload: () => window.location.reload(), - }); +// "Refresh UI": download the new shell into the SW caches (all-or-nothing), +// activate a waiting worker if any, reload once — see sw-update.js. The +// attempt is recorded in sessionStorage so the reloaded page can confirm it +// is really running `target` (checkUpdateOutcome) instead of looping. +const UPDATE_STATE_KEY = 'ytpUpdateAttempt'; +const UPDATE_MAX_ATTEMPTS = 3; +const SHELL_CACHE_RE = /^ytplayer-(?!thumbs$|fonts$)/; + +function readUpdateState() { + try { return JSON.parse(sessionStorage.getItem(UPDATE_STATE_KEY) || 'null'); } catch { return null; } +} +function writeUpdateState(st) { + try { + if (st) sessionStorage.setItem(UPDATE_STATE_KEY, JSON.stringify(st)); + else sessionStorage.removeItem(UPDATE_STATE_KEY); + } catch { /* storage blocked — the update still applies, just unverified */ } +} + +async function applyUpdate(target, { quiet = false } = {}) { + if (_updating) return; + _updating = true; + const prev = readUpdateState(); + writeUpdateState({ target, attempt: (prev ? prev.attempt : 0) + 1 }); + if (!quiet) _updateToast = toast('Downloading the update…', { duration: 125000 }); + try { + const reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null); + const SU = window.SwUpdate; + const reload = () => window.location.reload(); + if (!SU) { reload(); return; } // helper failed to load (flaky link): a reload is the best we can do + await SU.applyUpdate({ + reg, + container: navigator.serviceWorker, + reload, + refreshShell: ('caches' in window) ? () => SU.refreshShellInPlace({ + cachesApi: caches, + fetchFn: (url, init) => fetch(url, init), + isShellCache: (name) => SHELL_CACHE_RE.test(name), + timeoutMs: 120000, + attempts: 3, + attemptTimeoutMs: 25000, + setTimeout: (fn, ms) => setTimeout(fn, ms), + }) : null, + }); + } catch (err) { + _updating = false; + writeUpdateState(null); + if (_updateToast) { _updateToast.remove(); _updateToast = null; } + toast('⚠ Update didn’t download (' + (err && err.message ? err.message : 'connection problem') + '). It will be offered again.', { duration: 6000 }); + // Let the next poll re-offer it. + _updateBannerShown = false; + } +} + +// After an update reload: confirm the page now runs the build it asked for. +// If a stubborn cache still served the old one, retry (bounded) instead of +// re-opening the banner in a loop. +async function checkUpdateOutcome() { + const st = readUpdateState(); + if (!st) return; + // Suppress the banner while this is being decided. + _updateBannerShown = true; + let server = null; + try { server = await fetchServerBuild(); } catch { /* offline */ } + // Current if it runs what was asked for — or whatever is deployed now (a + // newer deploy may have landed in between). + if (!RUNNING_BUILD || RUNNING_BUILD === st.target || (server && RUNNING_BUILD === server)) { + writeUpdateState(null); + _updateBannerShown = false; + if (RUNNING_BUILD) toast('Updated ✓'); + return; + } + if (st.attempt < UPDATE_MAX_ATTEMPTS) { + _updateToast = toast('Finishing the update…', { duration: 125000 }); + setTimeout(() => applyUpdate(server || st.target, { quiet: true }), 1500); + return; + } + _updateBannerShown = false; + writeUpdateState(null); + toast('⚠ Couldn’t finish updating — the connection looks unstable. It will be offered again later.', { duration: 6000 }); } async function registerServiceWorker() { @@ -5186,20 +5275,20 @@ async function registerServiceWorker() { // If a new SW is already waiting (e.g. user refreshed after an update), // show the dialog right away. - if (reg.waiting && hasController()) { showUpdateBanner(); return; } + if (reg.waiting && hasController()) maybeShowUpdateBanner(); // Listen for a new SW installing after the page is open. reg.addEventListener('updatefound', () => { const sw = reg.installing; if (!sw) return; sw.addEventListener('statechange', () => { - if (sw.state === 'installed' && reg.waiting && hasController()) showUpdateBanner(); + if (sw.state === 'installed' && reg.waiting && hasController()) maybeShowUpdateBanner(); }); }); // The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate. navigator.serviceWorker.addEventListener('message', (e) => { - if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') showUpdateBanner(); + if (e.data && e.data.type === 'SW_UPDATE_AVAILABLE') maybeShowUpdateBanner(); }); // Check for updates in the background (useful for long-lived sessions) @@ -5269,6 +5358,7 @@ async function boot() { els.searchInput.focus(); // Register service worker + ping server with fingerprint (WEB mode only) + if (WEB) checkUpdateOutcome(); registerServiceWorker(); if (WEB) pollBuildTag(); if (WEB) { diff --git a/frontend/index.html b/frontend/index.html index 3c18ed1..8e6b535 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ + + diff --git a/frontend/sw-update.js b/frontend/sw-update.js index 85c9de0..c3dfd4b 100644 --- a/frontend/sw-update.js +++ b/frontend/sw-update.js @@ -1,5 +1,5 @@ /* ============================================================================ - * sw-update — applies a waiting service-worker update in place. + * sw-update — makes "Refresh UI" actually land the new build. * * Framework-free and dependency-free on purpose (same pattern as * async-guard.js): @@ -7,53 +7,128 @@ * global `window.SwUpdate`). * • `require`-able by `node --test` (CommonJS `module.exports`). * - * Bug this fixes (Task #61): "Update ready" kept reappearing right after the - * user clicked "Reload now" / "Refresh UI". The old flow called - * hardReloadUI(), which unregisters the service worker and wipes every cache - * before navigating — forcing a brand-new install on the next load. That - * fresh install briefly has `reg.waiting` truthy again (a normal but - * transient SW lifecycle state), which registerServiceWorker() misread as a - * genuinely new update and re-showed the banner immediately. + * History: the "Update available keeps showing" loop was patched three times + * by chasing individual triggers (a transient `reg.waiting`, the activate + * broadcast, the buildTag poll firing before a worker was waiting). It kept + * coming back on the real deployment because the homelab link is slow and + * drops requests: the new worker's install (`cache.addAll`, all-or-nothing) + * failed, "Refresh UI" reloaded into the OLD cached shell, and the next + * successful install re-opened the banner — sometimes on a page that was + * already current (a failed install leaves a partial cache that the + * activate handler then mistook for a previous deploy). * - * Fix: activate the *already-installed* waiting worker in place — - * postMessage SKIP_WAITING to it, wait for it to actually take control - * (`controllerchange`), and only then reload. The reloaded page is served by - * the new SW from its very first request, and no fresh install/registration - * cycle happens, so the banner has nothing to spuriously re-trigger on. + * The fix no longer depends on the service-worker install lifecycle: + * 1. refreshShellInPlace() downloads a fresh copy of every file the shell + * caches hold (cache-busted, so even an old worker's cache-first + * handler can't answer with the stale copy) — ALL of them or nothing — + * and writes them into every versioned shell cache. Whichever worker + * serves the next load, it serves the new build. + * 2. Only then is a waiting worker (if any) activated, and the page + * reloaded once. + * The banner itself only opens when the build the page is running differs + * from the server's (see app.js maybeShowUpdateBanner), so no lifecycle + * event can re-open it once the page is current. * ========================================================================== */ (function (root) { 'use strict'; + const BUST_PARAM = '__ytpfresh'; + + function withTimeout(promise, ms, setTimeoutFn, label) { + if (!ms || !setTimeoutFn) return promise; + return Promise.race([ + promise, + new Promise((_, reject) => setTimeoutFn(() => reject(new Error(label || 'timed out')), ms)), + ]); + } + /** - * Applies a pending SW update: messages the waiting worker to skipWaiting(), - * waits for controllerchange, then reloads exactly once. + * Re-downloads every file held by the versioned shell caches and replaces + * the cached copies. All-or-nothing: if any download fails, nothing is + * written and it throws, so a flaky connection can never leave a + * half-old/half-new shell. * * @param {object} opts - * @param {ServiceWorkerRegistration|null} opts.reg the current registration - * @param {ServiceWorkerContainer} opts.container navigator.serviceWorker - * @param {() => void} opts.reload called at most once - * @param {(fn: () => void, ms: number) => any} [opts.setTimeout] injectable for tests - * @returns {Promise} + * @param {CacheStorage} opts.cachesApi + * @param {(url: string, init: object) => Promise} opts.fetchFn + * @param {(name: string) => boolean} opts.isShellCache + * @param {string} [opts.bust] cache-busting token + * @param {number} [opts.timeoutMs] overall download budget + * @param {number} [opts.attempts] tries per file (default 3) + * @param {number} [opts.attemptTimeoutMs] bound on a single try + * @param {Function} [opts.setTimeout] + * @returns {Promise<{refreshed: number, caches: number}>} */ - async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) { - const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null); - let waiting = reg && reg.waiting; + async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, setTimeout: setTimeoutFn }) { + const names = (await cachesApi.keys()).filter(isShellCache); + if (!names.length) return { refreshed: 0, caches: 0 }; // uncontrolled page: a reload already hits the network - if (!waiting && reg && typeof reg.update === 'function') { - // The banner can be triggered by the server buildTag poll before the - // browser has fetched the new sw.js at all. With no waiting worker, a - // bare reload would be served the OLD cache-first shell, the new SW - // would then install in the background, and the banner would reappear - // — the "update available keeps showing" loop. Fetch the update now - // and wait (bounded) for it to reach `installed` so a single click - // activates the new version. - try { await reg.update(); } catch { /* offline / fetch failed — fall through */ } - waiting = reg.waiting || (await waitForInstalled(reg, scheduleTimeout, 8000)); + const urls = new Set(); + for (const name of names) { + const cache = await cachesApi.open(name); + for (const req of await cache.keys()) { + const u = new URL(req.url); + if (u.searchParams.has(BUST_PARAM)) { await cache.delete(req); continue; } // leftovers of a failed try + urls.add(u.href); + } } + const token = bust || String(Date.now()); + // Each file gets a few attempts (the homelab link drops requests), each + // bounded so one hung connection can't eat the whole budget. + const fetchOne = async (url) => { + let last = null; + for (let attempt = 0; attempt < (attempts || 3); attempt++) { + const u = new URL(url); + u.searchParams.set(BUST_PARAM, token + '-' + attempt); + const ctl = typeof AbortController !== 'undefined' ? new AbortController() : null; + try { + const res = await withTimeout( + fetchFn(u.href, { cache: 'reload', credentials: 'same-origin', signal: ctl ? ctl.signal : undefined }), + attemptTimeoutMs, setTimeoutFn, `${new URL(url).pathname} timed out`); + if (res && res.ok) return [url, res]; + last = new Error(`${new URL(url).pathname} → ${res ? res.status : 'no response'}`); + } catch (err) { + if (ctl) { try { ctl.abort(); } catch { /* already done */ } } + last = err; + } + } + throw last; + }; + const fetched = await withTimeout(Promise.all([...urls].map(fetchOne)), + timeoutMs, setTimeoutFn, 'update download timed out'); + + for (const name of names) { + const cache = await cachesApi.open(name); + for (const [url, res] of fetched) await cache.put(url, res.clone()); + // An old worker's cache-first handler may have stored the busted URLs. + for (const req of await cache.keys()) { + if (new URL(req.url).searchParams.has(BUST_PARAM)) await cache.delete(req); + } + } + return { refreshed: fetched.length, caches: names.length }; + } + + /** + * Applies an update: refresh the shell in place (throws on failure — the + * caller reports it and nothing reloads), then activate a waiting worker if + * there is one, then reload exactly once. + * + * @param {object} opts + * @param {ServiceWorkerRegistration|null} opts.reg + * @param {ServiceWorkerContainer} opts.container + * @param {() => void} opts.reload called at most once + * @param {() => Promise} [opts.refreshShell] refreshShellInPlace bound to real APIs + * @param {Function} [opts.setTimeout] injectable for tests + * @returns {Promise} + */ + async function applyUpdate({ reg, container, reload, refreshShell, setTimeout: setTimeoutFn }) { + const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null); + + if (refreshShell) await refreshShell(); + + const waiting = reg && reg.waiting; if (!waiting) { - // Nothing to activate (e.g. banner was shown from a broadcast message - // rather than an actual waiting worker) — just reload. reload(); return; } @@ -64,34 +139,14 @@ reloaded = true; reload(); }; - + // The waiting worker's cache was refreshed too, so activating it is safe + // either way; reload on controllerchange, or after a short safety net. container.addEventListener('controllerchange', reloadOnce, { once: true }); - // Safety net in case controllerchange never fires (e.g. no controller yet). - if (scheduleTimeout) scheduleTimeout(reloadOnce, 3000); - + if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000); waiting.postMessage({ type: 'SKIP_WAITING' }); } - /** - * Waits for reg.installing to reach the `installed` state (at which point - * it becomes reg.waiting), bounded by a timeout. Resolves with the waiting - * worker or null. - */ - function waitForInstalled(reg, scheduleTimeout, ms) { - return new Promise((resolve) => { - const sw = reg.installing; - if (!sw || typeof sw.addEventListener !== 'function') { resolve(null); return; } - let settled = false; - const settle = (v) => { if (!settled) { settled = true; resolve(v); } }; - sw.addEventListener('statechange', () => { - if (sw.state === 'installed') settle(reg.waiting || sw); - else if (sw.state === 'redundant') settle(null); - }); - if (scheduleTimeout) scheduleTimeout(() => settle(reg.waiting || null), ms); - }); - } - - const SwUpdate = { applyUpdate }; + const SwUpdate = { applyUpdate, refreshShellInPlace, BUST_PARAM }; if (typeof module !== 'undefined' && module.exports) { module.exports = SwUpdate; diff --git a/frontend/sw-update.test.js b/frontend/sw-update.test.js index a729aab..0400beb 100644 --- a/frontend/sw-update.test.js +++ b/frontend/sw-update.test.js @@ -1,22 +1,24 @@ 'use strict'; /** - * Unit tests for sw-update.js (Task #61). + * Unit tests for sw-update.js — the "Update available keeps showing after + * Refresh UI" loop. * - * Bug: "Update ready / Reload now" reappeared immediately after the user - * clicked "Reload now". Root cause — the old flow unregistered the service - * worker and wiped every cache before navigating, forcing a brand-new - * install on the next load; that fresh install's registration briefly has - * `reg.waiting` truthy again (a normal but transient SW lifecycle state), - * which was misread as a new pending update and re-showed the banner. + * On the real deployment (slow homelab link that drops requests) the new + * service worker's all-or-nothing install kept failing, so "Refresh UI" + * reloaded into the old cached shell and the banner came back. The fix + * refreshes the cached shell in place (all-or-nothing, cache-busted) before + * activating any waiting worker and reloading once. * - * These tests drive applyUpdate() directly against mocked registration / - * container / reload objects — no real service worker or browser needed. + * No real service worker or browser needed: fake CacheStorage / fetch / + * registration / container objects drive the functions directly. */ const { test } = require('node:test'); const assert = require('node:assert'); -const { applyUpdate } = require('./sw-update'); +const { applyUpdate, refreshShellInPlace, BUST_PARAM } = require('./sw-update'); + +const ORIGIN = 'https://worship.example'; // A minimal fake ServiceWorkerContainer supporting addEventListener/once. function fakeContainer() { @@ -29,172 +31,183 @@ function fakeContainer() { const fns = (listeners.controllerchange || []).slice(); for (const { fn, once } of fns) { fn(); - if (once) { - listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn); - } + if (once) listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn); } }, }; } -test('messages the waiting worker to skipWaiting and reloads only after controllerchange', async () => { - const messages = []; - const waiting = { postMessage: (m) => messages.push(m) }; - const container = fakeContainer(); - let reloadCount = 0; - - const done = applyUpdate({ - reg: { waiting }, - container, - reload: () => { reloadCount++; }, - setTimeout: () => {}, // no-op — we drive controllerchange manually - }); - - // SKIP_WAITING should be sent immediately, before any reload. - await Promise.resolve(); - assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }]); - assert.strictEqual(reloadCount, 0, 'must not reload before the new SW has taken control'); - - container.fireControllerChange(); - await done; - assert.strictEqual(reloadCount, 1, 'reloads exactly once after controllerchange'); -}); - -test('reloads only once even if controllerchange fires more than once (no reload loop)', async () => { - const waiting = { postMessage: () => {} }; - const container = fakeContainer(); - let reloadCount = 0; - - const done = applyUpdate({ - reg: { waiting }, - container, - reload: () => { reloadCount++; }, - setTimeout: () => {}, - }); - - container.fireControllerChange(); - container.fireControllerChange(); // simulate a spurious second event - await done; - - assert.strictEqual(reloadCount, 1, 'reload must be idempotent — no loop'); -}); - -test('falls back to a plain reload when there is no waiting worker', async () => { - const container = fakeContainer(); - let reloadCount = 0; - - await applyUpdate({ - reg: { waiting: null }, - container, - reload: () => { reloadCount++; }, - setTimeout: () => { throw new Error('timeout should not be scheduled without a waiting worker'); }, - }); - - assert.strictEqual(reloadCount, 1); -}); - -test('with no waiting worker, fetches the SW update and activates the newly installed worker (buildTag-poll path)', async () => { - // Simulates: server redeployed (banner shown by the /api/version poll) but - // the browser hasn't fetched the new sw.js yet — reg.waiting is null until - // reg.update() is called and the new worker finishes installing. - const messages = []; - const container = fakeContainer(); - let reloadCount = 0; - - const stateListeners = []; - const installing = { - state: 'installing', - addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); }, - postMessage: (m) => messages.push(m), - }; - const reg = { - waiting: null, - installing: null, - update() { - // Browser found a byte-different sw.js → a new worker starts installing. - this.installing = installing; - return Promise.resolve(); +// Fake CacheStorage: { name: { url: body } }. +function fakeCaches(initial) { + const store = new Map(Object.entries(initial).map(([k, v]) => [k, new Map(Object.entries(v))])); + return { + store, + async keys() { return [...store.keys()]; }, + async open(name) { + if (!store.has(name)) store.set(name, new Map()); + const m = store.get(name); + return { + async keys() { return [...m.keys()].map((url) => ({ url })); }, + async put(url, res) { m.set(url, res.body); }, + async delete(req) { return m.delete(req.url); }, + }; }, }; +} - const done = applyUpdate({ - reg, - container, - reload: () => { reloadCount++; }, - setTimeout: () => {}, // no-op — we drive state transitions manually +const res = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, body, clone() { return res(body, status); } }); +const isShellCache = (n) => /^ytplayer-(?!thumbs$|fonts$)/.test(n); + +test('refreshShellInPlace replaces every cached shell file in every shell cache, cache-busted', async () => { + const cachesApi = fakeCaches({ + 'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app' }, + 'ytplayer-new': { [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/video-edit.js`]: 'old-edit' }, + 'ytplayer-thumbs': { [`${ORIGIN}/thumb.jpg`]: 'thumb' }, }); - - // Let applyUpdate reach the waitForInstalled stage, then finish the install. - await Promise.resolve(); await Promise.resolve(); - installing.state = 'installed'; - reg.waiting = installing; - stateListeners.forEach((fn) => fn()); - await Promise.resolve(); await Promise.resolve(); - - assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }], 'skip-waiting sent to the freshly installed worker'); - assert.strictEqual(reloadCount, 0, 'must not reload before the new SW takes control'); - - container.fireControllerChange(); - await done; - assert.strictEqual(reloadCount, 1); + const fetched = []; + const r = await refreshShellInPlace({ + cachesApi, + isShellCache, + bust: 'T1', + fetchFn: async (url, init) => { + fetched.push({ url, init }); + return res('NEW ' + new URL(url).pathname); + }, + }); + assert.strictEqual(r.refreshed, 3); + // Busted so an old worker's cache-first handler can't answer from its cache. + for (const f of fetched) { + assert.strictEqual(new URL(f.url).searchParams.get(BUST_PARAM), 'T1-0'); + assert.strictEqual(f.init.cache, 'reload'); + } + const old = cachesApi.store.get('ytplayer-old'); + assert.strictEqual(old.get(`${ORIGIN}/`), 'NEW /'); + assert.strictEqual(old.get(`${ORIGIN}/app.js`), 'NEW /app.js'); + assert.strictEqual(old.get(`${ORIGIN}/video-edit.js`), 'NEW /video-edit.js'); + assert.strictEqual(cachesApi.store.get('ytplayer-new').get(`${ORIGIN}/`), 'NEW /'); + assert.strictEqual(cachesApi.store.get('ytplayer-thumbs').get(`${ORIGIN}/thumb.jpg`), 'thumb', 'utility caches untouched'); }); -test('with no waiting worker and no update found, reloads once after the bounded wait', async () => { - const container = fakeContainer(); - let reloadCount = 0; - const timeouts = []; +test('refreshShellInPlace is all-or-nothing: one failed download writes nothing and throws', async () => { + const cachesApi = fakeCaches({ + 'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/styles.css`]: 'old-css' }, + }); + await assert.rejects(refreshShellInPlace({ + cachesApi, + isShellCache, + fetchFn: async (url) => (url.includes('/styles.css') ? res('gateway timeout', 504) : res('NEW')), + }), /styles\.css/, 'fails after its retries are used up'); + const old = cachesApi.store.get('ytplayer-old'); + assert.deepStrictEqual([...old.values()], ['old-index', 'old-app', 'old-css'], 'no partial shell'); +}); - const reg = { - waiting: null, - installing: null, - update: () => Promise.resolve(), // update check ran; nothing new +test('refreshShellInPlace retries a dropped download with a fresh cache-bust token', async () => { + const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/`]: 'old-index' } }); + const tries = []; + await refreshShellInPlace({ + cachesApi, isShellCache, bust: 'T3', + fetchFn: async (url) => { + tries.push(url); + if (url.includes('/app.js') && tries.filter((t) => t.includes('/app.js')).length === 1) throw new TypeError('network error'); + return res('NEW'); + }, + }); + const appTries = tries.filter((t) => t.includes('/app.js')).map((t) => new URL(t).searchParams.get(BUST_PARAM)); + assert.deepStrictEqual(appTries, ['T3-0', 'T3-1']); + assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'NEW'); +}); + +test('refreshShellInPlace drops busted URLs an old cache-first worker stored on the way through', async () => { + const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' } }); + const fetchFn = async (url) => { + // Simulate the old worker's cacheFirst caching the busted request. + (await cachesApi.open('ytplayer-old')).put(url, res('NEW')); + return res('NEW'); }; - - const done = applyUpdate({ - reg, - container, - reload: () => { reloadCount++; }, - setTimeout: (fn) => { timeouts.push(fn); }, - }); - - await Promise.resolve(); await Promise.resolve(); - // reg.installing is null → waitForInstalled resolves immediately with null. - await done; - assert.strictEqual(reloadCount, 1, 'plain reload when the update check finds nothing'); + await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, bust: 'T2' }); + assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').keys()], [`${ORIGIN}/app.js`]); }); -test('falls back to a plain reload when there is no registration at all', async () => { - const container = fakeContainer(); - let reloadCount = 0; - - await applyUpdate({ - reg: null, - container, - reload: () => { reloadCount++; }, +test('refreshShellInPlace times out instead of hanging on a stalled request', async () => { + const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' } }); + let fire = null; + const p = refreshShellInPlace({ + cachesApi, isShellCache, + fetchFn: () => new Promise(() => {}), // never settles + timeoutMs: 1000, + setTimeout: (fn, ms) => { if (ms === 1000) fire = fn; }, }); + await new Promise((r) => setImmediate(r)); + fire(); + await assert.rejects(p, /timed out/); + assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'old-app'); +}); - assert.strictEqual(reloadCount, 1); +test('refreshShellInPlace with no shell caches (uncontrolled page) is a no-op', async () => { + const r = await refreshShellInPlace({ cachesApi: fakeCaches({}), isShellCache, fetchFn: async () => { throw new Error('no fetch expected'); } }); + assert.deepStrictEqual(r, { refreshed: 0, caches: 0 }); +}); + +test('applyUpdate refreshes first, then activates the waiting worker, then reloads once on controllerchange', async () => { + const order = []; + const waiting = { postMessage: (m) => order.push('msg:' + m.type) }; + const container = fakeContainer(); + let reloads = 0; + await applyUpdate({ + reg: { waiting }, + container, + reload: () => { reloads++; order.push('reload'); }, + refreshShell: async () => { order.push('refresh'); }, + setTimeout: () => {}, + }); + assert.deepStrictEqual(order, ['refresh', 'msg:SKIP_WAITING']); + assert.strictEqual(reloads, 0, 'must not reload before the new worker has taken control'); + container.fireControllerChange(); + container.fireControllerChange(); + assert.strictEqual(reloads, 1, 'reloads exactly once'); +}); + +test('applyUpdate with no waiting worker reloads right after the refresh', async () => { + const order = []; + await applyUpdate({ + reg: { waiting: null }, + container: fakeContainer(), + reload: () => order.push('reload'), + refreshShell: async () => { order.push('refresh'); }, + }); + assert.deepStrictEqual(order, ['refresh', 'reload']); +}); + +test('applyUpdate does NOT reload or activate anything when the refresh fails', async () => { + const messages = []; + let reloads = 0; + await assert.rejects(applyUpdate({ + reg: { waiting: { postMessage: (m) => messages.push(m) } }, + container: fakeContainer(), + reload: () => { reloads++; }, + refreshShell: async () => { throw new Error('/app.js → 504'); }, + }), /504/); + assert.strictEqual(reloads, 0, 'reloading now would just serve the old shell again (the loop)'); + assert.deepStrictEqual(messages, []); }); test('the timeout safety net reloads once if controllerchange never fires', async () => { - const waiting = { postMessage: () => {} }; + let fire = null; + let reloads = 0; const container = fakeContainer(); - let reloadCount = 0; - let scheduledFn = null; - await applyUpdate({ - reg: { waiting }, + reg: { waiting: { postMessage() {} } }, container, - reload: () => { reloadCount++; }, - setTimeout: (fn) => { scheduledFn = fn; }, // capture instead of real timer + reload: () => { reloads++; }, + setTimeout: (fn) => { fire = fn; }, }); - - assert.strictEqual(reloadCount, 0, 'not reloaded yet — timeout not fired'); - scheduledFn(); // simulate the timeout elapsing - assert.strictEqual(reloadCount, 1); - - // A late controllerchange after the timeout already reloaded must not - // trigger a second reload. + fire(); container.fireControllerChange(); - assert.strictEqual(reloadCount, 1, 'no double reload once the timeout fallback has fired'); + assert.strictEqual(reloads, 1); +}); + +test('applyUpdate with no registration at all still reloads', async () => { + let reloads = 0; + await applyUpdate({ reg: null, container: fakeContainer(), reload: () => { reloads++; } }); + assert.strictEqual(reloads, 1); }); diff --git a/frontend/sw.js b/frontend/sw.js index 1d1c0cd..5d97b2e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -11,12 +11,12 @@ * Auto-update flow: * 1. New SW installs alongside the old one and waits (skipWaiting() is NOT * called automatically — see the install handler below). - * 2. Client (app.js registerServiceWorker()) notices the waiting worker - * and shows the "Update ready" banner. - * 3. User clicks "Reload now" → client (applyUpdate() in sw-update.js) - * posts SKIP_WAITING to the waiting worker and waits for it to take - * control (controllerchange) before reloading — it does NOT unregister - * or wipe caches, so the reload doesn't force a brand-new install. + * 2. The client (app.js) shows the "Update available" banner only when the + * build it is running differs from the server's /api/version buildTag — + * a waiting worker or a broadcast is merely a prompt to re-check. + * 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every + * versioned shell cache (all-or-nothing), activates a waiting worker if + * there is one (SKIP_WAITING), and reloads once. * 4. activate: if an older *versioned shell cache* is found (i.e. this * activation is genuinely replacing a previous deploy, not just the * first-ever install of a freshly (re)registered worker), broadcast @@ -68,14 +68,40 @@ const SHELL = [ ]; // ---- Install: pre-cache the app shell ---- +// skipWaiting() is NOT called here — the page activates a waiting worker +// from "Refresh UI" (see sw-update.js). self.addEventListener('install', (e) => { - e.waitUntil( - caches.open(CACHE).then((cache) => cache.addAll(SHELL)) - // skipWaiting() is NOT called here — we wait for the client to confirm - // before activating, so the update banner can appear first. - ); + e.waitUntil(precacheShell()); }); +// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could +// otherwise hand the new worker yesterday's app.js), a few attempts per file +// because the homelab link drops requests, and a failed install deletes its +// partial cache — a leftover ytplayer- cache is exactly what activate +// uses to decide "this is a genuine update", and it used to re-open the +// update banner on pages that were already current. +const PRECACHE_ATTEMPTS = 3; +async function precacheShell() { + const cache = await caches.open(CACHE); + try { + await Promise.all(SHELL.map(async (url) => { + let lastErr = null; + for (let attempt = 0; attempt < PRECACHE_ATTEMPTS; attempt++) { + try { + const res = await fetch(new Request(url, { cache: 'reload' })); + if (!res.ok) throw new Error(url + ' → ' + res.status); + await cache.put(url, res); + return; + } catch (err) { lastErr = err; } + } + throw lastErr; + })); + } catch (err) { + await caches.delete(CACHE); + throw err; + } +} + // ---- Activate: evict old caches, claim clients, notify about update ---- self.addEventListener('activate', (e) => { e.waitUntil((async () => { diff --git a/frontend/sw.test.js b/frontend/sw.test.js index cbb1be3..56d7ac4 100644 --- a/frontend/sw.test.js +++ b/frontend/sw.test.js @@ -27,8 +27,9 @@ const SW_SOURCE = fs.readFileSync(path.join(__dirname, 'sw.js'), 'utf8'); // Builds a fresh sandboxed SW environment with the given starting cache keys // and returns handles to drive/inspect it. -function loadSw(initialCacheKeys) { +function loadSw(initialCacheKeys, { fetchImpl } = {}) { const cacheStore = new Set(initialCacheKeys); + const puts = []; const deleted = []; const clientMessages = []; @@ -50,14 +51,15 @@ function loadSw(initialCacheKeys) { async delete(key) { deleted.push(key); return cacheStore.delete(key); }, async open(key) { cacheStore.add(key); - return { addAll: async () => {}, match: async () => undefined, put: async () => {} }; + return { addAll: async () => {}, match: async () => undefined, put: async (url) => { puts.push(url); } }; }, }; const sandbox = { self: self_, caches: caches_, - fetch: async () => { throw new Error('fetch not mocked'); }, + fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }), + Request: class { constructor(url, init) { this.url = url; Object.assign(this, init); } }, Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } }, URL, console, @@ -72,7 +74,14 @@ function loadSw(initialCacheKeys) { await waitPromise; } - return { triggerActivate, deleted, clientMessages, cacheStoreRemaining: () => Array.from(cacheStore) }; + async function triggerInstall() { + let waitPromise = Promise.resolve(); + const event = { waitUntil: (p) => { waitPromise = p; } }; + for (const fn of listeners.install || []) fn(event); + await waitPromise; + } + + return { triggerActivate, triggerInstall, deleted, puts, clientMessages, cacheStoreRemaining: () => Array.from(cacheStore) }; } test('activate does NOT broadcast on a fresh install (only the current-version cache exists)', async () => { @@ -115,3 +124,29 @@ test('activate reports an update and still preserves utility caches together', a ['ytplayer-fonts', 'ytplayer-thumbs', 'ytplayer-v1.0.4'].sort() ); }); + +test('install fetches every shell file past the HTTP cache and retries dropped requests', async () => { + const seen = {}; + const sw = loadSw([], { + fetchImpl: async (req) => { + seen[req.url] = (seen[req.url] || 0) + 1; + assert.strictEqual(req.cache, 'reload', 'must bypass the browser HTTP cache'); + // The homelab link drops the first request for app.js. + if (req.url === '/app.js' && seen[req.url] === 1) throw new TypeError('network error'); + return { ok: true, status: 200 }; + }, + }); + await sw.triggerInstall(); + assert.strictEqual(seen['/app.js'], 2, 'a dropped request is retried'); + assert.ok(sw.puts.includes('/app.js') && sw.puts.includes('/'), 'shell files cached'); + assert.deepStrictEqual(sw.deleted, []); +}); + +test('a failed install deletes its partial cache so activate never mistakes it for an old deploy', async () => { + const sw = loadSw([], { + fetchImpl: async (req) => (req.url === '/styles.css' ? { ok: false, status: 504 } : { ok: true, status: 200 }), + }); + await assert.rejects(sw.triggerInstall(), /styles\.css/); + assert.deepStrictEqual(sw.deleted, ['ytplayer-v1.0.4'], 'partial cache removed'); + assert.deepStrictEqual(sw.cacheStoreRemaining(), []); +}); diff --git a/server/server.js b/server/server.js index 65cb3d8..9404d29 100644 --- a/server/server.js +++ b/server/server.js @@ -1581,9 +1581,27 @@ app.get('/sw.js', (c) => { // Static files — serve the frontend/public directory // Must come AFTER all /api routes so API takes priority // ============================================================================ -app.use('/*', serveStatic({ root: './public' })); +// index.html carries the build tag it belongs to (), +// stamped here at request time the same way /sw.js gets it. The page compares +// that against /api/version, so "Update available" only ever shows when the +// running build really differs from the deployed one (see app.js). +let _indexSource = null; +function indexHtml(c) { + if (_indexSource === null) { + try { _indexSource = readFileSync('./public/index.html', 'utf8'); } + catch { return c.text('index.html not found', 404); } + } + return c.html(_indexSource.replace('__BUILD_TAG__', BUILD_TAG), 200, { 'Cache-Control': 'no-cache' }); +} +app.get('/', indexHtml); +app.get('/index.html', indexHtml); + +// no-cache (revalidate every time) on the shell: the service worker is the +// only cache that should hold app files. With no headers at all, a browser +// may heuristically cache app.js and hand a new worker's install the old one. +app.use('/*', serveStatic({ root: './public', onFound: (_path, c) => { c.header('Cache-Control', 'no-cache'); } })); // SPA fallback — return index.html for any unmatched path -app.get('/*', serveStatic({ path: './public/index.html' })); +app.get('/*', indexHtml); // ============================================================================ // Boot