Compare commits
7 Commits
7434f2f486
...
4950d3e417
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4950d3e417 | ||
|
|
92404563b3 | ||
|
|
5d1e5ec791 | ||
|
|
bb446c717f | ||
|
|
5b25ccaa86 | ||
|
|
54a07a0a2a | ||
|
|
a1f16f2e3b |
112
frontend/asset-sync-core.js
Normal file
112
frontend/asset-sync-core.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Exact, resumable asset synchronization shared by pages and workers. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const CACHE = 'ytplayer-assets';
|
||||
const STATE = '/__ytp_asset_state';
|
||||
const url = (path, file) => path + '?v=' + file.h;
|
||||
|
||||
function paths(manifest) {
|
||||
return [...new Set(Object.values(manifest.groups).flatMap(group => group.files))].sort();
|
||||
}
|
||||
|
||||
function blocking(manifest, previous, activeLayout) {
|
||||
return [...new Set(Object.entries(manifest.groups)
|
||||
.filter(([name, group]) => name === 'core' || !group.background ||
|
||||
name === 'layout:' + activeLayout ||
|
||||
(previous?.groups[name] && previous.groups[name].contract !== group.contract))
|
||||
.flatMap(([, group]) => group.files))].sort();
|
||||
}
|
||||
|
||||
function plan(manifest, keys) {
|
||||
const held = new Set(keys);
|
||||
return {
|
||||
missing: paths(manifest).map(path => url(path, manifest.files[path])).filter(key => !held.has(key)),
|
||||
blocking: blocking(manifest).map(path => url(path, manifest.files[path])),
|
||||
};
|
||||
}
|
||||
|
||||
function retained(current, previous) {
|
||||
const keep = new Set(paths(current).map(path => url(path, current.files[path])));
|
||||
if (previous) for (const path of paths(previous)) {
|
||||
if (current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
async function state(cache) {
|
||||
const response = await cache.match(STATE);
|
||||
return response ? response.json() : null;
|
||||
}
|
||||
|
||||
async function status(manifest, cache, { previous, activeLayout } = {}) {
|
||||
const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return !!response && response.headers.get('X-Asset-Hash') === manifest.files[path].h;
|
||||
}));
|
||||
const missing = found.filter(value => !value).length;
|
||||
return { ready: missing === 0, missing, version: manifest.buildTag };
|
||||
}
|
||||
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout }) {
|
||||
const previous = (await state(cache))?.current;
|
||||
const missing = [];
|
||||
for (const path of blocking(manifest, previous, activeLayout)) {
|
||||
const key = url(path, manifest.files[path]);
|
||||
const response = await cache.match(key);
|
||||
if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(key);
|
||||
}
|
||||
const count = missing.length;
|
||||
let failure;
|
||||
await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => {
|
||||
while (missing.length && !failure) {
|
||||
const key = missing.shift();
|
||||
let error;
|
||||
for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30000);
|
||||
try {
|
||||
const response = await fetchFn(key, { credentials: 'same-origin', signal: controller.signal });
|
||||
if (!response.ok || response.headers.get('X-Asset-Hash') !== key.split('v=')[1]) {
|
||||
throw new Error('Asset hash mismatch: ' + key);
|
||||
}
|
||||
// Keep the timeout through body consumption, not just response headers.
|
||||
await cache.put(key, response);
|
||||
error = null;
|
||||
break;
|
||||
} catch (err) { error = err; }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
if (error) failure = error;
|
||||
}
|
||||
}));
|
||||
if (failure) throw failure;
|
||||
if (!(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets');
|
||||
return { refreshed: count, caches: 1 };
|
||||
}
|
||||
|
||||
async function commit(manifest, cache, { previousClients, activeLayout } = {}) {
|
||||
const old = await state(cache);
|
||||
if (!(await status(manifest, cache, { previous: old?.current, activeLayout })).ready) {
|
||||
throw new Error('Incomplete blocking assets');
|
||||
}
|
||||
const sameBuild = old?.current?.buildTag === manifest.buildTag;
|
||||
const previous = sameBuild ? old.previous : old?.current;
|
||||
const next = {
|
||||
current: manifest, previous,
|
||||
previousClients: previousClients || (sameBuild ? old.previousClients : []),
|
||||
};
|
||||
// One state write publishes the complete build and its old-tab affinity.
|
||||
await cache.put(STATE, new Response(JSON.stringify(next), { headers: { 'Content-Type': 'application/json' } }));
|
||||
const keep = retained(manifest, previous);
|
||||
for (const request of await cache.keys()) {
|
||||
const parsed = new URL(request.url);
|
||||
const key = parsed.pathname + parsed.search;
|
||||
if (parsed.searchParams.has('v') && !keep.has(key)) await cache.delete(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
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);
|
||||
12
frontend/asset-sync-core.test.js
Normal file
12
frontend/asset-sync-core.test.js
Normal file
@@ -0,0 +1,12 @@
|
||||
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);});
|
||||
test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));});
|
||||
test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));});
|
||||
test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));});
|
||||
12
frontend/asset-worker.test.js
Normal file
12
frontend/asset-worker.test.js
Normal 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':m.files[u.split('?')[0]]?.h || ''}}),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(reply.missing,1);assert.equal(reply.version,'next');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);assert.equal((await e.request('/app.js?v=stale')).status,409);});
|
||||
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);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);});
|
||||
@@ -12,6 +12,7 @@
|
||||
"/flag-ui.js",
|
||||
"/async-guard.js",
|
||||
"/visible-timer.js",
|
||||
"/asset-sync-core.js",
|
||||
"/sw-update.js",
|
||||
"/fingerprint.js",
|
||||
"/opfs.js",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* ============================================================================
|
||||
* sw-update — makes "Refresh UI" actually land the new build.
|
||||
*
|
||||
* Framework-free and dependency-free on purpose (same pattern as
|
||||
* Framework-free, with a shared sync core and a legacy fallback (same pattern as
|
||||
* async-guard.js):
|
||||
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
||||
* global `window.SwUpdate`).
|
||||
@@ -17,7 +17,7 @@
|
||||
* already current (a failed install leaves a partial cache that the
|
||||
* activate handler then mistook for a previous deploy).
|
||||
*
|
||||
* The fix no longer depends on the service-worker install lifecycle:
|
||||
* Legacy fallback (retained for ASSET_SYNC=0):
|
||||
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
|
||||
* caches hold (cache-busted, so even an old worker's cache-first
|
||||
* handler can't answer with the stale copy) — ALL of them or nothing —
|
||||
@@ -25,6 +25,10 @@
|
||||
* serves the next load, it serves the new build.
|
||||
* 2. Only then is a waiting worker (if any) activated, and the page
|
||||
* reloaded once.
|
||||
* Incremental mode uses AssetSyncCore to fetch only missing verified hash URLs
|
||||
* and publishes a complete blocking set. Playback guards cover activation and
|
||||
* reload callbacks; PLAYING reports provide an additional worker guard.
|
||||
*
|
||||
* The banner itself only opens when the build the page is running differs
|
||||
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
|
||||
* event can re-open it once the page is current.
|
||||
@@ -64,7 +68,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 +149,45 @@
|
||||
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);
|
||||
// Unknown protocol is a retryable failure, never an empty successful
|
||||
// legacy refresh followed by a reload into the old persistent manifest.
|
||||
if (worker && !status) throw new Error('Worker update status unavailable; try again');
|
||||
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 +234,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 +259,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 +267,7 @@
|
||||
|
||||
let reloaded = false;
|
||||
const reloadOnce = () => {
|
||||
if (reloaded) return;
|
||||
if (reloaded || (isPlaying() && !overridden)) return;
|
||||
reloaded = true;
|
||||
reload();
|
||||
};
|
||||
@@ -224,6 +275,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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -349,3 +349,22 @@ 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);
|
||||
});
|
||||
test('explicit playback override clears the worker guard and reloads once',async()=>{
|
||||
const messages=[];let reloads=0;const container=fakeContainer();
|
||||
await applyUpdate({reg:{waiting:{postMessage:m=>messages.push(m)}},container,reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>true,askStatus:async()=>({ready:true}),setTimeout:()=>{}});
|
||||
assert.deepStrictEqual(messages,[{type:'PLAYING',value:false},{type:'SKIP_WAITING'}]);
|
||||
container.fireControllerChange();container.fireControllerChange();assert.equal(reloads,1);
|
||||
});
|
||||
test('an unanswered worker status cannot fall through to an empty legacy refresh',async()=>{
|
||||
const vm=require('node:vm'),fs=require('node:fs');
|
||||
const sandbox={AssetSyncCore:{CACHE:'ytplayer-assets'},navigator:{serviceWorker:{getRegistration:async()=>({active:{postMessage(){}}})}}};
|
||||
vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox);
|
||||
await assert.rejects(sandbox.SwUpdate.refreshShellInPlace({cachesApi:{open:async()=>({}),keys:async()=>[]},fetchFn:async()=>({ok:true,json:async()=>({buildTag:'new'})}),isShellCache:()=>true}),/Worker update status unavailable/);
|
||||
});
|
||||
|
||||
123
frontend/sw.js
123
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
|
||||
@@ -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 === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
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
|
||||
@@ -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) || (await AssetSyncCore.state(cache))?.current; 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,87 @@ 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 previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id);
|
||||
await AssetSyncCore.commit(m,cache,{previousClients});
|
||||
// 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') && 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;
|
||||
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}); }
|
||||
}
|
||||
|
||||
@@ -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'); }),
|
||||
|
||||
@@ -181,3 +181,34 @@ which receive hashed URLs as HTML scripts. Font preloads retain plain URLs to ma
|
||||
`fonts.css`; the webmanifest and its icon URLs also stay plain and precached.
|
||||
`piano-engine.mjs` is manifested but is historically absent from SHELL; this
|
||||
existing offline piano limitation is deferred to the loader phase.
|
||||
|
||||
## Phase 2 incremental-sync verification
|
||||
|
||||
The current migration harness verifies the production page's own ready fast path
|
||||
into `ytplayer-assets`: no `__ytpfresh` downloads, no reload before Refresh UI,
|
||||
one banner/reload, an open N-1 tab, playback guard, incremental refresh after
|
||||
an eviction, and offline launch. It preserves the legacy user-triggered reload
|
||||
exception. Async CacheStorage/registration conditions are polled with awaited
|
||||
`page.evaluate`; Playwright's `waitForFunction` does not poll async predicates.
|
||||
|
||||
```bash
|
||||
node perf/migration.mjs --browser all --profile lossy
|
||||
node perf/migration.mjs --browser all --profile unthrottled
|
||||
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
|
||||
--out perf/results/phase2-final-2026-10-07.json \
|
||||
--compare perf/results/after-phase0-2026-10-07.json
|
||||
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||
--scenario offline --out perf/results/phase2-rollback-2026-10-07.json
|
||||
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
|
||||
--scenario offline --out perf/results/phase2-hashing-rollback-2026-10-07.json
|
||||
```
|
||||
|
||||
Results now include response paths/statuses and compressed response-body bytes
|
||||
for cold/update runs. The total includes service-worker script checks/imports;
|
||||
inspect individual responses to distinguish those from app asset downloads.
|
||||
Migration results are `phase2-migration-<profile>-2026-10-07.json`.
|
||||
`ASSET_SYNC=0` selects the legacy path while preserving the persistent utility
|
||||
cache. `ASSET_HASHING=0` also selects legacy sync because single-tag URLs cannot
|
||||
pass per-file hash checks. Current assets.json groups all block; idle loading and
|
||||
lazy parsing await Phase 3. Incremental mode now caches the manifested
|
||||
`piano-engine.mjs` even though it remains absent from the legacy SHELL.
|
||||
|
||||
@@ -392,6 +392,7 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
|
||||
domContentLoaded: pageMetrics.domContentLoaded,
|
||||
bootDone: pageMetrics.bootDone,
|
||||
longTaskTotal: pageMetrics.longTaskTotal,
|
||||
requests: stats.requests,
|
||||
swInstallDuration,
|
||||
swInstallBytes: stats.totalWireBytes,
|
||||
storageEstimate: storage,
|
||||
@@ -630,6 +631,7 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
||||
|
||||
return {
|
||||
updateKind,
|
||||
requests: stats.requests,
|
||||
duration,
|
||||
requestCount: stats.requestCount,
|
||||
wireBytes: stats.totalWireBytes,
|
||||
@@ -726,6 +728,7 @@ async function main() {
|
||||
mediaReady: summarizeList(coldRuns.map((r) => r.mediaReady)),
|
||||
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
||||
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
||||
requests: coldRuns[0]?.requests || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -796,6 +799,7 @@ async function main() {
|
||||
wireBytes: summarizeList(updJsRuns.map((r) => r.wireBytes)),
|
||||
duration: summarizeList(updJsRuns.map((r) => r.duration)),
|
||||
wireBytesByType: updJsRuns[0]?.wireBytesByType || {},
|
||||
requests: updJsRuns[0]?.requests || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -822,6 +826,7 @@ async function main() {
|
||||
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
||||
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
||||
wireBytesByType: updCssRuns[0]?.wireBytesByType || {},
|
||||
requests: updCssRuns[0]?.requests || [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { chromium, webkit } from 'playwright';
|
||||
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
|
||||
const args = process.argv.slice(2);
|
||||
const profile = args.includes('--profile') ? args[args.indexOf('--profile')+1] : 'lossy';
|
||||
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
|
||||
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
|
||||
execFileSync('bash', [join(repo, 'perf/make-shell-fixture.sh'), commit, oldTag], { cwd: repo, stdio: 'inherit' });
|
||||
@@ -25,6 +26,14 @@ symlinkSync(join(repo, 'server/node_modules'), join(scratch, 'server/node_module
|
||||
symlinkSync(join(repo, `perf/fixtures/shell-${oldTag}`), join(scratch, 'server/public'));
|
||||
const shell = [...readFileSync(join(repo, 'frontend/sw.js'), 'utf8').match(/const SHELL = \[([\s\S]*?)\n\];/)[1].matchAll(/['"](\/[^'"]+)['"]/g)].map(m => m[1]);
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function waitAsync(page,predicate,arg,timeout=90000) {
|
||||
const deadline=Date.now()+timeout;
|
||||
while(Date.now()<deadline) {
|
||||
if(await page.evaluate(predicate,arg)) return;
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error('Timed out waiting for asynchronous migration condition');
|
||||
}
|
||||
async function freePort() {
|
||||
const server = createServer();
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
@@ -64,7 +73,7 @@ async function run(name) {
|
||||
try {
|
||||
await start(true);
|
||||
assert.equal((await (await fetch(`http://127.0.0.1:${port}/api/version`)).json()).buildTag, oldTag);
|
||||
proxy = createThrottleProxy({ targetPort: port, profile: 'lossy' });
|
||||
proxy = createThrottleProxy({ targetPort: port, profile });
|
||||
const proxyPort = await proxy.listen();
|
||||
const origin = `http://127.0.0.1:${proxyPort}`;
|
||||
browser = await ({ chromium, webkit }[name]).launch({ headless: true });
|
||||
@@ -114,10 +123,27 @@ async function run(name) {
|
||||
await checkBuildTag();
|
||||
await (await navigator.serviceWorker.getRegistration()).update();
|
||||
});
|
||||
await page.waitForFunction(async () => !!(await navigator.serviceWorker.getRegistration())?.waiting, { timeout: 90000 });
|
||||
await waitAsync(page,async () => !!(await navigator.serviceWorker.getRegistration())?.waiting);
|
||||
await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden'));
|
||||
assert.equal(banners, 1, 'exactly one offer before refresh');
|
||||
const before = navigations;
|
||||
await sleep(4500);
|
||||
assert.equal(navigations,before,'legacy installation does not reload without Refresh UI');
|
||||
// A legacy startup update can still be in flight through the stall proxy.
|
||||
// Require the target candidate, not a transient earlier waiting worker.
|
||||
await waitAsync(page,async tag => {
|
||||
const reg=await navigator.serviceWorker.getRegistration();
|
||||
const cache=await caches.open('ytplayer-assets');
|
||||
return !reg.installing && reg.waiting?.state==='installed' &&
|
||||
!!await cache.match('/__ytp_candidate/'+tag);
|
||||
}, manifest.buildTag);
|
||||
const status = await page.evaluate(async()=>SwUpdate.askCacheStatus((await navigator.serviceWorker.getRegistration()).waiting,{timeoutMs:4000}));
|
||||
assert.equal(status?.ready,true,'new worker confirms persistent cache to old helper');
|
||||
assert.equal(status.version,manifest.buildTag,'status belongs to the target build');
|
||||
const oldTab = await context.newPage();
|
||||
await oldTab.goto(origin,{waitUntil:'load'});
|
||||
assert.equal(await oldTab.locator('meta[name="ytp-build"]').getAttribute('content'),oldTag);
|
||||
const oldBytes = await oldTab.evaluate(async()=>await (await fetch('/app.js')).text());
|
||||
await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click();
|
||||
await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
|
||||
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
|
||||
@@ -125,11 +151,14 @@ async function run(name) {
|
||||
await sleep(2000);
|
||||
assert.equal(navigations - before, 1, 'one reload, no update loop');
|
||||
assert.equal(banners, 1, 'no banner after applying the current build');
|
||||
assert.equal(await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()),oldBytes,'open legacy tab keeps N-1 plain assets');
|
||||
await oldTab.close();
|
||||
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
|
||||
const cache = await caches.open(`ytplayer-${tag}`);
|
||||
const cache = await caches.open('ytplayer-assets');
|
||||
const missing = [], incorrect = [];
|
||||
for (const path of shell) {
|
||||
const response = await cache.match(path);
|
||||
const p = path === '/' ? '/index.html' : path;
|
||||
const response = await cache.match(p+'?v='+files[p].h);
|
||||
if (!response) { missing.push(path); continue; }
|
||||
if (response.headers.get('x-asset-hash') !== files[path === '/' ? '/index.html' : path]?.h) incorrect.push(path);
|
||||
}
|
||||
@@ -139,8 +168,38 @@ async function run(name) {
|
||||
assert.deepEqual(cacheResult.incorrect, [], 'every cached shell asset belongs to the new build');
|
||||
proxy.stopTracking();
|
||||
const stats = proxy.getStats();
|
||||
assert.equal(stats.requests.filter(r=>r.url.includes('__ytpfresh')).length,0,'legacy helper skips its full download after ready:true');
|
||||
const durationMs = Date.now() - started;
|
||||
assert.deepEqual(errors, [], 'no script failures during installation and update');
|
||||
// Exercise the new helper's actual lexical Player guard, without starting
|
||||
// media or changing the production update/outcome code.
|
||||
let guardText='';
|
||||
page.once('dialog',async dialog=>{guardText=dialog.message();await dialog.dismiss();});
|
||||
const guard=await page.evaluate(async()=>{
|
||||
const saved=Player._wantsPlaying;Player._wantsPlaying=true;let reloads=0;
|
||||
try { await SwUpdate.applyUpdate({reg:null,container:navigator.serviceWorker,reload:()=>reloads++});return {reloads,blocked:false}; }
|
||||
catch(e){return {reloads,blocked:/Playing/.test(e.message)};}
|
||||
finally{Player._wantsPlaying=saved;}
|
||||
});
|
||||
assert.match(guardText,/Playing - update after this song \/ when paused/);
|
||||
assert.deepEqual(guard,{reloads:0,blocked:true});
|
||||
const refresh=await page.evaluate(async files=>{
|
||||
const cache=await caches.open('ytplayer-assets');
|
||||
const key='/async-guard.js?v='+files['/async-guard.js'].h;
|
||||
await cache.delete(key);
|
||||
const requests=[];
|
||||
const result=await SwUpdate.refreshShellInPlace({cachesApi:caches,
|
||||
fetchFn:(url,init)=>{requests.push(url);return fetch(url,init)},isShellCache:()=>true});
|
||||
if(!await cache.match(key)) throw Error('Refresh did not repair missing asset');
|
||||
// Independently test the worker's plain-URL eviction repair.
|
||||
const other='/visible-timer.js?v='+files['/visible-timer.js'].h;
|
||||
await cache.delete(other);
|
||||
const r=await fetch('/visible-timer.js');
|
||||
if(r.headers.get('X-Asset-Hash')!==files['/visible-timer.js'].h || !await cache.match(other)) throw Error('Evicted asset did not self-heal');
|
||||
return {result,requests};
|
||||
},manifest.files);
|
||||
assert.equal(refresh.result.refreshed,1,'thin refresh downloads only its missing asset');
|
||||
assert.deepEqual(refresh.requests,['/api/manifest','/async-guard.js?v='+manifest.files['/async-guard.js'].h]);
|
||||
disconnected = true;
|
||||
// Match baseline.mjs: Linux WebKit's setOffline breaks even SW navigation.
|
||||
// Cut the proxy AND stop Bun so offline boot cannot use network bytes.
|
||||
@@ -160,7 +219,7 @@ async function run(name) {
|
||||
assert.ok(error.message === 'TypeError: Load failed' || /\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message), `unexpected offline error: ${error.message}`);
|
||||
}
|
||||
if (errors.length) assert.ok(failedRequests.some(url => /\/api\/(?:recommendations|version)/.test(url)), 'transport errors correspond to disconnected API requests');
|
||||
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile: 'lossy', offlineTransportErrors: errors.length };
|
||||
const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile, userInitiatedOnly: true, playbackGuard: true, evictionRepair: true, incrementalRefresh: true, offlineTransportErrors: errors.length };
|
||||
console.log(JSON.stringify(result));
|
||||
return result;
|
||||
} finally {
|
||||
@@ -172,5 +231,5 @@ async function run(name) {
|
||||
try {
|
||||
const results = [];
|
||||
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
|
||||
writeFileSync(join(repo, 'perf/results/phase1-migration-2026-10-07.json'), JSON.stringify({ commit, results }, null, 2) + '\n');
|
||||
writeFileSync(join(repo, `perf/results/phase2-migration-${profile}-2026-10-07.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
|
||||
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
||||
|
||||
@@ -48,6 +48,7 @@ export function categorizeContentType(contentType, urlPath) {
|
||||
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
let activeTracking = false;
|
||||
let requestCount = 0;
|
||||
let requests = [];
|
||||
let totalWireBytes = 0;
|
||||
let bytesByType = {
|
||||
html: 0,
|
||||
@@ -110,8 +111,11 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
const ct = upRes.headers['content-type'] || '';
|
||||
const cat = categorizeContentType(ct, pathname);
|
||||
|
||||
let recorded;
|
||||
if (activeTracking) {
|
||||
requestCount++;
|
||||
recorded={url:clientReq.url,status:upRes.statusCode,bytes:0};
|
||||
requests.push(recorded);
|
||||
}
|
||||
|
||||
clientRes.writeHead(upRes.statusCode, upRes.headers);
|
||||
@@ -120,6 +124,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
upRes.on('data', (chunk) => {
|
||||
if (activeTracking) {
|
||||
totalWireBytes += chunk.length;
|
||||
if(recorded) recorded.bytes += chunk.length;
|
||||
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||
}
|
||||
clientRes.write(chunk);
|
||||
@@ -131,6 +136,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
throttler.on('data', (chunk) => {
|
||||
if (activeTracking) {
|
||||
totalWireBytes += chunk.length;
|
||||
if(recorded) recorded.bytes += chunk.length;
|
||||
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||
}
|
||||
});
|
||||
@@ -176,7 +182,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
activeTracking = false;
|
||||
},
|
||||
resetStats: () => {
|
||||
requestCount = 0;
|
||||
requestCount = 0; requests = [];
|
||||
totalWireBytes = 0;
|
||||
bytesByType = {
|
||||
html: 0,
|
||||
@@ -193,7 +199,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||
isOfflineMode = !!val;
|
||||
},
|
||||
getStats: () => ({
|
||||
requestCount,
|
||||
requestCount, requests,
|
||||
totalWireBytes,
|
||||
bytesByType: { ...bytesByType },
|
||||
}),
|
||||
|
||||
2082
perf/results/phase2-final-2026-10-07.json
Normal file
2082
perf/results/phase2-final-2026-10-07.json
Normal file
File diff suppressed because it is too large
Load Diff
131
perf/results/phase2-hashing-rollback-2026-10-07.json
Normal file
131
perf/results/phase2-hashing-rollback-2026-10-07.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"date": "2026-10-07T16:00:49.668Z",
|
||||
"commit": "bb446c717f92ef912f5000ddc757b9d1071120dc",
|
||||
"runsConfigured": 1,
|
||||
"browsers": {
|
||||
"chromium": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 76,
|
||||
"min": 76,
|
||||
"max": 76,
|
||||
"values": [
|
||||
76
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 300,
|
||||
"min": 300,
|
||||
"max": 300,
|
||||
"values": [
|
||||
300
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 233,
|
||||
"min": 233,
|
||||
"max": 233,
|
||||
"values": [
|
||||
233
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 279,
|
||||
"min": 279,
|
||||
"max": 279,
|
||||
"values": [
|
||||
279
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 306,
|
||||
"min": 306,
|
||||
"max": 306,
|
||||
"values": [
|
||||
306
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webkit": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 129,
|
||||
"min": 129,
|
||||
"max": 129,
|
||||
"values": [
|
||||
129
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 158,
|
||||
"min": 158,
|
||||
"max": 158,
|
||||
"values": [
|
||||
158
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 117,
|
||||
"min": 117,
|
||||
"max": 117,
|
||||
"values": [
|
||||
117
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 153,
|
||||
"min": 153,
|
||||
"max": 153,
|
||||
"values": [
|
||||
153
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 165,
|
||||
"min": 165,
|
||||
"max": 165,
|
||||
"values": [
|
||||
165
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
perf/results/phase2-migration-lossy-2026-10-07.json
Normal file
41
perf/results/phase2-migration-lossy-2026-10-07.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commit": "b77938a",
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "4f7090fff60c",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 92,
|
||||
"durationMs": 27514,
|
||||
"wireBytes": 622009,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "lossy",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 0
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "4f7090fff60c",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 92,
|
||||
"durationMs": 19558,
|
||||
"wireBytes": 731221,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "lossy",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
41
perf/results/phase2-migration-unthrottled-2026-10-07.json
Normal file
41
perf/results/phase2-migration-unthrottled-2026-10-07.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"commit": "b77938a",
|
||||
"results": [
|
||||
{
|
||||
"browser": "chromium",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "4f7090fff60c",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 92,
|
||||
"durationMs": 10811,
|
||||
"wireBytes": 630657,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "unthrottled",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 0
|
||||
},
|
||||
{
|
||||
"browser": "webkit",
|
||||
"oldTag": "1175f1a1d2c1",
|
||||
"newTag": "4f7090fff60c",
|
||||
"banners": 1,
|
||||
"updateReloads": 1,
|
||||
"cachedFiles": 92,
|
||||
"durationMs": 9283,
|
||||
"wireBytes": 744401,
|
||||
"offline": true,
|
||||
"legacyURLs": 21,
|
||||
"profile": "unthrottled",
|
||||
"userInitiatedOnly": true,
|
||||
"playbackGuard": true,
|
||||
"evictionRepair": true,
|
||||
"incrementalRefresh": true,
|
||||
"offlineTransportErrors": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
131
perf/results/phase2-rollback-2026-10-07.json
Normal file
131
perf/results/phase2-rollback-2026-10-07.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"date": "2026-10-07T15:55:07.011Z",
|
||||
"commit": "54a07a0a2ae1711e441545b208c45c55b3318963",
|
||||
"runsConfigured": 1,
|
||||
"browsers": {
|
||||
"chromium": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 72,
|
||||
"min": 72,
|
||||
"max": 72,
|
||||
"values": [
|
||||
72
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 72,
|
||||
"min": 72,
|
||||
"max": 72,
|
||||
"values": [
|
||||
72
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 225,
|
||||
"min": 225,
|
||||
"max": 225,
|
||||
"values": [
|
||||
225
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 249,
|
||||
"min": 249,
|
||||
"max": 249,
|
||||
"values": [
|
||||
249
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 262,
|
||||
"min": 262,
|
||||
"max": 262,
|
||||
"values": [
|
||||
262
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webkit": {
|
||||
"lte": {
|
||||
"offline": {
|
||||
"requestCount": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"wireBytes": {
|
||||
"median": 0,
|
||||
"min": 0,
|
||||
"max": 0,
|
||||
"values": [
|
||||
0
|
||||
]
|
||||
},
|
||||
"fcp": {
|
||||
"median": 109,
|
||||
"min": 109,
|
||||
"max": 109,
|
||||
"values": [
|
||||
109
|
||||
]
|
||||
},
|
||||
"lcp": {
|
||||
"median": 138,
|
||||
"min": 138,
|
||||
"max": 138,
|
||||
"values": [
|
||||
138
|
||||
]
|
||||
},
|
||||
"domContentLoaded": {
|
||||
"median": 101,
|
||||
"min": 101,
|
||||
"max": 101,
|
||||
"values": [
|
||||
101
|
||||
]
|
||||
},
|
||||
"bootDone": {
|
||||
"median": 133,
|
||||
"min": 133,
|
||||
"max": 133,
|
||||
"values": [
|
||||
133
|
||||
]
|
||||
},
|
||||
"reloadDuration": {
|
||||
"median": 145,
|
||||
"min": 145,
|
||||
"max": 145,
|
||||
"values": [
|
||||
145
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
plans/phase2-plan.md
Normal file
71
plans/phase2-plan.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Phase 2 — incremental service-worker asset sync
|
||||
|
||||
Base: `codex/phase1` at `7434f2f`; branch: `codex/phase2`.
|
||||
|
||||
1. Add tests first in `frontend/asset-sync-core.test.js`, extend
|
||||
`frontend/sw.test.js` / `frontend/sw-update.test.js`, and deliberately extend
|
||||
`frontend/shell-consistency.test.js` and `server/static-delivery.test.js`.
|
||||
2. Add pure UMD `frontend/asset-sync-core.js`: exact hashed URL planning,
|
||||
group-based blocking sets (everything except explicit background groups),
|
||||
contract-bump blocking, verified resumable writes, capped six-way concurrency,
|
||||
three retries, missing-entry checks and changed-file N-1 retention/N-2 pruning.
|
||||
Register it in `frontend/assets.json`, `frontend/index.html` and the retained
|
||||
legacy SHELL. Do not include unrelated shipped test/source files in downloads.
|
||||
3. Integrate in `frontend/sw.js`: persistent `ytplayer-assets` utility cache,
|
||||
per-build candidate manifests, atomic active-manifest promotion on activation,
|
||||
verified exact lookups, plain-URL resolution, navigation/SPA index fallback,
|
||||
resumable install, honest CACHE_STATUS, and quota logging. Preserve thumbnails,
|
||||
fonts, share-target handling and network-only APIs. Delete legacy shell caches
|
||||
only after commit and retain verified old bytes/manifest for N-1 compatibility.
|
||||
4. Integrate in `frontend/sw-update.js`: a thin sync-core refresh caller,
|
||||
preserved opt-in ready fast path, and playback checks immediately before
|
||||
activation and every reload callback. Keep a legacy fallback for rollback.
|
||||
Never change the banner rule, update outcome logic or audio/timer machinery.
|
||||
5. Required server gap: inject ASSET_SYNC mode and a versioned core-module URL
|
||||
into the served worker in `server/asset-manifest.js` / `server/server.js`.
|
||||
Keep served worker hash headers accurate after injection. `ASSET_SYNC=0`
|
||||
selects the legacy worker install/update path; test both modes. For cold
|
||||
installs, share already downloaded HTTP asset bytes without accepting stale
|
||||
plain responses or weakening hash verification; report remaining URL gaps.
|
||||
6. Extend `perf/migration.mjs`: actual b77938a old helper, ready:true reply,
|
||||
proof no refresh re-download, exactly one banner/reload, N-1 old-tab requests,
|
||||
offline boot; Chromium/WebKit, with and without `perf/proxy.mjs` stalls.
|
||||
Add browser checks for interrupted downloads, eviction repair and rollback.
|
||||
7. Run `perf/baseline.mjs` reduced update/cold/warm/offline measurements against
|
||||
`perf/results/after-phase0-2026-10-07.json`; record requested asset paths and
|
||||
compressed bytes. Acceptance: JS-only change fetches JS + index + manifest;
|
||||
CSS-only change <30 KB; zero font/icon update downloads; no duplicate cold
|
||||
shell transfer (or explain an evidenced gap). All required unit suites green
|
||||
after each implementation commit, and browser checks pass in both engines.
|
||||
8. Review full diff against master §2c/§3.2/§6b; commit a ≤350-word
|
||||
`plans/phase2-report.md` with hashes, measurements, gaps and iPhone checks;
|
||||
write DONE02 only after completion. No push, merge, deploy or production call.
|
||||
|
||||
Risks: an install must not silently switch the active tab's manifest; incomplete
|
||||
sync must not delete old data; exact hashes must not resolve to unrelated current
|
||||
bytes; worker/page HTTP caches can duplicate font transfers; cache quota competes
|
||||
with saved audio. Rollback preserves both schemes until its own commit succeeds.
|
||||
|
||||
## Required owner decision before implementation
|
||||
|
||||
The production `b77938a` helper already running in legacy tabs does not check
|
||||
Player._wantsPlaying: applyUpdate sends SKIP_WAITING and schedules an unconditional
|
||||
reload after four seconds. A read-only Node VM reproduction using that exact git
|
||||
source with Player._wantsPlaying=true produced:
|
||||
`{"wantsPlaying":true,"messages":["SKIP_WAITING"],"reloads":1}`.
|
||||
A newly installed worker cannot cancel a timer in an already-running old page.
|
||||
Refusing SKIP_WAITING still leaves that old timer able to reload via the old worker.
|
||||
|
||||
Recommended policy: enforce the playback guarantee in all upgraded clients and
|
||||
require paused playback for the one-time legacy Refresh UI migration. Legacy
|
||||
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.
|
||||
43
plans/phase2-report.md
Normal file
43
plans/phase2-report.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Phase 2 report
|
||||
|
||||
Shipped on `codex/phase2`: plan `a1f16f2`; incremental sync/playback guards
|
||||
`54a07a0`; atomic state, exact hash rejection and retention `5b25cca`;
|
||||
single-tag rollback compatibility `bb446c7`; migration verification `5d1e5ec`;
|
||||
fail-closed status handling `9240456`.
|
||||
|
||||
Persistent `ytplayer-assets` stores verified exact URLs and current/previous
|
||||
manifests. Downloads resume, use six workers/three attempts, and publish only
|
||||
complete blocking sets. Activation retains N-1 changed/removed files, prunes
|
||||
N-2, then deletes legacy caches; quota is logged. All current groups block;
|
||||
lazy parsing/background warming await Phase 3.
|
||||
|
||||
Server gaps addressed: inject ASSET_SYNC and the hashed sync-core import,
|
||||
hash final worker bytes, compress manifest responses while preserving build
|
||||
ETags. ASSET_SYNC=0 selects legacy sync; ASSET_HASHING=0 also selects legacy.
|
||||
|
||||
Verification: 168 frontend/181 server tests pass without skips. Chromium/WebKit
|
||||
migration passes with and without stalls using actual b77938a code: ready:true,
|
||||
zero legacy refresh downloads, one banner/user-triggered reload, N-1 open tab,
|
||||
eviction repair, incremental refresh and offline boot. Both rollback flags
|
||||
pass offline browser checks. Async polling fixes the Playwright harness race.
|
||||
|
||||
Accepted legacy exception: b77938a retains its four-second timer after the USER
|
||||
presses Refresh UI and does not report playback. New pages guard playback,
|
||||
offer explicit override and post PLAYING; workers defer known-playing requests
|
||||
and never call skipWaiting during installation.
|
||||
|
||||
Final one-run LTE measurements (`phase2-final-2026-10-07.json`, Chromium/WebKit):
|
||||
JS updates 155,648/189,354 bytes versus 611,338/680,822; CSS 31,476/30,545 versus
|
||||
611,476/680,831. Sync fetches changed app/style + index/manifest; fonts/icons transfer
|
||||
zero bytes. Browser worker checks add overhead: Chromium downloads sw.js twice;
|
||||
WebKit also downloads the unchanged imported core. Thus total CSS exceeds
|
||||
30,000 bytes.
|
||||
|
||||
Cold bytes: 624,762/1,191,130 versus 1,040,158/1,180,749. Chromium transfers app.js
|
||||
once; WebKit transfers it twice despite HTTP-cache reuse attempts. Cache sharing
|
||||
remains unresolved. Cold boot: 11,155/4,327 ms versus 10,480/3,290; warm:
|
||||
671/121 versus 692/139; offline: 264/133 versus 259/137. Single-run cold regressions
|
||||
need further investigation; no claim that every performance target was met.
|
||||
|
||||
Owner iPhone checks: locked playback, override/cancel, stall recovery, offline
|
||||
launch and OPFS/quota coexistence. No push, merge or deployment.
|
||||
@@ -26,7 +26,9 @@ 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 } = {}) {
|
||||
// Single-tag URLs cannot be verified against per-file URL hashes.
|
||||
assetSync = hashing && assetSync;
|
||||
const bytes = new Map();
|
||||
const legacy = createHash('sha256');
|
||||
function walk(dir, prefix = '') {
|
||||
@@ -65,11 +67,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));
|
||||
|
||||
@@ -60,3 +60,15 @@ 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));
|
||||
}));
|
||||
test('single-tag hashing rollback also selects the legacy worker', () => 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;");
|
||||
expect(createAssetManifest(dir,{hashing:false}).sw).toContain('const SYNC = false');
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user