291 lines
14 KiB
JavaScript
291 lines
14 KiB
JavaScript
/* ============================================================================
|
|
* sw-update — makes "Refresh UI" actually land the new build.
|
|
*
|
|
* Framework-free, with a shared sync core and a legacy fallback (same pattern as
|
|
* async-guard.js):
|
|
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
|
* global `window.SwUpdate`).
|
|
* • `require`-able by `node --test` (CommonJS `module.exports`).
|
|
*
|
|
* 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).
|
|
*
|
|
* Legacy fallback (retained for ASSET_SYNC=0):
|
|
* 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.
|
|
* Incremental mode uses AssetSyncCore to fetch only missing verified hash URLs
|
|
* and publishes a complete blocking set. Playback guards cover activation and
|
|
* reload callbacks; PLAYING reports provide an additional worker guard.
|
|
*
|
|
* 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';
|
|
const STAGING_PREFIX = 'ytp-staging-';
|
|
const DEFAULT_CONCURRENCY = 4;
|
|
|
|
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)),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Re-downloads every file held by the versioned shell caches and replaces
|
|
* the cached copies. All-or-nothing: if any download fails, the live shell
|
|
* is untouched and it throws, so a flaky connection can never leave a
|
|
* half-old/half-new shell. Files that did arrive stay staged, so the next
|
|
* try for the same build resumes instead of starting over.
|
|
*
|
|
* @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 {number} [opts.concurrency] downloads in flight at once (default 4)
|
|
* @param {string} [opts.stagingKey] target build; staged files survive a failed try for it
|
|
* @param {Function} [opts.setTimeout]
|
|
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
|
*/
|
|
async function refreshLegacyShell({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) {
|
|
const names = (await cachesApi.keys()).filter(isShellCache);
|
|
if (!names.length) return { refreshed: 0, caches: 0, resumed: 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);
|
|
}
|
|
}
|
|
|
|
// Resumable: every file lands in a staging cache the moment it arrives,
|
|
// so a retry after a timeout only fetches what is still missing. The
|
|
// staging cache is keyed by the target build — a newer deploy never
|
|
// reuses files staged for an older one. Nothing touches the live shell
|
|
// caches until the whole set is staged (still all-or-nothing).
|
|
const stagingName = STAGING_PREFIX + (stagingKey || 'current');
|
|
for (const k of await cachesApi.keys()) {
|
|
if (k.startsWith(STAGING_PREFIX) && k !== stagingName) await cachesApi.delete(k);
|
|
}
|
|
const staging = await cachesApi.open(stagingName);
|
|
const missing = [];
|
|
for (const url of urls) if (!(await staging.match(url))) missing.push(url);
|
|
const resumed = urls.size - missing.length;
|
|
|
|
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) { await staging.put(url, res); return; }
|
|
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;
|
|
};
|
|
// A lossy link copes far better with a few requests in flight than with
|
|
// the whole shell at once.
|
|
const pool = async () => {
|
|
const queue = missing.slice();
|
|
let failed = null;
|
|
const worker = async () => {
|
|
while (queue.length && !failed) {
|
|
const url = queue.shift();
|
|
try { await fetchOne(url); } catch (err) { failed = failed || err; }
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency || DEFAULT_CONCURRENCY, queue.length || 1)) }, worker));
|
|
if (failed) throw failed;
|
|
};
|
|
await withTimeout(pool(), timeoutMs, setTimeoutFn, 'update download timed out');
|
|
|
|
for (const name of names) {
|
|
const cache = await cachesApi.open(name);
|
|
for (const url of urls) {
|
|
const res = await staging.match(url);
|
|
if (res) await cache.put(url, res);
|
|
}
|
|
// 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);
|
|
}
|
|
}
|
|
await cachesApi.delete(stagingName);
|
|
return { refreshed: urls.size, caches: names.length, resumed };
|
|
}
|
|
|
|
async function refreshShellInPlace(opts) {
|
|
const core = root.AssetSyncCore;
|
|
if (core) {
|
|
const response = await opts.fetchFn('/api/manifest', { cache: 'no-store' });
|
|
if (!response.ok) throw new Error('Manifest unavailable');
|
|
const m = await response.json();
|
|
const cache = await opts.cachesApi.open(core.CACHE);
|
|
// Only upgraded workers can route the persistent scheme. A rollback
|
|
// worker explicitly advertises the legacy path through CACHE_STATUS.
|
|
const reg = root.navigator && await root.navigator.serviceWorker.getRegistration();
|
|
const worker = reg && (reg.waiting || reg.active);
|
|
const status = await askCacheStatus(worker);
|
|
// Unknown protocol is a retryable failure, never an empty successful
|
|
// legacy refresh followed by a reload into the old persistent manifest.
|
|
if (worker && !status) throw new Error('Worker update status unavailable; try again');
|
|
if (status && status.assetSync) {
|
|
const activeLayout = root.Lazy?.layout || 'classic';
|
|
const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn,activeLayout});
|
|
if (!reg.waiting) await core.commit(m,cache,{activeLayout});
|
|
return result;
|
|
}
|
|
}
|
|
return refreshLegacyShell({ ...opts, isShellCache: n => n !== 'ytplayer-assets' && opts.isShellCache(n) });
|
|
}
|
|
|
|
function playing() { return typeof Player !== 'undefined' && !!Player._wantsPlaying; }
|
|
function reportPlaying(value = playing()) {
|
|
const sw = root.navigator && root.navigator.serviceWorker;
|
|
if (!sw) return;
|
|
sw.getRegistration().then(reg => {
|
|
for (const worker of [sw.controller, reg && reg.waiting]) if(worker) worker.postMessage({type:'PLAYING',value});
|
|
}).catch(()=>{});
|
|
}
|
|
if (root.document && root.navigator && root.navigator.serviceWorker) {
|
|
if (root.VisibleTimer) root.VisibleTimer.setIntervalWhenVisible(()=>reportPlaying(),1000);
|
|
for (const event of ['play','pause','ended']) root.document.addEventListener(event,()=>reportPlaying(),true);
|
|
root.document.addEventListener('visibilitychange',()=>reportPlaying());
|
|
root.navigator.serviceWorker.addEventListener('controllerchange',()=>reportPlaying());
|
|
}
|
|
|
|
/**
|
|
* Asks a worker whether it has already precached its whole shell.
|
|
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
|
* the caller then takes the download path, which is always correct.
|
|
*
|
|
* @param {ServiceWorker} worker
|
|
* @param {object} [opts]
|
|
* @param {number} [opts.timeoutMs]
|
|
* @param {Function} [opts.setTimeout]
|
|
* @param {Function} [opts.Channel] MessageChannel constructor (injectable)
|
|
* @returns {Promise<{ready: boolean, version: string}|null>}
|
|
*/
|
|
function askCacheStatus(worker, { timeoutMs = 3000, setTimeout: setTimeoutFn, Channel } = {}) {
|
|
const Ctor = Channel || (typeof MessageChannel !== 'undefined' ? MessageChannel : null);
|
|
const schedule = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
|
if (!worker || !Ctor) return Promise.resolve(null);
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
try {
|
|
const ch = new Ctor();
|
|
ch.port1.onmessage = (ev) => finish(ev && ev.data ? ev.data : null);
|
|
worker.postMessage({ type: 'CACHE_STATUS', activeLayout: root.Lazy?.layout }, [ch.port2]);
|
|
} catch { finish(null); return; }
|
|
if (schedule) schedule(() => finish(null), timeoutMs);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* Fast path: a waiting worker precached the entire new shell while it
|
|
* installed, so when it confirms that, the download is skipped and the swap
|
|
* is immediate. If it cannot confirm, the all-or-nothing download runs as
|
|
* before — the slow path is still what guarantees correctness.
|
|
*
|
|
* @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, askStatus, onPhase, isPlaying = playing, confirmOverride = root.confirm && root.confirm.bind(root), setTimeout: setTimeoutFn }) {
|
|
let overridden = false;
|
|
const guard = () => {
|
|
if (!isPlaying() || overridden) return true;
|
|
if (confirmOverride && confirmOverride('Playing - update after this song / when paused. Update now anyway? Playback may stop.')) { overridden = true; return true; }
|
|
return false;
|
|
};
|
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
|
const scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
|
const waiting = reg && reg.waiting;
|
|
|
|
// Did the waiting worker already download this build in the background?
|
|
// Opt-in: without an askStatus the download path runs exactly as before.
|
|
// (It must never be implicit — a probe that waits on a reply would hang
|
|
// forever if the caller injected a setTimeout that never fires.)
|
|
let precached = false;
|
|
if (waiting && askStatus) {
|
|
try {
|
|
const status = await askStatus(waiting);
|
|
precached = !!(status && status.ready);
|
|
} catch { precached = false; }
|
|
}
|
|
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
|
if (!precached && refreshShell) await refreshShell();
|
|
|
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
|
if (!waiting) {
|
|
reload();
|
|
return;
|
|
}
|
|
|
|
let reloaded = false;
|
|
const reloadOnce = () => {
|
|
if (reloaded || (isPlaying() && !overridden)) return;
|
|
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 });
|
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
|
if (overridden) waiting.postMessage({type:'PLAYING',value:false});
|
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
|
}
|
|
|
|
const SwUpdate = { applyUpdate, refreshShellInPlace, askCacheStatus, BUST_PARAM };
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = SwUpdate;
|
|
} else {
|
|
root.SwUpdate = SwUpdate;
|
|
}
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|