Restore native boot scheduling without an extra core request
This commit is contained in:
@@ -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);
|
|
||||||
@@ -3,18 +3,18 @@ const assert=require('node:assert/strict');
|
|||||||
const {webcrypto,createHash}=require('node:crypto');
|
const {webcrypto,createHash}=require('node:crypto');
|
||||||
const vm=require('node:vm');
|
const vm=require('node:vm');
|
||||||
const {readFileSync}=require('node:fs');
|
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 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 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)}});
|
const response=()=>new Response(tampered?'bad':source,{headers:{'X-Asset-Hash':hash.slice(0,10)}});
|
||||||
if(cached)held.set(key,response());
|
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']}}}}));}
|
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 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);
|
root.window=root;const context=vm.createContext(root);
|
||||||
vm.runInContext(readFileSync(require.resolve('./app-bootstrap.js'),'utf8'),context);
|
vm.runInContext(readFileSync(require.resolve('./section-rail.js'),'utf8'),context);
|
||||||
return {root,held,appended,listeners,key,calls:()=>calls,doc,response,oldKey};
|
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()=>{
|
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);
|
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()=>{
|
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);
|
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',()=>{
|
test('eager shell and Settings definitions load before app state and defer reading it',()=>{
|
||||||
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
const html=readFileSync(join(__dirname,'index.html'),'utf8');
|
||||||
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
for(const file of ['shell-core.js','views-core.js','section-rail.js']) {
|
||||||
assert.ok(html.indexOf('src="'+file+'"')<html.indexOf('src="app-bootstrap.js"'));
|
assert.ok(html.indexOf('src="'+file+'"')<=html.indexOf('src="section-rail.js"'));
|
||||||
const vm=require('node:vm');const context=vm.createContext({window:{},document:{}});
|
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.
|
// Evaluation before any data/$/els/Player declaration must be safe.
|
||||||
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
vm.runInContext(readFileSync(join(__dirname,file),'utf8'),context);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"groups": {
|
"groups": {
|
||||||
"core": {
|
"core": {
|
||||||
"contract": 1,
|
"contract": 2,
|
||||||
"eager": true,
|
"eager": true,
|
||||||
"files": [
|
"files": [
|
||||||
"/index.html",
|
"/index.html",
|
||||||
@@ -60,8 +60,7 @@
|
|||||||
"/device-db.js",
|
"/device-db.js",
|
||||||
"/shell-core.js",
|
"/shell-core.js",
|
||||||
"/views-core.js",
|
"/views-core.js",
|
||||||
"/section-rail.js",
|
"/section-rail.js"
|
||||||
"/app-bootstrap.js"
|
|
||||||
],
|
],
|
||||||
"background": false
|
"background": false
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -138,7 +138,7 @@
|
|||||||
api.appResponse.catch(()=>{});
|
api.appResponse.catch(()=>{});
|
||||||
} else if(manifest.appCache) {
|
} else if(manifest.appCache) {
|
||||||
// Preserve native script/code-cache reuse in engines without the WebKit gap.
|
// 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.Lazy = api;
|
||||||
root.navigator.serviceWorker?.addEventListener('message',event=>{
|
root.navigator.serviceWorker?.addEventListener('message',event=>{
|
||||||
|
|||||||
@@ -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()=>{
|
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');
|
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.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()=>{
|
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;
|
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()=>{
|
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);
|
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.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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -245,3 +245,77 @@ const SectionRail = (() => {
|
|||||||
|
|
||||||
return { boot, refresh, setFolded, isFolded };
|
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 { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8'));
|
||||||
const described = new Set();
|
const described = new Set();
|
||||||
for (const [name, group] of Object.entries(groups)) {
|
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');
|
assert.equal(typeof group.eager, 'boolean');
|
||||||
for (const asset of group.files) {
|
for (const asset of group.files) {
|
||||||
assert.ok(!described.has(asset), `${asset} has one group`);
|
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'));
|
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(groups.core.files.includes('/app.js'));
|
||||||
assert.ok(listed.has('/app-bootstrap.js')&&listed.has('/app.js'));
|
assert.ok(groups.core.files.includes('/section-rail.js'));
|
||||||
assert.ok(html.indexOf('src="app-bootstrap.js"')>html.indexOf('src="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(html.includes("'sha256-__APP_SCRIPT_HASH__'"));
|
||||||
assert.ok(readFileSync(join(__dirname,'lazy.js'),'utf8').includes("api.appResponse=(async()=>"));
|
assert.ok(readFileSync(join(__dirname,'section-rail.js'),'utf8').includes('root.AppBootstrap={ready}'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,7 +146,6 @@ const SHELL = [
|
|||||||
'/share-external.js',
|
'/share-external.js',
|
||||||
'/stats-view.js',
|
'/stats-view.js',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/app-bootstrap.js',
|
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
'/icons/icon-512.png',
|
'/icons/icon-512.png',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"results": [
|
"results": [
|
||||||
{
|
{
|
||||||
"browser": "chromium",
|
"browser": "chromium",
|
||||||
"sourceBuildTag": "38131d1a6b5e",
|
"sourceBuildTag": "31617662d2f0",
|
||||||
"classicSharedDefaults": true,
|
"classicSharedDefaults": true,
|
||||||
"cssOrderPreserved": true,
|
"cssOrderPreserved": true,
|
||||||
"settingsSearch": true,
|
"settingsSearch": true,
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"browser": "webkit",
|
"browser": "webkit",
|
||||||
"sourceBuildTag": "38131d1a6b5e",
|
"sourceBuildTag": "31617662d2f0",
|
||||||
"classicSharedDefaults": true,
|
"classicSharedDefaults": true,
|
||||||
"cssOrderPreserved": true,
|
"cssOrderPreserved": true,
|
||||||
"settingsSearch": true,
|
"settingsSearch": true,
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "/127.0.0.1:43269/api/recommendations?fp=163577c02a53bc40.",
|
"message": "/127.0.0.1:38489/api/version.",
|
||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "/127.0.0.1:43269/api/version.",
|
"message": "/127.0.0.1:38489/api/recommendations?fp=163577c02a53bc40.",
|
||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "/127.0.0.1:43269/api/version.",
|
"message": "/127.0.0.1:38489/api/version.",
|
||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "/127.0.0.1:43269/api/recommendations?fp=163577c02a53bc40.",
|
"message": "/127.0.0.1:38489/api/recommendations?fp=163577c02a53bc40.",
|
||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"disconnected": true
|
"disconnected": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"message": "/127.0.0.1:43269/api/version.",
|
"message": "/127.0.0.1:38489/api/version.",
|
||||||
"disconnected": true
|
"disconnected": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ and is diagnostic only; the shipping fix must retain selected-layout behavior.
|
|||||||
The modern synchronizer already uses default fetch caching. Changing it to
|
The modern synchronizer already uses default fetch caching. Changing it to
|
||||||
force-cache has no effect: every WebKit sample still transfers app.js twice.
|
force-cache has no effect: every WebKit sample still transfers app.js twice.
|
||||||
The page and worker cannot reuse these HTTP-cache responses in this harness.
|
The page and worker cannot reuse these HTTP-cache responses in this harness.
|
||||||
A same-build page handoff can reuse the page HTTP cache instead. Worker validation
|
The next hypothesis was a same-build page handoff reusing the page HTTP cache.
|
||||||
must check both the X-Asset-Hash header and SHA-256 of the decoded bytes. It must
|
The prototype checked both the X-Asset-Hash header and decoded-body SHA-256, and
|
||||||
strip compressed transport headers before constructing the cached Response.
|
stripped compressed transport headers before constructing the cached Response.
|
||||||
Updates and legacy migration must continue through their existing worker path.
|
Updates and legacy migration must continue through their existing worker path.
|
||||||
|
|
||||||
The shipping CSS fix is a preload, not an unconditional stylesheet. Its three-run
|
The shipping CSS fix is a preload, not an unconditional stylesheet. Its three-run
|
||||||
@@ -113,3 +113,25 @@ visits. Controlled warm/offline/update pages use the worker's native exact-URL
|
|||||||
script path, including its strengthened contract guard. A failing head-loader
|
script path, including its strengthened contract guard. A failing head-loader
|
||||||
test precedes this change; all 222 frontend and 184 server tests and both-engine
|
test precedes this change; all 222 frontend and 184 server tests and both-engine
|
||||||
lazy smoke pass. Cold capture and both rollback flags are unchanged.
|
lazy smoke pass. Cold capture and both rollback flags are unchanged.
|
||||||
|
|
||||||
|
The final native-path review caught a new Chromium paint regression: five-run
|
||||||
|
FCP 1616/2328 ms (LTE/lossy), despite faster boot. A contemporary Phase 5 LTE
|
||||||
|
control measured 1360 ms over three runs. Lowering the new app preload priority
|
||||||
|
reduced FCP to 1396 ms (three runs). Native parser execution restores app's
|
||||||
|
original DOMContentLoaded boot boundary. A rejected no-preload controlled-page
|
||||||
|
trial worsened WebKit warm boot to 98 ms; native cache preloading remains.
|
||||||
|
|
||||||
|
The standalone helper adds a CacheStorage script read to native startup. Packing
|
||||||
|
the loader into the existing final core script, section-rail.js, measured Chrome
|
||||||
|
warm 425 ms versus contemporary Phase 5 426 ms (five runs); WebKit measured
|
||||||
|
95 ms versus 88 ms, so zero warm overhead is not yet established. The merged
|
||||||
|
three-run Chrome LTE cold trial measured FCP 1384 ms and boot 6512 ms. Core's
|
||||||
|
contract becomes 2 because an old section script lacks this loader and cannot
|
||||||
|
safely substitute for it. No Phase 5 SHELL file is removed; the experimental
|
||||||
|
Phase 6 standalone helper is removed. Native-order, exact final-script packing
|
||||||
|
and contract tests precede implementation. All 223 frontend and 184 server tests
|
||||||
|
and both-engine lazy/offline/eviction/pinning/contracts/playback smoke pass.
|
||||||
|
|
||||||
|
The intermediate native-auto-priority full dataset is retained as a rejected
|
||||||
|
trial; it is not the final acceptance result. Source directories named in trial
|
||||||
|
JSONs are dirty diagnostic worktrees of 9408ee0 with exactly the changes above.
|
||||||
|
|||||||
Reference in New Issue
Block a user