Merge main into jamesdsp and register its assets
This commit is contained in:
216
frontend/sw.js
216
frontend/sw.js
@@ -2,7 +2,7 @@
|
||||
* sw.js — YT Player Service Worker
|
||||
*
|
||||
* Strategy:
|
||||
* App shell (HTML/CSS/JS) → cache-first, versioned cache
|
||||
* App shell (HTML/CSS/JS) → cache-first, exact per-file hash keys
|
||||
* /api/* requests → network-only (never cache yt-dlp results)
|
||||
* /api/download/* → network-only (streamed binary, never cache)
|
||||
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
||||
@@ -14,9 +14,9 @@
|
||||
* 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.
|
||||
* 3. Install syncs missing assets into ytplayer-assets. "Refresh UI" activates
|
||||
* the verified waiting worker (SKIP_WAITING) and reloads once. The legacy
|
||||
* full-shell path remains available through ASSET_SYNC=0.
|
||||
* 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
|
||||
@@ -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
|
||||
@@ -58,6 +60,7 @@ const SHELL = [
|
||||
'/styles.css',
|
||||
'/service-ui.css',
|
||||
'/refresh.css',
|
||||
'/layout-base.css',
|
||||
'/theme-glass.css',
|
||||
'/theme-bento-hub.css',
|
||||
'/theme-f7.css',
|
||||
@@ -71,6 +74,11 @@ const SHELL = [
|
||||
'/export.js',
|
||||
'/async-guard.js',
|
||||
'/visible-timer.js',
|
||||
'/asset-sync-core.js',
|
||||
'/lazy.js',
|
||||
'/feature-bootstrap.js',
|
||||
'/eq-parameters.js',
|
||||
'/assets.json',
|
||||
'/sw-update.js',
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
@@ -131,9 +139,18 @@ const SHELL = [
|
||||
'/piano.js',
|
||||
'/setlist-import.js',
|
||||
'/car-mode.js',
|
||||
'/prewarm-next.js',
|
||||
'/car-mode.css',
|
||||
'/lower-third.css',
|
||||
'/settings-sections.css',
|
||||
'/shell-core.js',
|
||||
'/views-core.js',
|
||||
'/section-rail.js',
|
||||
'/presenter.js',
|
||||
'/remote-client.js',
|
||||
'/watch-party-client.js',
|
||||
'/share-external.js',
|
||||
'/stats-view.js',
|
||||
'/app.js',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
@@ -151,7 +168,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
|
||||
@@ -185,6 +202,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,
|
||||
@@ -252,6 +270,8 @@ self.addEventListener('fetch', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ASSET_SYNC && request.mode === 'navigate') e.waitUntil(completeAssets());
|
||||
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||
|
||||
@@ -283,13 +303,62 @@ 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();
|
||||
let activeLayout = 'classic', layoutReply;
|
||||
let completionTask;
|
||||
function completeAssets(force = false) {
|
||||
if (!ASSET_SYNC) return Promise.resolve();
|
||||
completionTask ||= (async () => {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE);
|
||||
const m = (await AssetSyncCore.state(cache))?.current;
|
||||
if (!m || m.buildTag !== VERSION) return;
|
||||
const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force,
|
||||
notify: async status => {
|
||||
for (const client of await self.clients.matchAll({ type: 'window' }))
|
||||
client.postMessage({ type: 'OFFLINE_STATUS', ...status });
|
||||
}
|
||||
});
|
||||
if (!job.offlineReady) {
|
||||
// Chromium can wake this job without a page; Safari resumes on the next
|
||||
// navigation/online message. No timers outside an event lifetime.
|
||||
try { await self.registration.sync?.register('ytp-offline-complete'); } catch {}
|
||||
}
|
||||
})().catch(error => console.warn('[asset-sync] completion paused', error.message))
|
||||
.finally(() => { completionTask = null; });
|
||||
return completionTask;
|
||||
}
|
||||
self.addEventListener('sync', e => {
|
||||
if (e.tag === 'ytp-offline-complete') e.waitUntil(completeAssets());
|
||||
});
|
||||
self.addEventListener('message', (e) => {
|
||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') {
|
||||
activeLayout = e.data.value;
|
||||
layoutReply?.();
|
||||
e.waitUntil((async()=>{
|
||||
const cache=await caches.open(AssetSyncCore.CACHE);
|
||||
await cache.put('/__ytp_layout',Response.json({layout:activeLayout}));
|
||||
const m=await candidate(cache);
|
||||
if(m && m.activeLayout!==activeLayout) {
|
||||
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetch,activeLayout});
|
||||
m.activeLayout=activeLayout; await cache.put(candidateKey,Response.json(m));
|
||||
}
|
||||
})());
|
||||
}
|
||||
if (ASSET_SYNC && ['WARM_ASSETS', 'COMPLETE_ASSETS'].includes(e.data?.type)) {
|
||||
e.waitUntil(completeAssets(e.data.type === 'COMPLETE_ASSETS'));
|
||||
}
|
||||
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' && e.source && e.source.id) {
|
||||
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
|
||||
@@ -297,7 +366,19 @@ self.addEventListener('message', (e) => {
|
||||
// deletes its cache on failure, but a cache can also be evicted under
|
||||
// storage pressure, so the files are actually checked.
|
||||
if (e.data && e.data.type === 'CACHE_STATUS') {
|
||||
if (ASSET_SYNC) e.waitUntil(completeAssets());
|
||||
e.waitUntil((async () => {
|
||||
if (ASSET_SYNC) {
|
||||
let reply = { ready: false, missing: -1, version: VERSION };
|
||||
try {
|
||||
const cache = await caches.open(AssetSyncCore.CACHE), state = await AssetSyncCore.state(cache);
|
||||
const pending = await candidate(cache), m = pending || state?.current;
|
||||
if(m) reply = await AssetSyncCore.status(m,cache,{previous:pending ? state?.current : state?.previous,activeLayout:e.data.activeLayout || (pending ? m.activeLayout : activeLayout) || 'classic'});
|
||||
} catch {}
|
||||
reply.type = 'CACHE_STATUS'; reply.assetSync = true; reply.completing = !!completionTask && !reply.offlineReady;
|
||||
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);
|
||||
@@ -316,7 +397,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.
|
||||
@@ -422,3 +504,117 @@ 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 remembered = await cache.match('/__ytp_layout');
|
||||
if(remembered)activeLayout=(await remembered.json()).layout || 'classic';
|
||||
let layoutKnown = !!remembered;
|
||||
const clients = await self.clients.matchAll({type:'window',includeUncontrolled:true});
|
||||
await new Promise(resolve=>{
|
||||
const timer=setTimeout(resolve,500); layoutReply=()=>{layoutKnown=true;clearTimeout(timer);resolve();};
|
||||
for(const client of clients)client.postMessage({type:'ASSET_LAYOUT_REQUEST'});
|
||||
});
|
||||
layoutReply=null; m.activeLayout=activeLayout;
|
||||
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, activeLayout });
|
||||
// Older pages cannot report their saved choice. Keep every layout ready for
|
||||
// that one migration so an immediate offline reload cannot lose its theme.
|
||||
if(!layoutKnown && ((await AssetSyncCore.state(cache)) || (await caches.keys()).some(isVersionedShellCache))) {
|
||||
const layouts=new Set(Object.keys(m.groups).filter(name=>name.startsWith('layout:')));
|
||||
for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency);
|
||||
await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]});
|
||||
}
|
||||
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 previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||
await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout});
|
||||
// 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});
|
||||
// Completion owns a message-event lifetime, not the activation barrier.
|
||||
// In particular, a legacy client's four-second reload must not wait for it.
|
||||
self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' });
|
||||
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') && state.current.files[u.pathname]) {
|
||||
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;
|
||||
if(state.current.files[path]?.h === h) {
|
||||
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
||||
const previousResponse=previousKey && await cache.match(previousKey);
|
||||
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) {
|
||||
// A stale body returned under a new URL must not poison the browser's
|
||||
// resource cache either. Its decoded stream has no transport encoding.
|
||||
const headers=new Headers(previousResponse.headers);headers.set('Cache-Control','no-store');
|
||||
for(const name of ['Content-Encoding','Content-Length','Transfer-Encoding'])headers.delete(name);
|
||||
return new Response(previousResponse.body,{status:previousResponse.status,headers});
|
||||
}
|
||||
}
|
||||
try { const r = await fetch(key); if(r.ok && r.headers.get('X-Asset-Hash') !== h) return new Response('Asset version unavailable',{status:409}); if(r.ok && request.method !== 'HEAD') 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