Compare commits

..

7 Commits

Author SHA1 Message Date
Jonathan Sykes
4950d3e417 Document incremental sync verification and performance gaps 2026-10-08 00:21:19 +08:00
Jonathan Sykes
92404563b3 Fail closed when worker update status is unavailable 2026-10-08 00:10:04 +08:00
Jonathan Sykes
5d1e5ec791 Verify explicit activation and legacy incremental migration 2026-10-08 00:03:14 +08:00
Jonathan Sykes
bb446c717f Keep single-tag rollback on the legacy worker path 2026-10-07 23:58:29 +08:00
Jonathan Sykes
5b25ccaa86 Publish asset state atomically and reject unavailable versions 2026-10-07 23:56:46 +08:00
Jonathan Sykes
54a07a0a2a Synchronize verified assets incrementally and guard update activation 2026-10-07 23:40:20 +08:00
Jonathan Sykes
a1f16f2e3b Plan phase 2: Add incremental asset sync and migration verification 2026-10-07 23:20:59 +08:00
25 changed files with 3023 additions and 37 deletions

112
frontend/asset-sync-core.js Normal file
View 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);

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

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

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

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

View File

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

View File

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

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

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

View File

@@ -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 || [],
};
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View 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
]
}
}
}
}
}
}

View 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
}
]
}

View 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
}
]
}

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

View File

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

View File

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

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