Limit the WebKit response handoff to uncontrolled visits

This commit is contained in:
Jonathan Sykes
2026-10-08 10:08:35 +08:00
parent 97eb31e732
commit 9408ee07e5
4 changed files with 23 additions and 10 deletions

View File

@@ -125,7 +125,7 @@
// Own this response once. Classic-script and fetch preloads do not share // Own this response once. Classic-script and fetch preloads do not share
// their cached bodies in WebKit; the later bootstrap consumes this promise. // their cached bodies in WebKit; the later bootstrap consumes this promise.
const agent=root.navigator.userAgent || ''; const agent=root.navigator.userAgent || '';
api.captureApp=!!(manifest.appCache && root.crypto?.subtle && /AppleWebKit\//.test(agent) && !/(?:Chrome|Chromium|Edg|OPR)\//.test(agent)); api.captureApp=!!(manifest.appCache && !root.navigator.serviceWorker?.controller && root.crypto?.subtle && /AppleWebKit\//.test(agent) && !/(?:Chrome|Chromium|Edg|OPR)\//.test(agent));
if(api.captureApp) { if(api.captureApp) {
api.appResponse=(async()=>{ api.appResponse=(async()=>{
const key=url('/app.js'); const key=url('/app.js');

View File

@@ -2,12 +2,12 @@ const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs'); const { readFileSync } = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1') { function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/605.1 Safari/605.1', controlled = false) {
const inserted = [], listeners = {}, writes = [], fetched = []; 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 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 }; 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?.()); } }; doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
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}, 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:{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 }) }; 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; root.window=root; root.globalThis=root;
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root); vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
@@ -108,3 +108,9 @@ test('iOS Chromium-branded browsers still use their WebKit response capture',asy
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;
assert.equal(f.root.Lazy.captureApp,true);assert.deepEqual(f.fetched,['/app.js?v=a']); 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');
});

View File

@@ -2,7 +2,7 @@
"results": [ "results": [
{ {
"browser": "chromium", "browser": "chromium",
"sourceBuildTag": "22eb7414f94c", "sourceBuildTag": "38131d1a6b5e",
"classicSharedDefaults": true, "classicSharedDefaults": true,
"cssOrderPreserved": true, "cssOrderPreserved": true,
"settingsSearch": true, "settingsSearch": true,
@@ -28,7 +28,7 @@
}, },
{ {
"browser": "webkit", "browser": "webkit",
"sourceBuildTag": "22eb7414f94c", "sourceBuildTag": "38131d1a6b5e",
"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:33445/api/version.", "message": "/127.0.0.1:43269/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -64,7 +64,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33445/api/recommendations?fp=163577c02a53bc40.", "message": "/127.0.0.1:43269/api/version.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -72,7 +72,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33445/api/version.", "message": "/127.0.0.1:43269/api/version.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -80,7 +80,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33445/api/recommendations?fp=163577c02a53bc40.", "message": "/127.0.0.1:43269/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true "disconnected": true
}, },
{ {
@@ -88,7 +88,7 @@
"disconnected": true "disconnected": true
}, },
{ {
"message": "/127.0.0.1:33445/api/version.", "message": "/127.0.0.1:43269/api/version.",
"disconnected": true "disconnected": true
} }
] ]

View File

@@ -106,3 +106,10 @@ CacheStorage or hashing work. WebKit's byte-verified owned response path is
unchanged. The native fixture models the actual worker fallback decision instead unchanged. The native fixture models the actual worker fallback decision instead
of a second page-side decision. All 221 frontend tests and 184 server tests pass; of a second page-side decision. All 221 frontend tests and 184 server tests pass;
both-engine lazy/offline/eviction/pinning/contracts/playback smoke passes. both-engine lazy/offline/eviction/pinning/contracts/playback smoke passes.
The WebKit warm prototype also paid repeated handoff/cache/hash work (warm
~98 ms versus Phase 5 83 ms). Capture now applies only to uncontrolled WebKit
visits. Controlled warm/offline/update pages use the worker's native exact-URL
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
lazy smoke pass. Cold capture and both rollback flags are unchanged.