Synchronize verified assets incrementally and guard update activation

This commit is contained in:
Jonathan Sykes
2026-10-07 23:40:20 +08:00
parent a1f16f2e3b
commit 54a07a0a2a
15 changed files with 260 additions and 22 deletions

View File

@@ -27,13 +27,15 @@
// It changes on every deploy/restart so the cache is busted automatically
// without any manual version bump.
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
const ASSET_SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;
if (ASSET_SYNC) importScripts('/asset-sync-core.js');
const CACHE = 'ytplayer-' + VERSION;
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
// match this — they aren't versioned and must survive every activate.
const SHELL_CACHE_PREFIX = 'ytplayer-';
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts']);
const UTILITY_CACHES = new Set(['ytplayer-thumbs', 'ytplayer-fonts', 'ytplayer-assets']);
const THUMB_CACHE = 'ytplayer-thumbs';
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
@@ -71,6 +73,7 @@ const SHELL = [
'/export.js',
'/async-guard.js',
'/visible-timer.js',
'/asset-sync-core.js',
'/sw-update.js',
'/fingerprint.js',
'/opfs.js',
@@ -146,7 +149,7 @@ const 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(precacheShell());
e.waitUntil(ASSET_SYNC ? installAssets() : precacheShell());
});
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
@@ -180,6 +183,7 @@ async function precacheShell() {
// ---- Activate: evict old caches, claim clients, notify about update ----
self.addEventListener('activate', (e) => {
e.waitUntil((async () => {
if (ASSET_SYNC) { await activateAssets(); return; }
const keys = await caches.keys();
// Was there a *previous deploy's* app-shell cache lying around? If so,
@@ -278,13 +282,20 @@ self.addEventListener('fetch', (e) => {
}
// App shell → cache-first, then network, then generic offline fallback
e.respondWith(cacheFirst(request));
e.respondWith(cacheFirst(request, e.clientId));
});
// ---- Message: handle SKIP_WAITING from the client ----
const playingClients = new Map(), deferredClients = new Set();
self.addEventListener('message', (e) => {
if (e.data && e.data.type === 'PLAYING' && e.source) {
playingClients.set(e.source.id, !!e.data.value);
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
}
if (e.data && e.data.type === 'SKIP_WAITING') {
self.skipWaiting();
const id = e.source && e.source.id;
if (playingClients.get(id)) deferredClients.add(id);
else self.skipWaiting();
}
// "Is your shell already downloaded?" — a waiting worker has precached the
// whole new build during install, so "Refresh UI" can swap to it instantly
@@ -293,6 +304,13 @@ self.addEventListener('message', (e) => {
// storage pressure, so the files are actually checked.
if (e.data && e.data.type === 'CACHE_STATUS') {
e.waitUntil((async () => {
if (ASSET_SYNC) {
let reply = { ready: false, missing: -1, version: VERSION };
try { const cache = await caches.open(AssetSyncCore.CACHE); const m = await candidate(cache); if(m) reply = await AssetSyncCore.status(m,cache); } catch {}
reply.type = 'CACHE_STATUS'; reply.assetSync = true;
if(e.ports && e.ports[0]) e.ports[0].postMessage(reply); else if(e.source) e.source.postMessage(reply);
return;
}
let missing = SHELL.length;
try {
const cache = await caches.open(CACHE);
@@ -311,7 +329,8 @@ self.addEventListener('message', (e) => {
// ============================================================================
// Cache-first: serve from cache; if missing, fetch, cache, return.
async function cacheFirst(request) {
async function cacheFirst(request, clientId) {
if (ASSET_SYNC) return assetFetch(request, clientId);
const cache = await caches.open(CACHE);
// Shell files are precached under their plain URL; index.html asks for them as
// `file?v=<build>` (so the HTTP cache can keep them forever) — match ignoring it.
@@ -417,3 +436,88 @@ async function staleWhileRevalidate(request, cacheName) {
// surface a real network error instead.
return cached || (await networkFetch) || Response.error();
}
// Installation stores a candidate only. Activation publishes one atomic state record.
const candidateKey = '/__ytp_candidate/' + VERSION;
async function candidate(cache) { const r = await cache.match(candidateKey); return r ? r.json() : null; }
async function installAssets() {
const r = await fetch('/api/manifest', { cache: 'no-store' });
if (!r.ok) throw Error('Manifest unavailable');
const m = await r.json();
if (m.buildTag !== VERSION) throw Error('Deployment changed during install');
const cache = await caches.open(AssetSyncCore.CACHE);
const freshInstall = !(await AssetSyncCore.state(cache));
const fetchAsset = async (url,init) => {
// Fonts/icons still use plain URLs in CSS/manifest. On first install reuse
// their HTTP-cache responses, but only if the hash proves current bytes.
if(freshInstall && /^(?:\/fonts\/|\/icons\/|\/manifest.webmanifest)/.test(url)) {
const r = await fetch(url.split('?')[0],{...init,cache:'force-cache'});
if(r.ok && r.headers.get('X-Asset-Hash') === url.split('v=')[1]) return r;
}
return fetch(url,init);
};
await AssetSyncCore.syncAssets(m, { cache, fetchFn: fetchAsset });
await cache.put(candidateKey, new Response(JSON.stringify(m)));
}
async function migrateLegacy(cache, m) {
// Legacy responses lack hash headers. Verify their actual bytes locally before
// retaining them; this also preserves old whole-build URLs in already-open tabs.
const names = (await caches.keys()).filter(isVersionedShellCache);
let previous = null;
for (const name of names) {
const old = await caches.open(name);
const files = {}, legacyURLs = {};
for (const req of await old.keys()) {
const u = new URL(req.url), p = u.pathname === '/' ? '/index.html' : u.pathname;
if (!m.files[p]) continue;
const r = await old.match(req);
if (!r || !r.ok) continue;
const bytes = await r.clone().arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', bytes);
const h = [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2,'0')).join('').slice(0,10);
const headers = new Headers(r.headers); headers.set('X-Asset-Hash', h);
files[p] = { h }; legacyURLs[p] = p + '?v=' + h;
await cache.put(legacyURLs[p], new Response(bytes, { headers }));
}
if (Object.keys(files).length) previous = { buildTag:name.slice(SHELL_CACHE_PREFIX.length),files,groups:{core:{files:Object.keys(files)}},legacyURLs };
}
if (previous && !(await AssetSyncCore.state(cache))) await cache.put(AssetSyncCore.STATE, new Response(JSON.stringify({current:previous})));
return names;
}
async function activateAssets() {
const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache);
if (!m) throw Error('Missing candidate manifest');
const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache);
const state = await AssetSyncCore.commit(m,cache);
state.previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
await cache.put(AssetSyncCore.STATE,new Response(JSON.stringify(state)));
// Legacy deletion occurs strictly after verified commit.
await Promise.all(names.map(n => caches.delete(n)));
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
await self.clients.claim();
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
}
async function assetFetch(request, clientId) {
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
if(!state) return fetch(request);
const u = new URL(request.url);
let m = state.current;
if(u.origin !== self.location.origin) return fetch(request);
if(u.searchParams.has('__ytpfresh')) return fetch(request);
const nav = request.mode === 'navigate' || u.pathname === '/' || u.pathname === '/index.html';
if(!nav && state.previousClients?.includes(clientId) && state.previous?.files[u.pathname]) m = state.previous;
let path = nav ? '/index.html' : u.pathname;
let key;
if(u.searchParams.has('v') && !nav) {
key = u.pathname + u.search;
if(state.previous?.legacyURLs && u.searchParams.get('v') === state.previous.buildTag) key = state.previous.legacyURLs[path] || key;
} else if(m.files[path]) key = AssetSyncCore.url(path,m.files[path]);
if(key) {
const cached = await cache.match(key);
const h = new URL(key,self.location.origin).searchParams.get('v');
if(cached && cached.headers.get('X-Asset-Hash') === h) return cached;
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') === h) await cache.put(key,r.clone()); return r; } catch { return new Response('Offline',{status:503}); }
}
try { return await fetch(request); } catch { return new Response('Offline',{status:503}); }
}