diff --git a/frontend/app-bootstrap.js b/frontend/app-bootstrap.js deleted file mode 100644 index 9998a2e..0000000 --- a/frontend/app-bootstrap.js +++ /dev/null @@ -1,65 +0,0 @@ -/* 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); - }); - } - 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 external(key); - 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); diff --git a/frontend/app-bootstrap.test.js b/frontend/app-bootstrap.test.js index d790f97..77fe7f6 100644 --- a/frontend/app-bootstrap.test.js +++ b/frontend/app-bootstrap.test.js @@ -3,18 +3,18 @@ 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}={}){ +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=[],listeners={};let calls=0; + 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',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();});}}}}; + 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('./app-bootstrap.js'),'utf8'),context); - return {root,held,appended,listeners,key,calls:()=>calls,doc,response,oldKey}; + 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); @@ -74,3 +74,11 @@ test('native-cache engines still reject an incompatible retained core',async()=> 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,['']);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); +}); diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index f17e810..f6dc72e 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -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+'"')({}),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); } diff --git a/frontend/assets.json b/frontend/assets.json index 3ec184c..cdf4327 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -1,7 +1,7 @@ { "groups": { "core": { - "contract": 1, + "contract": 2, "eager": true, "files": [ "/index.html", @@ -60,8 +60,7 @@ "/device-db.js", "/shell-core.js", "/views-core.js", - "/section-rail.js", - "/app-bootstrap.js" + "/section-rail.js" ], "background": false }, diff --git a/frontend/index.html b/frontend/index.html index 76f1ab0..b57f5da 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,7 @@ - + @@ -674,6 +674,5 @@ - diff --git a/frontend/lazy.js b/frontend/lazy.js index fe83a12..ae7f0de 100644 --- a/frontend/lazy.js +++ b/frontend/lazy.js @@ -138,7 +138,7 @@ 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.href=url('/app.js');doc.head.append(preload); + 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=>{ diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js index 1e7d88a..6543250 100644 --- a/frontend/lazy.test.js +++ b/frontend/lazy.test.js @@ -102,7 +102,7 @@ test('head capture is cache-first even before a worker controls the page',async( 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.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; @@ -112,5 +112,5 @@ test('iOS Chromium-branded browsers still use their WebKit response capture',asy 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].as,'script');assert.equal(f.inserted[0].href,'/app.js?v=a');assert.equal(f.inserted[0].fetchPriority,'low'); }); diff --git a/frontend/section-rail.js b/frontend/section-rail.js index c84cf10..e277312 100644 --- a/frontend/section-rail.js +++ b/frontend/section-rail.js @@ -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(''); + }); + } + 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); diff --git a/frontend/shell-consistency.test.js b/frontend/shell-consistency.test.js index bfd326c..e044ff5 100644 --- a/frontend/shell-consistency.test.js +++ b/frontend/shell-consistency.test.js @@ -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`); @@ -100,12 +100,15 @@ test('classic first-visit style preload is discovered before the head loader wit }); -test('the eager cache bootstrap stays last and retains app.js in core and legacy shell',()=>{ +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.ok(groups.core.files.includes('/app-bootstrap.js')); + 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(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(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(/