Show the update banner only when the running build differs from the server and make Refresh UI land the new shell on flaky links
This commit is contained in:
32
CLAUDE.md
32
CLAUDE.md
@@ -95,12 +95,32 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f
|
|||||||
"Update available keeps showing" bug.
|
"Update available keeps showing" bug.
|
||||||
- `BUILD_TAG` hashes **every** file under `./public` recursively. Don't reduce it
|
- `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.
|
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
|
- **The banner has ONE rule** (`maybeShowUpdateBanner` in app.js): show it only
|
||||||
has a controller** — a first install (fresh visit, or after Settings → Force
|
when the build this page runs differs from `/api/version`. The server stamps
|
||||||
refresh unregisters) passes through `waiting` transiently and must not banner.
|
the running build into index.html (`<meta name="ytp-build">`, at request
|
||||||
- "Refresh UI" (`frontend/sw-update.js`): if no worker is waiting yet (banner came
|
time like sw.js's BUILD_TAG); the SW caches that index.html with the rest of the
|
||||||
from the `/api/version` poll), it calls `reg.update()`, waits for `installed`,
|
shell, so the meta always describes the code in the tab. The poll, a waiting
|
||||||
posts SKIP_WAITING, waits for `controllerchange`, then reloads once.
|
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-<tag>` 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
|
## Cache layout & offline thumbnails
|
||||||
- Three cache families, and the split matters on activate: the **versioned shell
|
- Three cache families, and the split matters on activate: the **versioned shell
|
||||||
|
|||||||
136
frontend/app.js
136
frontend/app.js
@@ -925,6 +925,7 @@ function toast(msg, { duration = 2200 } = {}) {
|
|||||||
first.classList.add('toast-exit');
|
first.classList.add('toast-exit');
|
||||||
setTimeout(() => first.remove(), 280);
|
setTimeout(() => first.remove(), 280);
|
||||||
}
|
}
|
||||||
|
return t;
|
||||||
}
|
}
|
||||||
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
|
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.
|
// 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 (<meta name="ytp-build">), 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;
|
let _knownBuildTag = null;
|
||||||
|
|
||||||
async function checkBuildTag() {
|
async function fetchServerBuild() {
|
||||||
try {
|
|
||||||
const res = await fetch('/api/version', { cache: 'no-store' });
|
const res = await fetch('/api/version', { cache: 'no-store' });
|
||||||
if (!res.ok) return;
|
if (!res.ok) return null;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const tag = data.buildTag;
|
return data.buildTag || null;
|
||||||
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
|
// The ONLY way the update banner opens: the server runs a different build
|
||||||
showUpdateBanner();
|
// 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);
|
||||||
}
|
}
|
||||||
} catch { /* network error — try again next interval */ }
|
|
||||||
|
function checkBuildTag() {
|
||||||
|
return maybeShowUpdateBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollBuildTag() {
|
function pollBuildTag() {
|
||||||
@@ -5134,7 +5152,9 @@ async function hardReloadUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let _updateBannerShown = false;
|
let _updateBannerShown = false;
|
||||||
function showUpdateBanner() {
|
let _updating = false;
|
||||||
|
let _updateToast = null;
|
||||||
|
function showUpdateBanner(target) {
|
||||||
// Only show the dialog once per page load
|
// Only show the dialog once per page load
|
||||||
if (_updateBannerShown) return;
|
if (_updateBannerShown) return;
|
||||||
_updateBannerShown = true;
|
_updateBannerShown = true;
|
||||||
@@ -5146,7 +5166,7 @@ function showUpdateBanner() {
|
|||||||
{ label: 'Later', onClick: closeModal },
|
{ label: 'Later', onClick: closeModal },
|
||||||
{ label: 'Refresh UI', primary: true, onClick: async () => {
|
{ label: 'Refresh UI', primary: true, onClick: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
await applyUpdate();
|
await applyUpdate(target);
|
||||||
}},
|
}},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -5155,16 +5175,85 @@ function showUpdateBanner() {
|
|||||||
// waiting worker without re-querying getRegistration().
|
// waiting worker without re-querying getRegistration().
|
||||||
let _swReg = null;
|
let _swReg = null;
|
||||||
|
|
||||||
// Applies a pending SW update in place (see sw-update.js for the full
|
// "Refresh UI": download the new shell into the SW caches (all-or-nothing),
|
||||||
// rationale — this used to call hardReloadUI(), which caused the "Update
|
// activate a waiting worker if any, reload once — see sw-update.js. The
|
||||||
// ready" banner to reappear right after being applied).
|
// attempt is recorded in sessionStorage so the reloaded page can confirm it
|
||||||
async function applyUpdate() {
|
// 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 reg = _swReg || (('serviceWorker' in navigator) ? await navigator.serviceWorker.getRegistration() : null);
|
||||||
await window.SwUpdate.applyUpdate({
|
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,
|
reg,
|
||||||
container: navigator.serviceWorker,
|
container: navigator.serviceWorker,
|
||||||
reload: () => window.location.reload(),
|
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() {
|
async function registerServiceWorker() {
|
||||||
@@ -5186,20 +5275,20 @@ async function registerServiceWorker() {
|
|||||||
|
|
||||||
// If a new SW is already waiting (e.g. user refreshed after an update),
|
// If a new SW is already waiting (e.g. user refreshed after an update),
|
||||||
// show the dialog right away.
|
// 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.
|
// Listen for a new SW installing after the page is open.
|
||||||
reg.addEventListener('updatefound', () => {
|
reg.addEventListener('updatefound', () => {
|
||||||
const sw = reg.installing;
|
const sw = reg.installing;
|
||||||
if (!sw) return;
|
if (!sw) return;
|
||||||
sw.addEventListener('statechange', () => {
|
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.
|
// The SW can also broadcast SW_UPDATE_AVAILABLE on its own activate.
|
||||||
navigator.serviceWorker.addEventListener('message', (e) => {
|
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)
|
// Check for updates in the background (useful for long-lived sessions)
|
||||||
@@ -5269,6 +5358,7 @@ async function boot() {
|
|||||||
els.searchInput.focus();
|
els.searchInput.focus();
|
||||||
|
|
||||||
// Register service worker + ping server with fingerprint (WEB mode only)
|
// Register service worker + ping server with fingerprint (WEB mode only)
|
||||||
|
if (WEB) checkUpdateOutcome();
|
||||||
registerServiceWorker();
|
registerServiceWorker();
|
||||||
if (WEB) pollBuildTag();
|
if (WEB) pollBuildTag();
|
||||||
if (WEB) {
|
if (WEB) {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#1a1311" />
|
<meta name="theme-color" content="#1a1311" />
|
||||||
|
<!-- Build this page was served from — the server stamps it at request time;
|
||||||
|
app.js compares it with /api/version before offering an update. -->
|
||||||
|
<meta name="ytp-build" content="__BUILD_TAG__" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
|||||||
@@ -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
|
* Framework-free and dependency-free on purpose (same pattern as
|
||||||
* async-guard.js):
|
* async-guard.js):
|
||||||
@@ -7,53 +7,128 @@
|
|||||||
* global `window.SwUpdate`).
|
* global `window.SwUpdate`).
|
||||||
* • `require`-able by `node --test` (CommonJS `module.exports`).
|
* • `require`-able by `node --test` (CommonJS `module.exports`).
|
||||||
*
|
*
|
||||||
* Bug this fixes (Task #61): "Update ready" kept reappearing right after the
|
* History: the "Update available keeps showing" loop was patched three times
|
||||||
* user clicked "Reload now" / "Refresh UI". The old flow called
|
* by chasing individual triggers (a transient `reg.waiting`, the activate
|
||||||
* hardReloadUI(), which unregisters the service worker and wipes every cache
|
* broadcast, the buildTag poll firing before a worker was waiting). It kept
|
||||||
* before navigating — forcing a brand-new install on the next load. That
|
* coming back on the real deployment because the homelab link is slow and
|
||||||
* fresh install briefly has `reg.waiting` truthy again (a normal but
|
* drops requests: the new worker's install (`cache.addAll`, all-or-nothing)
|
||||||
* transient SW lifecycle state), which registerServiceWorker() misread as a
|
* failed, "Refresh UI" reloaded into the OLD cached shell, and the next
|
||||||
* genuinely new update and re-showed the banner immediately.
|
* 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 —
|
* The fix no longer depends on the service-worker install lifecycle:
|
||||||
* postMessage SKIP_WAITING to it, wait for it to actually take control
|
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
|
||||||
* (`controllerchange`), and only then reload. The reloaded page is served by
|
* caches hold (cache-busted, so even an old worker's cache-first
|
||||||
* the new SW from its very first request, and no fresh install/registration
|
* handler can't answer with the stale copy) — ALL of them or nothing —
|
||||||
* cycle happens, so the banner has nothing to spuriously re-trigger on.
|
* 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) {
|
(function (root) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
const BUST_PARAM = '__ytpfresh';
|
||||||
* Applies a pending SW update: messages the waiting worker to skipWaiting(),
|
|
||||||
* waits for controllerchange, then reloads exactly once.
|
|
||||||
*
|
|
||||||
* @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<void>}
|
|
||||||
*/
|
|
||||||
async function applyUpdate({ reg, container, reload, setTimeout: setTimeoutFn }) {
|
|
||||||
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
|
||||||
let waiting = reg && reg.waiting;
|
|
||||||
|
|
||||||
if (!waiting && reg && typeof reg.update === 'function') {
|
function withTimeout(promise, ms, setTimeoutFn, label) {
|
||||||
// The banner can be triggered by the server buildTag poll before the
|
if (!ms || !setTimeoutFn) return promise;
|
||||||
// browser has fetched the new sw.js at all. With no waiting worker, a
|
return Promise.race([
|
||||||
// bare reload would be served the OLD cache-first shell, the new SW
|
promise,
|
||||||
// would then install in the background, and the banner would reappear
|
new Promise((_, reject) => setTimeoutFn(() => reject(new Error(label || 'timed out')), ms)),
|
||||||
// — 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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {CacheStorage} opts.cachesApi
|
||||||
|
* @param {(url: string, init: object) => Promise<Response>} 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 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
|
||||||
|
|
||||||
|
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<any>} [opts.refreshShell] refreshShellInPlace bound to real APIs
|
||||||
|
* @param {Function} [opts.setTimeout] injectable for tests
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
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) {
|
if (!waiting) {
|
||||||
// Nothing to activate (e.g. banner was shown from a broadcast message
|
|
||||||
// rather than an actual waiting worker) — just reload.
|
|
||||||
reload();
|
reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -64,34 +139,14 @@
|
|||||||
reloaded = true;
|
reloaded = true;
|
||||||
reload();
|
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 });
|
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
||||||
// Safety net in case controllerchange never fires (e.g. no controller yet).
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
||||||
if (scheduleTimeout) scheduleTimeout(reloadOnce, 3000);
|
|
||||||
|
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const SwUpdate = { applyUpdate, refreshShellInPlace, BUST_PARAM };
|
||||||
* 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 };
|
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = SwUpdate;
|
module.exports = SwUpdate;
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
'use strict';
|
'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
|
* On the real deployment (slow homelab link that drops requests) the new
|
||||||
* clicked "Reload now". Root cause — the old flow unregistered the service
|
* service worker's all-or-nothing install kept failing, so "Refresh UI"
|
||||||
* worker and wiped every cache before navigating, forcing a brand-new
|
* reloaded into the old cached shell and the banner came back. The fix
|
||||||
* install on the next load; that fresh install's registration briefly has
|
* refreshes the cached shell in place (all-or-nothing, cache-busted) before
|
||||||
* `reg.waiting` truthy again (a normal but transient SW lifecycle state),
|
* activating any waiting worker and reloading once.
|
||||||
* which was misread as a new pending update and re-showed the banner.
|
|
||||||
*
|
*
|
||||||
* These tests drive applyUpdate() directly against mocked registration /
|
* No real service worker or browser needed: fake CacheStorage / fetch /
|
||||||
* container / reload objects — no real service worker or browser needed.
|
* registration / container objects drive the functions directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { test } = require('node:test');
|
const { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
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.
|
// A minimal fake ServiceWorkerContainer supporting addEventListener/once.
|
||||||
function fakeContainer() {
|
function fakeContainer() {
|
||||||
@@ -29,172 +31,183 @@ function fakeContainer() {
|
|||||||
const fns = (listeners.controllerchange || []).slice();
|
const fns = (listeners.controllerchange || []).slice();
|
||||||
for (const { fn, once } of fns) {
|
for (const { fn, once } of fns) {
|
||||||
fn();
|
fn();
|
||||||
if (once) {
|
if (once) listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
|
||||||
listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('messages the waiting worker to skipWaiting and reloads only after controllerchange', async () => {
|
// Fake CacheStorage: { name: { url: body } }.
|
||||||
const messages = [];
|
function fakeCaches(initial) {
|
||||||
const waiting = { postMessage: (m) => messages.push(m) };
|
const store = new Map(Object.entries(initial).map(([k, v]) => [k, new Map(Object.entries(v))]));
|
||||||
const container = fakeContainer();
|
return {
|
||||||
let reloadCount = 0;
|
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({
|
const res = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, body, clone() { return res(body, status); } });
|
||||||
reg: { waiting },
|
const isShellCache = (n) => /^ytplayer-(?!thumbs$|fonts$)/.test(n);
|
||||||
container,
|
|
||||||
reload: () => { reloadCount++; },
|
test('refreshShellInPlace replaces every cached shell file in every shell cache, cache-busted', async () => {
|
||||||
setTimeout: () => {}, // no-op — we drive controllerchange manually
|
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' },
|
||||||
|
});
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// SKIP_WAITING should be sent immediately, before any reload.
|
test('refreshShellInPlace is all-or-nothing: one failed download writes nothing and throws', async () => {
|
||||||
await Promise.resolve();
|
const cachesApi = fakeCaches({
|
||||||
assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }]);
|
'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/styles.css`]: 'old-css' },
|
||||||
assert.strictEqual(reloadCount, 0, 'must not reload before the new SW has taken control');
|
});
|
||||||
|
await assert.rejects(refreshShellInPlace({
|
||||||
container.fireControllerChange();
|
cachesApi,
|
||||||
await done;
|
isShellCache,
|
||||||
assert.strictEqual(reloadCount, 1, 'reloads exactly once after controllerchange');
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reloads only once even if controllerchange fires more than once (no reload loop)', async () => {
|
test('refreshShellInPlace retries a dropped download with a fresh cache-bust token', async () => {
|
||||||
const waiting = { postMessage: () => {} };
|
const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/`]: 'old-index' } });
|
||||||
const container = fakeContainer();
|
const tries = [];
|
||||||
let reloadCount = 0;
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
const done = applyUpdate({
|
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');
|
||||||
|
};
|
||||||
|
await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, bust: 'T2' });
|
||||||
|
assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').keys()], [`${ORIGIN}/app.js`]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
reg: { waiting },
|
||||||
container,
|
container,
|
||||||
reload: () => { reloadCount++; },
|
reload: () => { reloads++; order.push('reload'); },
|
||||||
|
refreshShell: async () => { order.push('refresh'); },
|
||||||
setTimeout: () => {},
|
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();
|
||||||
container.fireControllerChange(); // simulate a spurious second event
|
container.fireControllerChange();
|
||||||
await done;
|
assert.strictEqual(reloads, 1, 'reloads exactly once');
|
||||||
|
|
||||||
assert.strictEqual(reloadCount, 1, 'reload must be idempotent — no loop');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to a plain reload when there is no waiting worker', async () => {
|
test('applyUpdate with no waiting worker reloads right after the refresh', async () => {
|
||||||
const container = fakeContainer();
|
const order = [];
|
||||||
let reloadCount = 0;
|
|
||||||
|
|
||||||
await applyUpdate({
|
await applyUpdate({
|
||||||
reg: { waiting: null },
|
reg: { waiting: null },
|
||||||
container,
|
container: fakeContainer(),
|
||||||
reload: () => { reloadCount++; },
|
reload: () => order.push('reload'),
|
||||||
setTimeout: () => { throw new Error('timeout should not be scheduled without a waiting worker'); },
|
refreshShell: async () => { order.push('refresh'); },
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(order, ['refresh', 'reload']);
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(reloadCount, 1);
|
test('applyUpdate does NOT reload or activate anything when the refresh fails', async () => {
|
||||||
});
|
|
||||||
|
|
||||||
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 messages = [];
|
||||||
const container = fakeContainer();
|
let reloads = 0;
|
||||||
let reloadCount = 0;
|
await assert.rejects(applyUpdate({
|
||||||
|
reg: { waiting: { postMessage: (m) => messages.push(m) } },
|
||||||
const stateListeners = [];
|
container: fakeContainer(),
|
||||||
const installing = {
|
reload: () => { reloads++; },
|
||||||
state: 'installing',
|
refreshShell: async () => { throw new Error('/app.js → 504'); },
|
||||||
addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); },
|
}), /504/);
|
||||||
postMessage: (m) => messages.push(m),
|
assert.strictEqual(reloads, 0, 'reloading now would just serve the old shell again (the loop)');
|
||||||
};
|
assert.deepStrictEqual(messages, []);
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const done = applyUpdate({
|
|
||||||
reg,
|
|
||||||
container,
|
|
||||||
reload: () => { reloadCount++; },
|
|
||||||
setTimeout: () => {}, // no-op — we drive state transitions manually
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('with no waiting worker and no update found, reloads once after the bounded wait', async () => {
|
|
||||||
const container = fakeContainer();
|
|
||||||
let reloadCount = 0;
|
|
||||||
const timeouts = [];
|
|
||||||
|
|
||||||
const reg = {
|
|
||||||
waiting: null,
|
|
||||||
installing: null,
|
|
||||||
update: () => Promise.resolve(), // update check ran; nothing 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');
|
|
||||||
});
|
|
||||||
|
|
||||||
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++; },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.strictEqual(reloadCount, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the timeout safety net reloads once if controllerchange never fires', async () => {
|
test('the timeout safety net reloads once if controllerchange never fires', async () => {
|
||||||
const waiting = { postMessage: () => {} };
|
let fire = null;
|
||||||
|
let reloads = 0;
|
||||||
const container = fakeContainer();
|
const container = fakeContainer();
|
||||||
let reloadCount = 0;
|
|
||||||
let scheduledFn = null;
|
|
||||||
|
|
||||||
await applyUpdate({
|
await applyUpdate({
|
||||||
reg: { waiting },
|
reg: { waiting: { postMessage() {} } },
|
||||||
container,
|
container,
|
||||||
reload: () => { reloadCount++; },
|
reload: () => { reloads++; },
|
||||||
setTimeout: (fn) => { scheduledFn = fn; }, // capture instead of real timer
|
setTimeout: (fn) => { fire = fn; },
|
||||||
});
|
});
|
||||||
|
fire();
|
||||||
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.
|
|
||||||
container.fireControllerChange();
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,12 +11,12 @@
|
|||||||
* Auto-update flow:
|
* Auto-update flow:
|
||||||
* 1. New SW installs alongside the old one and waits (skipWaiting() is NOT
|
* 1. New SW installs alongside the old one and waits (skipWaiting() is NOT
|
||||||
* called automatically — see the install handler below).
|
* called automatically — see the install handler below).
|
||||||
* 2. Client (app.js registerServiceWorker()) notices the waiting worker
|
* 2. The client (app.js) shows the "Update available" banner only when the
|
||||||
* and shows the "Update ready" banner.
|
* build it is running differs from the server's /api/version buildTag —
|
||||||
* 3. User clicks "Reload now" → client (applyUpdate() in sw-update.js)
|
* a waiting worker or a broadcast is merely a prompt to re-check.
|
||||||
* posts SKIP_WAITING to the waiting worker and waits for it to take
|
* 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every
|
||||||
* control (controllerchange) before reloading — it does NOT unregister
|
* versioned shell cache (all-or-nothing), activates a waiting worker if
|
||||||
* or wipe caches, so the reload doesn't force a brand-new install.
|
* there is one (SKIP_WAITING), and reloads once.
|
||||||
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
||||||
* activation is genuinely replacing a previous deploy, not just the
|
* activation is genuinely replacing a previous deploy, not just the
|
||||||
* first-ever install of a freshly (re)registered worker), broadcast
|
* first-ever install of a freshly (re)registered worker), broadcast
|
||||||
@@ -68,14 +68,40 @@ const SHELL = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ---- Install: pre-cache the app 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) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(
|
e.waitUntil(precacheShell());
|
||||||
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.
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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-<tag> 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 ----
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||||
self.addEventListener('activate', (e) => {
|
self.addEventListener('activate', (e) => {
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
|
|||||||
@@ -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
|
// Builds a fresh sandboxed SW environment with the given starting cache keys
|
||||||
// and returns handles to drive/inspect it.
|
// and returns handles to drive/inspect it.
|
||||||
function loadSw(initialCacheKeys) {
|
function loadSw(initialCacheKeys, { fetchImpl } = {}) {
|
||||||
const cacheStore = new Set(initialCacheKeys);
|
const cacheStore = new Set(initialCacheKeys);
|
||||||
|
const puts = [];
|
||||||
const deleted = [];
|
const deleted = [];
|
||||||
const clientMessages = [];
|
const clientMessages = [];
|
||||||
|
|
||||||
@@ -50,14 +51,15 @@ function loadSw(initialCacheKeys) {
|
|||||||
async delete(key) { deleted.push(key); return cacheStore.delete(key); },
|
async delete(key) { deleted.push(key); return cacheStore.delete(key); },
|
||||||
async open(key) {
|
async open(key) {
|
||||||
cacheStore.add(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 = {
|
const sandbox = {
|
||||||
self: self_,
|
self: self_,
|
||||||
caches: caches_,
|
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); } },
|
Response: class { constructor(body, init) { this.body = body; Object.assign(this, init); } },
|
||||||
URL,
|
URL,
|
||||||
console,
|
console,
|
||||||
@@ -72,7 +74,14 @@ function loadSw(initialCacheKeys) {
|
|||||||
await waitPromise;
|
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 () => {
|
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()
|
['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(), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1581,9 +1581,27 @@ app.get('/sw.js', (c) => {
|
|||||||
// Static files — serve the frontend/public directory
|
// Static files — serve the frontend/public directory
|
||||||
// Must come AFTER all /api routes so API takes priority
|
// Must come AFTER all /api routes so API takes priority
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
app.use('/*', serveStatic({ root: './public' }));
|
// index.html carries the build tag it belongs to (<meta name="ytp-build">),
|
||||||
|
// 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
|
// SPA fallback — return index.html for any unmatched path
|
||||||
app.get('/*', serveStatic({ path: './public/index.html' }));
|
app.get('/*', indexHtml);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Boot
|
// Boot
|
||||||
|
|||||||
Reference in New Issue
Block a user