Merge Phase 6 and reconcile offline completion, response capture and contract tests
This commit is contained in:
84
frontend/app-bootstrap.test.js
Normal file
84
frontend/app-bootstrap.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const {test}=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {webcrypto,createHash}=require('node:crypto');
|
||||
const vm=require('node:vm');
|
||||
const {readFileSync}=require('node:fs');
|
||||
function fixture({cached=false,tampered=false,enabled=true,loading=false,quota=false,insecure=false,previous=false,contract=1,capture=true,controller=true,parser=false}={}){
|
||||
const source='window.appRuns=(window.appRuns||0)+1; window.boot=()=>window.bootRuns=(window.bootRuns||0)+1; document.addEventListener("DOMContentLoaded",boot);',hash=createHash('sha256').update(source).digest('hex'), key='/app.js?v='+hash.slice(0,10);
|
||||
const oldSource='window.oldApp=true;'+source, oldHash=createHash('sha256').update(oldSource).digest('hex').slice(0,10), oldKey='/app.js?v='+oldHash;
|
||||
const held=new Map(), appended=[],writes=[],listeners={};let calls=0;
|
||||
const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
|
||||
if(cached)held.set(key,response());
|
||||
if(previous){held.set(oldKey,new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}));held.set('/__ytp_asset_state',Response.json({previous:{files:{'/app.js':{h:oldHash}},groups:{core:{contract,files:['/app.js']}}}}));}
|
||||
const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,AssetSyncCore:require('./asset-sync-core'),navigator:{serviceWorker:{controller:controller?{}:null}},Lazy:{captureApp:capture,manifest:{groups:{core:{contract:1,files:['/app.js']}},appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return previous?new Response(oldSource,{headers:{'X-Asset-Hash':oldHash}}):response();},caches:{open:async()=>({match:async k=>held.get(k)?.clone(),put:async(k,r)=>{if(quota)throw Error('quota');held.set(k,r.clone());}})}};
|
||||
const doc=root.document={readyState:loading?'loading':'complete',currentScript:parser?{}:null,write:value=>{writes.push(value);vm.runInContext(source,context);},querySelector:()=>({content:"script-src 'self' 'sha256-"+Buffer.from(hash,'hex').toString('base64')+"'"}),addEventListener:(name,fn)=>(listeners[name] ||= []).push(fn),createElement:()=>({remove(){this.removed=true;}}),head:{append(node){appended.push(node);if(node.textContent)vm.runInContext(node.textContent,context);else {queueMicrotask(async()=>{if(previous && node.src===key){const state=await held.get('/__ytp_asset_state').clone().json();if(!root.AssetSyncCore.fallback(root.Lazy.manifest,state.previous,'/app.js'))return node.onerror();vm.runInContext(oldSource,context);}else vm.runInContext(node.src===oldKey?oldSource:source,context);node.onload();});}}}};
|
||||
root.window=root;const context=vm.createContext(root);
|
||||
vm.runInContext(readFileSync(require.resolve('./section-rail.js'),'utf8'),context);
|
||||
return {root,held,appended,writes,listeners,key,calls:()=>calls,doc,response,oldKey};
|
||||
}
|
||||
test('cold boot caches verified app before execution so worker skips its download',async()=>{
|
||||
const f=fixture();await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);assert.equal(f.appended[0].src,undefined);assert.equal(f.appended[0].removed,true);
|
||||
});
|
||||
test('offline boot reads the exact cached app without network or re-evaluation',async()=>{
|
||||
const f=fixture({cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
test('tampered cached bytes are rejected before execution',async()=>{
|
||||
const f=fixture({cached:true,tampered:true});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
test('native and rollback boot use the original external classic script',async()=>{
|
||||
const f=fixture({enabled:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
test('boot waits for the normal DOM event when the parser has not finished',async()=>{
|
||||
const f=fixture({loading:true});await f.root.AppBootstrap.ready;assert.equal(f.root.bootRuns,undefined);for(const fn of f.listeners.DOMContentLoaded)fn();assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
test('quota failure still executes the verified response without deleting saved data',async()=>{
|
||||
const f=fixture({quota:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),1);assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('the early head response is consumed without a second page fetch',async()=>{
|
||||
const f=fixture();f.root.Lazy.appResponse=Promise.resolve(f.response());await f.root.AppBootstrap.ready;
|
||||
assert.equal(f.calls(),0);assert.ok(f.held.has(f.key));assert.equal(f.root.appRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('insecure local HTTP boot retains external execution without WebCrypto',async()=>{
|
||||
const f=fixture({insecure:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('missing current app uses verified same-contract N-1 offline through the controlling worker',async()=>{
|
||||
const f=fixture({previous:true});await f.root.AppBootstrap.ready;
|
||||
assert.equal(f.appended[0].src,f.oldKey);assert.equal(f.root.oldApp,true);assert.equal(f.root.bootRuns,1);assert.equal(f.held.has(f.key),false);
|
||||
});
|
||||
test('an incompatible previous core never executes against the new shell',async()=>{
|
||||
const f=fixture({previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
|
||||
|
||||
test('a feature contract change also prevents stale core execution',async()=>{
|
||||
const f=fixture({previous:true});f.root.Lazy.manifest.groups['feature:test']={contract:2,files:[]};
|
||||
const state=await f.held.get('/__ytp_asset_state').json();state.previous.groups['feature:test']={contract:1,files:[]};f.held.set('/__ytp_asset_state',Response.json(state));
|
||||
await assert.rejects(f.root.AppBootstrap.ready,/hash/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
|
||||
|
||||
test('native-cache engines use their external script and keep normal code-cache behavior',async()=>{
|
||||
const f=fixture({capture:false,controller:false});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
|
||||
test('native-cache engines still reject an incompatible retained core',async()=>{
|
||||
const f=fixture({capture:false,previous:true,contract:2});await assert.rejects(f.root.AppBootstrap.ready,/Unable to load player/);assert.equal(f.root.appRuns,undefined);
|
||||
});
|
||||
test('native-cache engines use the validated current cache entry without decoding its body',async()=>{
|
||||
const f=fixture({capture:false,cached:true});await f.root.AppBootstrap.ready;assert.equal(f.calls(),0);assert.equal(f.appended[0].src,f.key);assert.equal(f.root.bootRuns,1);
|
||||
});
|
||||
|
||||
test('native parser boot preserves classic evaluation before the original DOM event',async()=>{
|
||||
const f=fixture({capture:false,loading:true,parser:true});
|
||||
assert.equal(f.root.appRuns,1);assert.equal(f.root.bootRuns,undefined);
|
||||
assert.deepEqual(f.writes,['<script src="'+f.key+'"></script>']);assert.equal(f.appended.length,0);
|
||||
for(const fn of f.listeners.DOMContentLoaded)fn();await f.root.AppBootstrap.ready;
|
||||
assert.equal(f.root.bootRuns,1);assert.equal(f.calls(),0);
|
||||
});
|
||||
@@ -30,8 +30,8 @@ test('Phase 4 preserves the exact player, continuity, tracking and queue bodies'
|
||||
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
|
||||
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
||||
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
||||
assert.ok(html.indexOf('src="'+file+'"')<html.indexOf('src="app.js"'));
|
||||
const vm=require('node:vm');const context=vm.createContext({window:{},document:{}});
|
||||
assert.ok(html.indexOf('src="'+file+'"')<=html.indexOf('src="section-rail.js"'));
|
||||
const vm=require('node:vm');const doc={readyState:'complete',addEventListener(){},createElement:()=>({}),head:{append(){}}};const globals={document:doc,Lazy:{manifest:{appCache:false},url:()=>'/app.js'},console};globals.window=globals;const context=vm.createContext(globals);
|
||||
// Evaluation before any data/$/els/Player declaration must be safe.
|
||||
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path));
|
||||
if (!group || !previous.groups[group]?.files.includes(path) ||
|
||||
manifest.groups[group].contract !== previous.groups[group].contract) return null;
|
||||
// A stale core must understand every group contract in the new shell.
|
||||
if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) =>
|
||||
previous.groups[name]?.contract !== entry.contract)) return null;
|
||||
return url(path, previous.files[path]);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,3 +96,9 @@ test('metadata probes release response streams while stored asset bodies remain
|
||||
assert.equal((await core.status(m,c)).offlineReady,true);assert.ok(probes.length>0);assert.ok(probes.every(r=>r.bodyUsed));
|
||||
assert.equal(await(await match('/a.js?v=a')).text(),'a');assert.equal(await(await match('/index.html?v=b')).text(),'b');
|
||||
});
|
||||
|
||||
test('stale app core requires every feature contract to match the new shell',()=>{
|
||||
const old={files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||
const next=structuredClone(old);next.files['/app.js'].h='new';next.groups.extra.contract=2;
|
||||
assert.equal(core.fallback(next,old,'/app.js'),null);next.groups.extra.contract=1;assert.equal(core.fallback(next,old,'/app.js'),'/app.js?v=old');
|
||||
});
|
||||
|
||||
@@ -15,11 +15,11 @@ test('staged same-contract responses use verified N-1 without poisoning the new
|
||||
const e=environment(); const cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}};
|
||||
e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'};
|
||||
await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old','Cache-Control':'public, max-age=31536000, immutable','Content-Encoding':'gzip','Content-Length':'9'}}));
|
||||
await e.dispatch('install'); await e.dispatch('activate');await e.complete();
|
||||
assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new');
|
||||
assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(stale.headers.get('Cache-Control'),'no-store');assert.equal(stale.headers.get('Content-Encoding'),null);assert.equal(stale.headers.get('Content-Length'),null);assert.equal(await cache.match('/extra.js?v=new'),undefined);
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:true}}); assert.ok(await cache.match('/extra.js?v=new'));
|
||||
await e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
assert.equal((await e.request('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new');
|
||||
@@ -56,3 +56,22 @@ test('waiting status does not start completion and activation delegates it to it
|
||||
const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job;
|
||||
assert.equal((await core.status(e.m,cache)).offlineReady,true);
|
||||
});
|
||||
|
||||
test('page-captured app is reused on first install without a worker download',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}}));
|
||||
await e.dispatch('install');assert.equal(e.fetches.includes('/app.js?v=app'),false);
|
||||
assert.equal(await(await cache.match('/app.js?v=app')).text(),'page captured app');
|
||||
assert.equal(e.skips.length,0);assert.equal(await core.state(cache),null);
|
||||
});
|
||||
|
||||
|
||||
test('worker never supplies stale app to a native script when a feature contract changed',async()=>{
|
||||
const e=environment(),cache=await e.storage.open(core.CACHE);
|
||||
const old={buildTag:'old',files:{'/app.js':{h:'old'}},groups:{core:{contract:1,files:['/app.js']},extra:{contract:1,files:[]}}};
|
||||
e.m.groups.core.contract=1;e.m.groups.extra={contract:2,background:true,files:[]};
|
||||
await cache.put(core.STATE,Response.json({current:old}));await cache.put('/app.js?v=old',new Response('old app',{headers:{'X-Asset-Hash':'old'}}));
|
||||
await e.dispatch('install');await e.dispatch('activate');await cache.delete('/app.js?v=app');
|
||||
e.sandbox.fetch=async()=>{throw Error('offline')};
|
||||
const response=await e.request('/app.js?v=app');assert.equal(response.status,503);assert.equal(await response.text(),'Offline');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"groups": {
|
||||
"core": {
|
||||
"contract": 1,
|
||||
"contract": 2,
|
||||
"eager": true,
|
||||
"files": [
|
||||
"/index.html",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -123,6 +123,24 @@
|
||||
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
|
||||
},
|
||||
};
|
||||
// Own this response once. Classic-script and fetch preloads do not share
|
||||
// their cached bodies in WebKit; the later bootstrap consumes this promise.
|
||||
const agent=root.navigator.userAgent || '';
|
||||
api.captureApp=!!(manifest.appCache && !root.navigator.serviceWorker?.controller && root.crypto?.subtle && /AppleWebKit\//.test(agent) && !/(?:Chrome|Chromium|Edg|OPR)\//.test(agent));
|
||||
if(api.captureApp) {
|
||||
api.appResponse=(async()=>{
|
||||
const key=url('/app.js');
|
||||
try {
|
||||
const cache=await root.caches?.open('ytplayer-assets'), held=await cache?.match(key);
|
||||
if(held?.headers.get('X-Asset-Hash')===manifest.files['/app.js']?.h && held)return held;
|
||||
} catch {}
|
||||
return root.fetch(key,{credentials:'same-origin'});
|
||||
})();
|
||||
api.appResponse.catch(()=>{});
|
||||
} else if(manifest.appCache) {
|
||||
// Preserve native script/code-cache reuse in engines without the WebKit gap.
|
||||
const preload=doc.createElement('link');preload.rel='preload';preload.as='script';preload.fetchPriority='low';preload.href=url('/app.js');doc.head.append(preload);
|
||||
}
|
||||
root.Lazy = api;
|
||||
root.navigator.serviceWorker?.addEventListener('message',event=>{
|
||||
if(event.data?.type==='ASSET_LAYOUT_REQUEST') event.source?.postMessage({type:'LAYOUT',value:api.layout});
|
||||
|
||||
@@ -2,16 +2,16 @@ const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
function fixture(current, held = []) {
|
||||
const inserted = [], listeners = {}, writes = [];
|
||||
const manifest = { buildTag: 'own', groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
||||
function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1', controlled = false) {
|
||||
const inserted = [], listeners = {}, writes = [], fetched = [];
|
||||
const manifest = { buildTag: 'own', appCache, groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/app.js':{h:'a'}, '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
||||
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
|
||||
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, setTimeout, clearTimeout, navigator:{}, addEventListener(){} };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} };
|
||||
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
|
||||
root.window=root; root.globalThis=root;
|
||||
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
||||
return { root, inserted, writes, listeners, manifest };
|
||||
return { root, inserted, writes, listeners, manifest, fetched };
|
||||
}
|
||||
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
|
||||
const {root,writes}=fixture();
|
||||
@@ -95,3 +95,31 @@ test('online launches request worker completion even with Save-Data and skip off
|
||||
assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']);
|
||||
f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2);
|
||||
});
|
||||
test('server-enabled head capture owns exactly one app response; native and rollback do not fetch',async()=>{
|
||||
const modern=fixture(null,[],true);await modern.root.Lazy.appResponse;
|
||||
assert.deepEqual(modern.fetched,['/app.js?v=a']);
|
||||
const legacy=fixture();assert.deepEqual(legacy.fetched,[]);assert.equal(legacy.root.Lazy.appResponse,undefined);
|
||||
});
|
||||
|
||||
|
||||
test('head capture is cache-first even before a worker controls the page',async()=>{
|
||||
const f=fixture(fixture().manifest,['/app.js?v=a'],true);const response=await f.root.Lazy.appResponse;
|
||||
assert.equal(response.headers.get('X-Asset-Hash'),'a');assert.deepEqual(f.fetched,[]);
|
||||
});
|
||||
|
||||
|
||||
test('Chromium retains native script loading and only preloads the current app URL',async()=>{
|
||||
const f=fixture(null,[],true,'AppleWebKit/537.36 Chrome/140.0 Safari/537.36');
|
||||
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
|
||||
assert.equal(f.inserted.length,1);assert.equal(f.inserted[0].rel,'preload');assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
|
||||
});
|
||||
test('iOS Chromium-branded browsers still use their WebKit response capture',async()=>{
|
||||
const f=fixture(null,[],true,'AppleWebKit/605.1 CriOS/140.0 Mobile Safari/605.1');await f.root.Lazy.appResponse;
|
||||
assert.equal(f.root.Lazy.captureApp,true);assert.deepEqual(f.fetched,['/app.js?v=a']);
|
||||
});
|
||||
|
||||
test('controlled WebKit visits use worker-cached native app scripts without a response handoff',async()=>{
|
||||
const f=fixture(null,[],true,'AppleWebKit/605.1 Safari/605.1',true);
|
||||
assert.equal(f.root.Lazy.captureApp,false);assert.equal(f.root.Lazy.appResponse,undefined);assert.deepEqual(f.fetched,[]);
|
||||
assert.equal(f.inserted[0].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low');
|
||||
});
|
||||
|
||||
@@ -245,3 +245,77 @@ const SectionRail = (() => {
|
||||
|
||||
return { boot, refresh, setFolded, isFolded };
|
||||
})();
|
||||
|
||||
/* Cache the single page-owned app response before it registers the worker. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const doc=root.document, manifest=root.Lazy.manifest, key=root.Lazy.url('/app.js');
|
||||
let domReady=doc.readyState==='complete', appSawDom=false;
|
||||
doc.addEventListener('DOMContentLoaded',()=>{domReady=true;appSawDom=typeof root.boot==='function';},{once:true});
|
||||
function execute(node) {
|
||||
doc.head.append(node);
|
||||
node.remove();
|
||||
delete root.Lazy.appResponse;
|
||||
// app.js keeps its original DOMContentLoaded listener. Async response
|
||||
// consumption can finish after that event; boot exactly once in that case.
|
||||
if(domReady)root.boot();
|
||||
}
|
||||
async function external(url) {
|
||||
await new Promise((resolve,reject)=>{
|
||||
const node=doc.createElement('script');node.src=url;
|
||||
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
|
||||
doc.head.append(node);
|
||||
});
|
||||
}
|
||||
function native() {
|
||||
if(doc.readyState!=='loading' || !doc.currentScript)return external(key);
|
||||
return new Promise((resolve,reject)=>{
|
||||
doc.addEventListener('DOMContentLoaded',()=>typeof root.boot==='function'?resolve():reject(Error('Unable to load player')),{once:true});
|
||||
const escaped=key.replaceAll('&','&').replaceAll('"','"').replaceAll('<','<');
|
||||
doc.write('<script src="'+escaped+'"></script>');
|
||||
});
|
||||
}
|
||||
async function verified(response, expected) {
|
||||
const bytes=await response.clone().arrayBuffer();
|
||||
const digest=new Uint8Array(await root.crypto.subtle.digest('SHA-256',bytes));
|
||||
const hex=Array.from(digest,b=>b.toString(16).padStart(2,'0')).join('');
|
||||
if(hex.slice(0,10)!==expected)throw Error('Player body hash mismatch');
|
||||
return {bytes,digest};
|
||||
}
|
||||
async function start() {
|
||||
if(!manifest.appCache || !root.crypto?.subtle || !root.Lazy.captureApp)return native();
|
||||
const expected=manifest.files['/app.js'].h;
|
||||
let cache;
|
||||
try { cache=await root.caches?.open('ytplayer-assets'); } catch {}
|
||||
let response=await cache?.match(key);
|
||||
if(!response || response.headers.get('X-Asset-Hash')!==expected) {
|
||||
try { response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'})); } catch {}
|
||||
}
|
||||
if(!response?.ok || response.headers.get('X-Asset-Hash')!==expected) {
|
||||
// Preserve staged N-1 boot after an individual cache eviction. Only the
|
||||
// controller can serve this old URL safely; never fetch stale URLs bare.
|
||||
const state=cache && await root.AssetSyncCore.state(cache);
|
||||
const previousKey=state && root.AssetSyncCore.fallback(manifest,state.previous,'/app.js');
|
||||
const previous=previousKey && await cache.match(previousKey);
|
||||
const previousHash=state?.previous?.files['/app.js']?.h;
|
||||
const contractsMatch=state?.previous && Object.entries(manifest.groups).every(([name,group])=>state.previous.groups[name]?.contract===group.contract);
|
||||
if(contractsMatch && root.navigator?.serviceWorker?.controller && previous?.ok && previous.headers.get('X-Asset-Hash')===previousHash) {
|
||||
await verified(previous,previousHash);
|
||||
delete root.Lazy.appResponse;
|
||||
return external(previousKey);
|
||||
}
|
||||
throw Error('Player asset hash mismatch');
|
||||
}
|
||||
const {bytes,digest}=await verified(response,expected);
|
||||
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
|
||||
if(!doc.querySelector('meta[http-equiv="Content-Security-Policy"]').content.includes(permission))throw Error('Player body hash mismatch');
|
||||
// CacheStorage may share a full quota with saved music. Never remove data
|
||||
// to make room; boot from these verified bytes even if caching is refused.
|
||||
try { await cache?.put(key,response); } catch(error) { root.console.warn('[app-cache]',error.message); }
|
||||
const node=doc.createElement('script');node.textContent=new root.TextDecoder().decode(bytes);
|
||||
execute(node);
|
||||
}
|
||||
const ready=start();
|
||||
root.AppBootstrap={ready};
|
||||
ready.catch(error=>root.console.error('[app-cache]',error.message));
|
||||
})(typeof window!=='undefined'?window:globalThis);
|
||||
|
||||
@@ -32,7 +32,7 @@ test('assets.json describes the complete existing shell exactly once with eager
|
||||
const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||
const described = new Set();
|
||||
for (const [name, group] of Object.entries(groups)) {
|
||||
assert.equal(group.contract, 1, `${name} initial contract`);
|
||||
assert.equal(group.contract, name === 'core' ? 2 : 1, `${name} contract (core boot packing changed)`);
|
||||
assert.equal(typeof group.eager, 'boolean');
|
||||
for (const asset of group.files) {
|
||||
assert.ok(!described.has(asset), `${asset} has one group`);
|
||||
@@ -70,6 +70,8 @@ test('only core scripts and styles remain eager; native fallback and active layo
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
const background=new Set(Object.values(groups).filter(g=>g.background).flatMap(g=>g.files));
|
||||
for(const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||
// A style preload discovers bytes without applying or blocking another layout.
|
||||
if(match[0].includes('rel="preload"'))continue;
|
||||
const path='/'+match[1].replace(/^\//,'').split(/[?#]/)[0];
|
||||
assert.ok(!background.has(path),`${path} is not eager`);
|
||||
}
|
||||
@@ -88,3 +90,25 @@ test('layout-independent visibility and transport defaults stay in eager CSS',()
|
||||
for(const selector of ['.stg-overlay','.deck-chips','.np-btn.done .i-save','.sleep-status .sl-ic','.ab-group','.mc-tune'])assert.ok(shared.includes(selector));
|
||||
assert.ok(html.includes('href="layout-base.css"'));
|
||||
});
|
||||
|
||||
|
||||
test('classic first-visit style preload is discovered before the head loader without applying another layout',()=>{
|
||||
const preload=html.match(/<link\b[^>]*rel="preload"[^>]*href="theme-classic.css"[^>]*as="style"[^>]*>/);
|
||||
assert.ok(preload,'classic stylesheet has an early preload');
|
||||
assert.ok(html.indexOf(preload[0])<html.indexOf('src="lazy.js"'));
|
||||
assert.ok(!/<link\b[^>]*rel="stylesheet"[^>]*href="theme-classic.css"/.test(html),'the selected layout remains parser-applied by the loader');
|
||||
});
|
||||
|
||||
|
||||
test('the last core script owns boot without an extra request or unsafe stale packing',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.equal(groups.core.contract,2,'old core packing cannot fall back without the loader');
|
||||
assert.ok(groups.core.files.includes('/app.js'));
|
||||
assert.ok(groups.core.files.includes('/section-rail.js'));
|
||||
assert.equal(groups.core.files.includes('/app-bootstrap.js'),false);
|
||||
assert.equal(listed.has('/app-bootstrap.js'),false);
|
||||
const scripts=[...html.matchAll(/<script src="([^"]+)"/g)].map(m=>m[1]);
|
||||
assert.equal(scripts.at(-1),'section-rail.js');
|
||||
assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
|
||||
assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}'));
|
||||
});
|
||||
|
||||
@@ -600,7 +600,13 @@ async function assetFetch(request, clientId) {
|
||||
if(state.current.files[path]?.h === h) {
|
||||
const previousKey=AssetSyncCore.fallback(state.current,state.previous,path);
|
||||
const previousResponse=previousKey && await cache.match(previousKey);
|
||||
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) return previousResponse;
|
||||
if(previousResponse && previousResponse.headers.get('X-Asset-Hash')===state.previous.files[path].h) {
|
||||
// A stale body returned under a new URL must not poison the browser's
|
||||
// resource cache either. Its decoded stream has no transport encoding.
|
||||
const headers=new Headers(previousResponse.headers);headers.set('Cache-Control','no-store');
|
||||
for(const name of ['Content-Encoding','Content-Length','Transfer-Encoding'])headers.delete(name);
|
||||
return new Response(previousResponse.body,{status:previousResponse.status,headers});
|
||||
}
|
||||
}
|
||||
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}); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user