Cache the page-owned app response before starting the worker
This commit is contained in:
44
frontend/app-bootstrap.js
Normal file
44
frontend/app-bootstrap.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* 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 start() {
|
||||
if(!manifest.appCache || !root.crypto?.subtle) {
|
||||
await new Promise((resolve,reject)=>{
|
||||
const node=doc.createElement('script');node.src=key;
|
||||
node.onload=()=>{if(domReady&&!appSawDom)root.boot();resolve();};node.onerror=()=>reject(Error('Unable to load player'));
|
||||
doc.head.append(node);
|
||||
});
|
||||
return;
|
||||
}
|
||||
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)response=await (root.Lazy.appResponse || root.fetch(key,{credentials:'same-origin'}));
|
||||
if(!response.ok || response.headers.get('X-Asset-Hash')!==expected)throw Error('Player asset hash mismatch');
|
||||
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('');
|
||||
const permission="'sha256-"+root.btoa(String.fromCharCode(...digest))+"'";
|
||||
if(hex.slice(0,10)!==expected || !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);
|
||||
45
frontend/app-bootstrap.test.js
Normal file
45
frontend/app-bootstrap.test.js
Normal file
@@ -0,0 +1,45 @@
|
||||
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}={}){
|
||||
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 held=new Map(), appended=[],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());
|
||||
const root={crypto:insecure?undefined:webcrypto,TextDecoder,Uint8Array,btoa,console,Lazy:{manifest:{appCache:enabled,files:{'/app.js':{h:hash.slice(0,10)}}},url:()=>key},fetch:async()=>{calls++;return 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',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 {vm.runInContext(source,context);queueMicrotask(()=>node.onload());}}}};
|
||||
root.window=root;const context=vm.createContext(root);
|
||||
vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context);
|
||||
return {root,held,appended,listeners,key,calls:()=>calls,doc,response};
|
||||
}
|
||||
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);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ 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"'));
|
||||
assert.ok(html.indexOf('src="'+file+'"')<html.indexOf('src="app-bootstrap.js"'));
|
||||
const vm=require('node:vm');const context=vm.createContext({window:{},document:{}});
|
||||
// Evaluation before any data/$/els/Player declaration must be safe.
|
||||
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
||||
|
||||
@@ -42,3 +42,12 @@ test('overlapping idle warm messages share one download pool',async()=>{
|
||||
await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]);
|
||||
assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1);
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"/device-db.js",
|
||||
"/shell-core.js",
|
||||
"/views-core.js",
|
||||
"/section-rail.js"
|
||||
"/section-rail.js",
|
||||
"/app-bootstrap.js"
|
||||
],
|
||||
"background": false
|
||||
},
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -122,6 +122,19 @@
|
||||
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.
|
||||
if(manifest.appCache && root.crypto?.subtle) {
|
||||
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(()=>{});
|
||||
}
|
||||
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) {
|
||||
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:{}, 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();
|
||||
@@ -84,3 +84,16 @@ test('classic chunk registry proxies use window.YT and keep the executed singlet
|
||||
const singleton=root.YT.TestApi;await root.Lazy.load('feature:test');
|
||||
assert.equal(root.YT.TestApi,singleton);assert.equal(inserted.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,[]);
|
||||
});
|
||||
|
||||
@@ -98,3 +98,14 @@ test('classic first-visit style preload is discovered before the head loader wit
|
||||
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 eager cache bootstrap stays last and retains app.js in core and legacy shell',()=>{
|
||||
const {groups}=JSON.parse(readFileSync(join(__dirname,'assets.json'),'utf8'));
|
||||
assert.ok(groups.core.files.includes('/app-bootstrap.js'));
|
||||
assert.ok(groups.core.files.includes('/app.js'));
|
||||
assert.ok(listed.has('/app-bootstrap.js')&&listed.has('/app.js'));
|
||||
assert.ok(html.indexOf('src="app-bootstrap.js"')>html.indexOf('src="section-rail.js"'));
|
||||
assert.ok(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
|
||||
assert.ok(readFileSync(join(__dirname,'lazy.js'),'utf8').includes("api.appResponse=(async()=>"));
|
||||
});
|
||||
|
||||
@@ -146,6 +146,7 @@ const SHELL = [
|
||||
'/share-external.js',
|
||||
'/stats-view.js',
|
||||
'/app.js',
|
||||
'/app-bootstrap.js',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
'/icons/icon-512.png',
|
||||
|
||||
Reference in New Issue
Block a user