Synchronize verified assets incrementally and guard update activation
This commit is contained in:
114
frontend/sw.js
114
frontend/sw.js
@@ -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}); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user