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