diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js new file mode 100644 index 0000000..d3c9f37 --- /dev/null +++ b/frontend/asset-sync-core.js @@ -0,0 +1,25 @@ +/* Exact, resumable asset synchronization shared by pages and workers. */ +(function(root){ + 'use strict'; + const CACHE='ytplayer-assets', STATE='/__ytp_asset_state'; + const url=(path,file)=>path+'?v='+file.h; + function paths(m){return [...new Set(Object.values(m.groups).flatMap(g=>g.files))].sort();} + function blocking(m){return [...new Set(Object.entries(m.groups).filter(([n,g])=>n==='core'||!g.background).flatMap(([,g])=>g.files))].sort();} + function plan(m,keys){const held=new Set(keys);return {missing:paths(m).map(p=>url(p,m.files[p])).filter(u=>!held.has(u)),blocking:blocking(m).map(p=>url(p,m.files[p]))};} + function retained(current,previous){const keep=new Set(paths(current).map(p=>url(p,current.files[p])));if(previous)for(const p of paths(previous))if(current.files[p]&¤t.files[p].h!==previous.files[p].h)keep.add(url(p,previous.files[p]));return keep;} + async function status(m,cache){let missing=0;for(const p of blocking(m)){const r=await cache.match(url(p,m.files[p]));if(!r||r.headers.get('X-Asset-Hash')!==m.files[p].h)missing++;}return {ready:missing===0,missing,version:m.buildTag};} + async function syncAssets(m,{cache,fetchFn,concurrency=6,attempts=3}){ + const missing=[];for(const p of paths(m)){const u=url(p,m.files[p]),r=await cache.match(u);if(!r||r.headers.get('X-Asset-Hash')!==m.files[p].h)missing.push(u);} + const count=missing.length;let failure; + await Promise.all(Array.from({length:Math.min(6,concurrency,Math.max(1,count))},async()=>{ + while(missing.length&&!failure){const u=missing.shift();let error; + for(let i=0;ictl.abort(),30000);let r;try{r=await fetchFn(u,{credentials:'same-origin',signal:ctl.signal});}finally{clearTimeout(timer);}if(!r.ok||r.headers.get('X-Asset-Hash')!==u.split('v=')[1])throw Error('Asset hash mismatch: '+u);await cache.put(u,r);error=null;break;}catch(e){error=e;}} + if(error)failure=error; + } + }));if(failure)throw failure;if(!(await status(m,cache)).ready)throw Error('Incomplete blocking assets');return {refreshed:count,caches:1}; + } + async function state(cache){const r=await cache.match(STATE);return r?await r.json():null;} + async function commit(m,cache){if(!(await status(m,cache)).ready)throw Error('Incomplete blocking assets');const old=await state(cache);const previous=old?.current?.buildTag===m.buildTag?old.previous:old?.current;await cache.put(STATE,new Response(JSON.stringify({current:m,previous}),{headers:{'Content-Type':'application/json'}}));const keep=retained(m,previous);for(const req of await cache.keys()){const u=new URL(req.url);const k=u.pathname+u.search;if(u.searchParams.has('v')&&!keep.has(k))await cache.delete(k);}return {current:m,previous};} + const api={CACHE,STATE,url,paths,blocking,plan,retained,status,syncAssets,state,commit}; + if(typeof module!=='undefined'&&module.exports)module.exports=api;else root.AssetSyncCore=api; +})(typeof globalThis!=='undefined'?globalThis:this); diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js new file mode 100644 index 0000000..1b62137 --- /dev/null +++ b/frontend/asset-sync-core.test.js @@ -0,0 +1,9 @@ +const {test}=require('node:test'); +const assert=require('node:assert/strict'); +const core=require('./asset-sync-core'); +const manifest=(a='a',b='b')=>({buildTag:a+b,files:{'/a.js':{h:a},'/index.html':{h:b}},groups:{core:{files:['/a.js','/index.html']}}}); +function cache(){const entries=new Map();return {entries,async match(k){return entries.get(k)?.clone()},async put(k,r){entries.set(k,r.clone())},async keys(){return [...entries.keys()].map(url=>({url:'http://local'+url}))},async delete(k){return entries.delete(k)}}} +const response=h=>new Response(h,{headers:{'X-Asset-Hash':h}}); +test('exact diff shares unchanged URLs; retains N-1 and prunes N-2',()=>{assert.deepEqual(core.plan(manifest(),['/a.js?v=a']).missing,['/index.html?v=b']);assert.deepEqual([...core.retained(manifest('c'),manifest())].sort(),['/a.js?v=a','/a.js?v=c','/index.html?v=b']);}); +test('interrupted sync resumes verified files and refuses a mismatched hash',async()=>{const c=cache();let fail=true;let calls=[];const fetchFn=async u=>{calls.push(u);if(u.includes('index')&&fail)return response('wrong');return response(u.split('=')[1])};await assert.rejects(core.syncAssets(manifest(),{cache:c,fetchFn}),/hash/);assert.ok(await c.match('/a.js?v=a'));fail=false;calls=[];await core.syncAssets(manifest(),{cache:c,fetchFn});assert.deepEqual(calls,['/index.html?v=b']);await c.delete('/a.js?v=a');assert.equal((await core.status(manifest(),c)).missing,1);}); +test('commit is atomic and pruning keeps only current and previous changed versions',async()=>{const c=cache();await c.put('/a.js?v=z',response('z'));await core.syncAssets(manifest(),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest(),c);await core.syncAssets(manifest('c'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('c'),c);assert.ok(await c.match('/a.js?v=a'));assert.equal(await c.match('/a.js?v=z'),undefined);await core.syncAssets(manifest('d'),{cache:c,fetchFn:async u=>response(u.split('=')[1])});await core.commit(manifest('d'),c);assert.equal(await c.match('/a.js?v=a'),undefined);}); diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js new file mode 100644 index 0000000..f7cc057 --- /dev/null +++ b/frontend/asset-worker.test.js @@ -0,0 +1,12 @@ +const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core'); +function environment(){ + const stores=new Map(),listeners={},messages=[],skips=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}}; + const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}}; + const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,crypto:require('node:crypto').webcrypto,console,fetch:async u=>u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':u.split('v=')[1]}}),self:{location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}}; + vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox); + async function dispatch(t,e={}){let p;listeners[t]({...e,waitUntil:v=>p=v});await p} + async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,respondWith:v=>p=v});return p} + return {storage,m,skips,dispatch,request}; +} +test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,false);assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);}); +test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);}); diff --git a/frontend/assets.json b/frontend/assets.json index b70facf..1326222 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -12,6 +12,7 @@ "/flag-ui.js", "/async-guard.js", "/visible-timer.js", + "/asset-sync-core.js", "/sw-update.js", "/fingerprint.js", "/opfs.js", diff --git a/frontend/index.html b/frontend/index.html index 8e55e31..f55934d 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -665,6 +665,7 @@ + diff --git a/frontend/shell-consistency.test.js b/frontend/shell-consistency.test.js index 8612339..547c292 100644 --- a/frontend/shell-consistency.test.js +++ b/frontend/shell-consistency.test.js @@ -57,3 +57,10 @@ test('the selected glass-stage layout includes its controls and panel dependenci '/theme-glass-panel.css', '/glass-panel-layout.js', ]); }); + +test('the shared sync core is eager and loads before the update helper',()=>{ + const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8')); + assert.ok(groups.core.files.includes('/asset-sync-core.js')); + assert.ok(html.indexOf('src="asset-sync-core.js"')} */ - async function refreshShellInPlace({ cachesApi, fetchFn, isShellCache, bust, timeoutMs, attempts, attemptTimeoutMs, concurrency, stagingKey, setTimeout: setTimeoutFn }) { + 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 @@ -145,6 +145,42 @@ 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); + if (status && status.assetSync) { + const result = await core.syncAssets(m,{cache,fetchFn:opts.fetchFn}); + if (!reg.waiting) await core.commit(m,cache); + 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) — @@ -191,7 +227,14 @@ * @param {Function} [opts.setTimeout] injectable for tests * @returns {Promise} */ - async function applyUpdate({ reg, container, reload, refreshShell, askStatus, onPhase, setTimeout: setTimeoutFn }) { + 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; @@ -209,6 +252,7 @@ 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; @@ -216,7 +260,7 @@ let reloaded = false; const reloadOnce = () => { - if (reloaded) return; + if (reloaded || (isPlaying() && !overridden)) return; reloaded = true; reload(); }; @@ -224,6 +268,7 @@ // 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' }); } diff --git a/frontend/sw-update.test.js b/frontend/sw-update.test.js index 11f3e0a..53e336f 100644 --- a/frontend/sw-update.test.js +++ b/frontend/sw-update.test.js @@ -349,3 +349,10 @@ test('askCacheStatus returns the worker reply', async () => { const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel }); assert.deepStrictEqual(got, { ready: true, version: 'abc' }); }); + +test('playback guard blocks activation and reload unless explicit override is accepted',async()=>{ + let messages=[],reloads=0;const reg={waiting:{postMessage:m=>messages.push(m)}}; + await assert.rejects(applyUpdate({reg,container:fakeContainer(),reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>false}),/Playing/); + assert.equal(messages.length,0);assert.equal(reloads,0); + let playing=false;const container=fakeContainer();await applyUpdate({reg,container,reload:()=>reloads++,isPlaying:()=>playing,askStatus:async()=>({ready:true}),setTimeout:()=>{}});playing=true;container.fireControllerChange();assert.equal(reloads,0); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 2c50908..f41b7ff 100644 --- a/frontend/sw.js +++ b/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-). // 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=` (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}); } +} diff --git a/frontend/sw.test.js b/frontend/sw.test.js index 56d7ac4..6d17e88 100644 --- a/frontend/sw.test.js +++ b/frontend/sw.test.js @@ -56,6 +56,7 @@ function loadSw(initialCacheKeys, { fetchImpl } = {}) { }; const sandbox = { + __ASSET_SYNC__: false, self: self_, caches: caches_, fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }), diff --git a/plans/phase2-plan.md b/plans/phase2-plan.md index 003ba05..c8635ef 100644 --- a/plans/phase2-plan.md +++ b/plans/phase2-plan.md @@ -62,3 +62,10 @@ migration browser tests use paused playback; already-open playing tabs are never automatically reloaded. This explicit legacy exception needs owner clarification because the brief states the guarantee without an exception. COMMON.md requires QUESTION and stopping for an unanswered user-visible behavior decision. + +## Owner answer + +Accepted: the legacy timer remains user-triggered by Refresh UI. New pages +show a playback guard with an explicit override, report PLAYING to the worker, +and the worker defers known-playing requests. Installation never calls +skipWaiting. Migration must prove no automatic legacy reload. diff --git a/server/asset-manifest.js b/server/asset-manifest.js index b7a5104..22e4384 100644 --- a/server/asset-manifest.js +++ b/server/asset-manifest.js @@ -26,7 +26,7 @@ export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_ })).replace('__BUILD_TAG__', buildTag); } -export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override } = {}) { +export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override, assetSync = true } = {}) { const bytes = new Map(); const legacy = createHash('sha256'); function walk(dir, prefix = '') { @@ -65,11 +65,13 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu // the index meta and SW injected tag, then publish hashes of the final bytes. // All original bytes (including index/SW source) remain inputs via source hashes. let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing }); - const canonical = { files: { ...files }, groups, contracts }; + const canonical = { assetSync, files: { ...files }, groups, contracts }; if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h }; const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12)); const index = source === null ? null : stampIndex(source, files, { hashing, buildTag }); - const sw = swSource === null ? null : injectBuildTag(swSource, buildTag); + const sw = swSource === null ? null : injectBuildTag(swSource, buildTag) + .replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync)) + .replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')"); if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) }; if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) }; if (index !== null) bytes.set('/index.html', Buffer.from(index)); diff --git a/server/asset-manifest.test.js b/server/asset-manifest.test.js index 71d59b8..0b03375 100644 --- a/server/asset-manifest.test.js +++ b/server/asset-manifest.test.js @@ -60,3 +60,11 @@ test('rollback retains legacy tag algorithm and relative-only single-tag stampin expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12)); expect(result.index).toContain('href="/app.css"'); })); + +test('sync rollback changes build identity and injected worker byte hash', () => fixture(dir => { + writeFileSync(join(dir,'sw.js'), "const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;"); + const on=createAssetManifest(dir),off=createAssetManifest(dir,{assetSync:false}); + expect(on.sw).toContain('const SYNC = true');expect(off.sw).toContain('const SYNC = false'); + expect(on.manifest.buildTag).not.toBe(off.manifest.buildTag); + expect(off.manifest.files['/sw.js'].h).toBe(hash(off.sw)); +})); diff --git a/server/server.js b/server/server.js index 43e9654..b7a7f3f 100644 --- a/server/server.js +++ b/server/server.js @@ -99,7 +99,7 @@ function withSaveSlot(fn) { // Snapshot every shipped public file. Hashes describe the served HTML/SW bytes; // rollback retains the historical recursive tag and single-tag URL stamping. const ASSET_HASHING = process.env.ASSET_HASHING !== '0'; -const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG }); +const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, assetSync: process.env.ASSET_SYNC !== '0', buildTag: process.env.BUILD_TAG }); const BUILD_TAG = assets.manifest.buildTag; // BUILD_TIME — human-readable "when was this image built". Written by the @@ -357,7 +357,8 @@ app.get('/api/manifest', (c) => { const headers = { 'Cache-Control': 'no-store', ETag: etag }; const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, '')); if (matches.includes(etag) || matches.includes('*')) return new Response(null, { status: 304, headers }); - return c.json(assets.manifest, 200, headers); + const entry = compressedEntry('api-manifest',Buffer.from(JSON.stringify(assets.manifest)), 'application/json; charset=utf-8'); + return sendCompressed(c, { ...entry, etag }, 'no-store'); }); app.get('/api/version', (c) => @@ -2440,13 +2441,8 @@ app.get('/sw.js', (c) => { // the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'), // after which the replacement silently did nothing, the SW version froze, // and clients never saw another update no matter how many times we deployed. - const src = _swSource.replace( - /typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, - JSON.stringify(BUILD_TAG) - ); - if (src === _swSource) { - console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js'); - } + const src = assets.sw; + if (!/typeof __BUILD_TAG__/.test(_swSource)) console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js'); return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h); }); diff --git a/server/static-delivery.test.js b/server/static-delivery.test.js index 4a9231d..0a6fff0 100644 --- a/server/static-delivery.test.js +++ b/server/static-delivery.test.js @@ -32,11 +32,11 @@ async function freePort() { }); } -async function startServer(hashing = true) { +async function startServer(hashing = true, sync = true) { const port = await freePort(); const child = Bun.spawn([process.execPath, serverFile], { cwd: root, - env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') }, + env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', ASSET_SYNC: sync ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') }, stdout: 'pipe', stderr: 'pipe', }); const stdout = new Response(child.stdout).text(); @@ -168,6 +168,19 @@ describe('static delivery characterization', () => { } finally { await stopServer(server); server = await startServer(); } }); + test('manifest compression preserves build ETag and sync mode is injected', async () => { + const version=await (await fetch(`${server.base}/api/version`)).json(); + const response=await fetch(`${server.base}/api/manifest`,{headers:{'Accept-Encoding':'gzip'}}); + expect(response.headers.get('etag')).toBe(`"${version.buildTag}"`); + expect(response.headers.get('content-encoding')).toBe('gzip'); + expect((await response.json()).buildTag).toBe(version.buildTag); + writeFileSync(join(publicDir,'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;"); + await stopServer(server);server=await startServer(true,false); + expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = false'); + await stopServer(server);server=await startServer(true,true); + expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = true'); + }); + test('service worker injection tracks fallback literal and reports missing placeholder', async () => { let sw = await (await fetch(`${server.base}/sw.js`)).text(); const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();