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",
|
"/flag-ui.js",
|
||||||
"/async-guard.js",
|
"/async-guard.js",
|
||||||
"/visible-timer.js",
|
"/visible-timer.js",
|
||||||
|
"/asset-sync-core.js",
|
||||||
"/sw-update.js",
|
"/sw-update.js",
|
||||||
"/fingerprint.js",
|
"/fingerprint.js",
|
||||||
"/opfs.js",
|
"/opfs.js",
|
||||||
|
|||||||
@@ -665,6 +665,7 @@
|
|||||||
<script src="p2p-core.js"></script>
|
<script src="p2p-core.js"></script>
|
||||||
<script src="p2p-transfer.js"></script>
|
<script src="p2p-transfer.js"></script>
|
||||||
<script src="async-guard.js"></script>
|
<script src="async-guard.js"></script>
|
||||||
|
<script src="asset-sync-core.js"></script>
|
||||||
<script src="sw-update.js"></script>
|
<script src="sw-update.js"></script>
|
||||||
<script src="related-videos.js"></script>
|
<script src="related-videos.js"></script>
|
||||||
<script src="audio-continuity.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',
|
'/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.
|
* 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):
|
* async-guard.js):
|
||||||
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
|
||||||
* global `window.SwUpdate`).
|
* global `window.SwUpdate`).
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
* already current (a failed install leaves a partial cache that the
|
* already current (a failed install leaves a partial cache that the
|
||||||
* activate handler then mistook for a previous deploy).
|
* 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
|
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
|
||||||
* caches hold (cache-busted, so even an old worker's cache-first
|
* 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 —
|
* 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.
|
* serves the next load, it serves the new build.
|
||||||
* 2. Only then is a waiting worker (if any) activated, and the page
|
* 2. Only then is a waiting worker (if any) activated, and the page
|
||||||
* reloaded once.
|
* 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
|
* The banner itself only opens when the build the page is running differs
|
||||||
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
|
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
|
||||||
* event can re-open it once the page is current.
|
* event can re-open it once the page is current.
|
||||||
@@ -64,7 +68,7 @@
|
|||||||
* @param {Function} [opts.setTimeout]
|
* @param {Function} [opts.setTimeout]
|
||||||
* @returns {Promise<{refreshed: number, caches: number, resumed: number}>}
|
* @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);
|
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
|
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 };
|
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.
|
* Asks a worker whether it has already precached its whole shell.
|
||||||
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
* Resolves to null on any problem (no reply, no MessageChannel, timeout) —
|
||||||
@@ -191,7 +234,14 @@
|
|||||||
* @param {Function} [opts.setTimeout] injectable for tests
|
* @param {Function} [opts.setTimeout] injectable for tests
|
||||||
* @returns {Promise<void>}
|
* @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 scheduleTimeout = setTimeoutFn || (typeof setTimeout !== 'undefined' ? setTimeout : null);
|
||||||
const waiting = reg && reg.waiting;
|
const waiting = reg && reg.waiting;
|
||||||
|
|
||||||
@@ -209,6 +259,7 @@
|
|||||||
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
if (onPhase) onPhase(precached ? 'ready' : 'downloading');
|
||||||
if (!precached && refreshShell) await refreshShell();
|
if (!precached && refreshShell) await refreshShell();
|
||||||
|
|
||||||
|
if (!guard()) throw new Error('Playing - update after this song / when paused');
|
||||||
if (!waiting) {
|
if (!waiting) {
|
||||||
reload();
|
reload();
|
||||||
return;
|
return;
|
||||||
@@ -216,7 +267,7 @@
|
|||||||
|
|
||||||
let reloaded = false;
|
let reloaded = false;
|
||||||
const reloadOnce = () => {
|
const reloadOnce = () => {
|
||||||
if (reloaded) return;
|
if (reloaded || (isPlaying() && !overridden)) return;
|
||||||
reloaded = true;
|
reloaded = true;
|
||||||
reload();
|
reload();
|
||||||
};
|
};
|
||||||
@@ -224,6 +275,7 @@
|
|||||||
// either way; reload on controllerchange, or after a short safety net.
|
// either way; reload on controllerchange, or after a short safety net.
|
||||||
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
container.addEventListener('controllerchange', reloadOnce, { once: true });
|
||||||
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
if (scheduleTimeout) scheduleTimeout(reloadOnce, 4000);
|
||||||
|
if (overridden) waiting.postMessage({type:'PLAYING',value:false});
|
||||||
waiting.postMessage({ type: 'SKIP_WAITING' });
|
waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,3 +349,22 @@ test('askCacheStatus returns the worker reply', async () => {
|
|||||||
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
||||||
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
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
|
* sw.js — YT Player Service Worker
|
||||||
*
|
*
|
||||||
* Strategy:
|
* 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/* requests → network-only (never cache yt-dlp results)
|
||||||
* /api/download/* → network-only (streamed binary, never cache)
|
* /api/download/* → network-only (streamed binary, never cache)
|
||||||
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
|
* 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
|
* 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 —
|
* 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.
|
* 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
|
* 3. Install syncs missing assets into ytplayer-assets. "Refresh UI" activates
|
||||||
* versioned shell cache (all-or-nothing), activates a waiting worker if
|
* the verified waiting worker (SKIP_WAITING) and reloads once. The legacy
|
||||||
* there is one (SKIP_WAITING), and reloads once.
|
* full-shell path remains available through ASSET_SYNC=0.
|
||||||
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
* 4. activate: if an older *versioned shell cache* is found (i.e. this
|
||||||
* activation is genuinely replacing a previous deploy, not just the
|
* activation is genuinely replacing a previous deploy, not just the
|
||||||
* first-ever install of a freshly (re)registered worker), broadcast
|
* 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
|
// It changes on every deploy/restart so the cache is busted automatically
|
||||||
// without any manual version bump.
|
// without any manual version bump.
|
||||||
const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v1.0.4';
|
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;
|
const CACHE = 'ytplayer-' + VERSION;
|
||||||
|
|
||||||
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
// Prefix shared by every versioned app-shell cache (ytplayer-<VERSION>).
|
||||||
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
// Utility caches (ytplayer-thumbs, ytplayer-fonts) intentionally do NOT
|
||||||
// match this — they aren't versioned and must survive every activate.
|
// match this — they aren't versioned and must survive every activate.
|
||||||
const SHELL_CACHE_PREFIX = 'ytplayer-';
|
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';
|
const THUMB_CACHE = 'ytplayer-thumbs';
|
||||||
|
|
||||||
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
// Hosts serving video thumbnails and channel avatars. yt-dlp hands back
|
||||||
@@ -71,6 +73,7 @@ const SHELL = [
|
|||||||
'/export.js',
|
'/export.js',
|
||||||
'/async-guard.js',
|
'/async-guard.js',
|
||||||
'/visible-timer.js',
|
'/visible-timer.js',
|
||||||
|
'/asset-sync-core.js',
|
||||||
'/sw-update.js',
|
'/sw-update.js',
|
||||||
'/fingerprint.js',
|
'/fingerprint.js',
|
||||||
'/opfs.js',
|
'/opfs.js',
|
||||||
@@ -146,7 +149,7 @@ const SHELL = [
|
|||||||
// skipWaiting() is NOT called here — the page activates a waiting worker
|
// skipWaiting() is NOT called here — the page activates a waiting worker
|
||||||
// from "Refresh UI" (see sw-update.js).
|
// from "Refresh UI" (see sw-update.js).
|
||||||
self.addEventListener('install', (e) => {
|
self.addEventListener('install', (e) => {
|
||||||
e.waitUntil(precacheShell());
|
e.waitUntil(ASSET_SYNC ? installAssets() : precacheShell());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fresh requests (`cache: 'reload'` skips the browser HTTP cache, which could
|
// 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 ----
|
// ---- Activate: evict old caches, claim clients, notify about update ----
|
||||||
self.addEventListener('activate', (e) => {
|
self.addEventListener('activate', (e) => {
|
||||||
e.waitUntil((async () => {
|
e.waitUntil((async () => {
|
||||||
|
if (ASSET_SYNC) { await activateAssets(); return; }
|
||||||
const keys = await caches.keys();
|
const keys = await caches.keys();
|
||||||
|
|
||||||
// Was there a *previous deploy's* app-shell cache lying around? If so,
|
// 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
|
// 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 ----
|
// ---- Message: handle SKIP_WAITING from the client ----
|
||||||
|
const playingClients = new Map(), deferredClients = new Set();
|
||||||
self.addEventListener('message', (e) => {
|
self.addEventListener('message', (e) => {
|
||||||
if (e.data && e.data.type === 'SKIP_WAITING') {
|
if (e.data && e.data.type === 'PLAYING' && e.source) {
|
||||||
self.skipWaiting();
|
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
|
// "Is your shell already downloaded?" — a waiting worker has precached the
|
||||||
// whole new build during install, so "Refresh UI" can swap to it instantly
|
// 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.
|
// storage pressure, so the files are actually checked.
|
||||||
if (e.data && e.data.type === 'CACHE_STATUS') {
|
if (e.data && e.data.type === 'CACHE_STATUS') {
|
||||||
e.waitUntil((async () => {
|
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;
|
let missing = SHELL.length;
|
||||||
try {
|
try {
|
||||||
const cache = await caches.open(CACHE);
|
const cache = await caches.open(CACHE);
|
||||||
@@ -311,7 +329,8 @@ self.addEventListener('message', (e) => {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Cache-first: serve from cache; if missing, fetch, cache, return.
|
// 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);
|
const cache = await caches.open(CACHE);
|
||||||
// Shell files are precached under their plain URL; index.html asks for them as
|
// 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.
|
// `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.
|
// surface a real network error instead.
|
||||||
return cached || (await networkFetch) || Response.error();
|
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 = {
|
const sandbox = {
|
||||||
|
__ASSET_SYNC__: false,
|
||||||
self: self_,
|
self: self_,
|
||||||
caches: caches_,
|
caches: caches_,
|
||||||
fetch: fetchImpl || (async () => { throw new Error('fetch not mocked'); }),
|
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.
|
`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
|
`piano-engine.mjs` is manifested but is historically absent from SHELL; this
|
||||||
existing offline piano limitation is deferred to the loader phase.
|
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,
|
domContentLoaded: pageMetrics.domContentLoaded,
|
||||||
bootDone: pageMetrics.bootDone,
|
bootDone: pageMetrics.bootDone,
|
||||||
longTaskTotal: pageMetrics.longTaskTotal,
|
longTaskTotal: pageMetrics.longTaskTotal,
|
||||||
|
requests: stats.requests,
|
||||||
swInstallDuration,
|
swInstallDuration,
|
||||||
swInstallBytes: stats.totalWireBytes,
|
swInstallBytes: stats.totalWireBytes,
|
||||||
storageEstimate: storage,
|
storageEstimate: storage,
|
||||||
@@ -630,6 +631,7 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
updateKind,
|
updateKind,
|
||||||
|
requests: stats.requests,
|
||||||
duration,
|
duration,
|
||||||
requestCount: stats.requestCount,
|
requestCount: stats.requestCount,
|
||||||
wireBytes: stats.totalWireBytes,
|
wireBytes: stats.totalWireBytes,
|
||||||
@@ -726,6 +728,7 @@ async function main() {
|
|||||||
mediaReady: summarizeList(coldRuns.map((r) => r.mediaReady)),
|
mediaReady: summarizeList(coldRuns.map((r) => r.mediaReady)),
|
||||||
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
||||||
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
||||||
|
requests: coldRuns[0]?.requests || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,6 +799,7 @@ async function main() {
|
|||||||
wireBytes: summarizeList(updJsRuns.map((r) => r.wireBytes)),
|
wireBytes: summarizeList(updJsRuns.map((r) => r.wireBytes)),
|
||||||
duration: summarizeList(updJsRuns.map((r) => r.duration)),
|
duration: summarizeList(updJsRuns.map((r) => r.duration)),
|
||||||
wireBytesByType: updJsRuns[0]?.wireBytesByType || {},
|
wireBytesByType: updJsRuns[0]?.wireBytesByType || {},
|
||||||
|
requests: updJsRuns[0]?.requests || [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -822,6 +826,7 @@ async function main() {
|
|||||||
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
||||||
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
||||||
wireBytesByType: updCssRuns[0]?.wireBytesByType || {},
|
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 repo = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
||||||
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
|
const commit = 'b77938a', oldTag = '1175f1a1d2c1';
|
||||||
const args = process.argv.slice(2);
|
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';
|
const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all';
|
||||||
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
|
assert.ok(['all', 'chromium', 'webkit'].includes(browserName));
|
||||||
execFileSync('bash', [join(repo, 'perf/make-shell-fixture.sh'), commit, oldTag], { cwd: repo, stdio: 'inherit' });
|
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'));
|
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 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));
|
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() {
|
async function freePort() {
|
||||||
const server = createServer();
|
const server = createServer();
|
||||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
@@ -64,7 +73,7 @@ async function run(name) {
|
|||||||
try {
|
try {
|
||||||
await start(true);
|
await start(true);
|
||||||
assert.equal((await (await fetch(`http://127.0.0.1:${port}/api/version`)).json()).buildTag, oldTag);
|
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 proxyPort = await proxy.listen();
|
||||||
const origin = `http://127.0.0.1:${proxyPort}`;
|
const origin = `http://127.0.0.1:${proxyPort}`;
|
||||||
browser = await ({ chromium, webkit }[name]).launch({ headless: true });
|
browser = await ({ chromium, webkit }[name]).launch({ headless: true });
|
||||||
@@ -114,10 +123,27 @@ async function run(name) {
|
|||||||
await checkBuildTag();
|
await checkBuildTag();
|
||||||
await (await navigator.serviceWorker.getRegistration()).update();
|
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'));
|
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');
|
assert.equal(banners, 1, 'exactly one offer before refresh');
|
||||||
const before = navigations;
|
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.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(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 });
|
||||||
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
|
await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt'));
|
||||||
@@ -125,11 +151,14 @@ async function run(name) {
|
|||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
assert.equal(navigations - before, 1, 'one reload, no update loop');
|
assert.equal(navigations - before, 1, 'one reload, no update loop');
|
||||||
assert.equal(banners, 1, 'no banner after applying the current build');
|
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 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 = [];
|
const missing = [], incorrect = [];
|
||||||
for (const path of shell) {
|
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) { missing.push(path); continue; }
|
||||||
if (response.headers.get('x-asset-hash') !== files[path === '/' ? '/index.html' : path]?.h) incorrect.push(path);
|
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');
|
assert.deepEqual(cacheResult.incorrect, [], 'every cached shell asset belongs to the new build');
|
||||||
proxy.stopTracking();
|
proxy.stopTracking();
|
||||||
const stats = proxy.getStats();
|
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;
|
const durationMs = Date.now() - started;
|
||||||
assert.deepEqual(errors, [], 'no script failures during installation and update');
|
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;
|
disconnected = true;
|
||||||
// Match baseline.mjs: Linux WebKit's setOffline breaks even SW navigation.
|
// Match baseline.mjs: Linux WebKit's setOffline breaks even SW navigation.
|
||||||
// Cut the proxy AND stop Bun so offline boot cannot use network bytes.
|
// 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}`);
|
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');
|
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));
|
console.log(JSON.stringify(result));
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -172,5 +231,5 @@ async function run(name) {
|
|||||||
try {
|
try {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
|
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 }); }
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export function categorizeContentType(contentType, urlPath) {
|
|||||||
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||||
let activeTracking = false;
|
let activeTracking = false;
|
||||||
let requestCount = 0;
|
let requestCount = 0;
|
||||||
|
let requests = [];
|
||||||
let totalWireBytes = 0;
|
let totalWireBytes = 0;
|
||||||
let bytesByType = {
|
let bytesByType = {
|
||||||
html: 0,
|
html: 0,
|
||||||
@@ -110,8 +111,11 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
const ct = upRes.headers['content-type'] || '';
|
const ct = upRes.headers['content-type'] || '';
|
||||||
const cat = categorizeContentType(ct, pathname);
|
const cat = categorizeContentType(ct, pathname);
|
||||||
|
|
||||||
|
let recorded;
|
||||||
if (activeTracking) {
|
if (activeTracking) {
|
||||||
requestCount++;
|
requestCount++;
|
||||||
|
recorded={url:clientReq.url,status:upRes.statusCode,bytes:0};
|
||||||
|
requests.push(recorded);
|
||||||
}
|
}
|
||||||
|
|
||||||
clientRes.writeHead(upRes.statusCode, upRes.headers);
|
clientRes.writeHead(upRes.statusCode, upRes.headers);
|
||||||
@@ -120,6 +124,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
upRes.on('data', (chunk) => {
|
upRes.on('data', (chunk) => {
|
||||||
if (activeTracking) {
|
if (activeTracking) {
|
||||||
totalWireBytes += chunk.length;
|
totalWireBytes += chunk.length;
|
||||||
|
if(recorded) recorded.bytes += chunk.length;
|
||||||
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||||
}
|
}
|
||||||
clientRes.write(chunk);
|
clientRes.write(chunk);
|
||||||
@@ -131,6 +136,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
throttler.on('data', (chunk) => {
|
throttler.on('data', (chunk) => {
|
||||||
if (activeTracking) {
|
if (activeTracking) {
|
||||||
totalWireBytes += chunk.length;
|
totalWireBytes += chunk.length;
|
||||||
|
if(recorded) recorded.bytes += chunk.length;
|
||||||
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -176,7 +182,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
activeTracking = false;
|
activeTracking = false;
|
||||||
},
|
},
|
||||||
resetStats: () => {
|
resetStats: () => {
|
||||||
requestCount = 0;
|
requestCount = 0; requests = [];
|
||||||
totalWireBytes = 0;
|
totalWireBytes = 0;
|
||||||
bytesByType = {
|
bytesByType = {
|
||||||
html: 0,
|
html: 0,
|
||||||
@@ -193,7 +199,7 @@ export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
|||||||
isOfflineMode = !!val;
|
isOfflineMode = !!val;
|
||||||
},
|
},
|
||||||
getStats: () => ({
|
getStats: () => ({
|
||||||
requestCount,
|
requestCount, requests,
|
||||||
totalWireBytes,
|
totalWireBytes,
|
||||||
bytesByType: { ...bytesByType },
|
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);
|
})).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 bytes = new Map();
|
||||||
const legacy = createHash('sha256');
|
const legacy = createHash('sha256');
|
||||||
function walk(dir, prefix = '') {
|
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.
|
// 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.
|
// All original bytes (including index/SW source) remain inputs via source hashes.
|
||||||
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
|
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 };
|
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 buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
|
||||||
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
|
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 (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 (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));
|
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.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
|
||||||
expect(result.index).toContain('href="/app.css"');
|
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;
|
// Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
|
||||||
// rollback retains the historical recursive tag and single-tag URL stamping.
|
// rollback retains the historical recursive tag and single-tag URL stamping.
|
||||||
const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
|
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;
|
const BUILD_TAG = assets.manifest.buildTag;
|
||||||
|
|
||||||
// BUILD_TIME — human-readable "when was this image built". Written by the
|
// 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 headers = { 'Cache-Control': 'no-store', ETag: etag };
|
||||||
const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, ''));
|
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 });
|
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) =>
|
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'),
|
// 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,
|
// after which the replacement silently did nothing, the SW version froze,
|
||||||
// and clients never saw another update no matter how many times we deployed.
|
// and clients never saw another update no matter how many times we deployed.
|
||||||
const src = _swSource.replace(
|
const src = assets.sw;
|
||||||
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/,
|
if (!/typeof __BUILD_TAG__/.test(_swSource)) console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||||
JSON.stringify(BUILD_TAG)
|
|
||||||
);
|
|
||||||
if (src === _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);
|
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 port = await freePort();
|
||||||
const child = Bun.spawn([process.execPath, serverFile], {
|
const child = Bun.spawn([process.execPath, serverFile], {
|
||||||
cwd: root,
|
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',
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
});
|
});
|
||||||
const stdout = new Response(child.stdout).text();
|
const stdout = new Response(child.stdout).text();
|
||||||
@@ -168,6 +168,19 @@ describe('static delivery characterization', () => {
|
|||||||
} finally { await stopServer(server); server = await startServer(); }
|
} 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 () => {
|
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
||||||
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
|||||||
Reference in New Issue
Block a user