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

@@ -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]&&current.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;i<attempts;i++){try{const ctl=new AbortController();const timer=setTimeout(()=>ctl.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);

View File

@@ -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);});

View File

@@ -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);});

View File

@@ -12,6 +12,7 @@
"/flag-ui.js",
"/async-guard.js",
"/visible-timer.js",
"/asset-sync-core.js",
"/sw-update.js",
"/fingerprint.js",
"/opfs.js",

View File

@@ -665,6 +665,7 @@
<script src="p2p-core.js"></script>
<script src="p2p-transfer.js"></script>
<script src="async-guard.js"></script>
<script src="asset-sync-core.js"></script>
<script src="sw-update.js"></script>
<script src="related-videos.js"></script>
<script src="audio-continuity.js"></script>

View File

@@ -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"')<html.indexOf('src="sw-update.js"'));
assert.ok(listed.has('/asset-sync-core.js'));
});

View File

@@ -64,7 +64,7 @@
* @param {Function} [opts.setTimeout]
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
*/
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<void>}
*/
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' });
}

View File

@@ -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);
});

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}); }
}

View File

@@ -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'); }),

View File

@@ -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.

View File

@@ -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));

View File

@@ -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));
}));

View File

@@ -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);
});

View File

@@ -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();