diff --git a/frontend/app-bootstrap.js b/frontend/app-bootstrap.js new file mode 100644 index 0000000..3152544 --- /dev/null +++ b/frontend/app-bootstrap.js @@ -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); diff --git a/frontend/app-bootstrap.test.js b/frontend/app-bootstrap.test.js new file mode 100644 index 0000000..912b9e0 --- /dev/null +++ b/frontend/app-bootstrap.test.js @@ -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); +}); diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index ced5ccd..f17e810 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -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+'"'){ 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); +}); diff --git a/frontend/assets.json b/frontend/assets.json index c8469f1..3ec184c 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -60,7 +60,8 @@ "/device-db.js", "/shell-core.js", "/views-core.js", - "/section-rail.js" + "/section-rail.js", + "/app-bootstrap.js" ], "background": false }, diff --git a/frontend/index.html b/frontend/index.html index 682325e..76f1ab0 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,13 +13,13 @@ YT Player - + @@ -674,6 +674,6 @@ - + diff --git a/frontend/lazy.js b/frontend/lazy.js index 7bb4258..6b8214b 100644 --- a/frontend/lazy.js +++ b/frontend/lazy.js @@ -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}); diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js index b084413..516c3de 100644 --- a/frontend/lazy.test.js +++ b/frontend/lazy.test.js @@ -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,[]); +}); diff --git a/frontend/shell-consistency.test.js b/frontend/shell-consistency.test.js index 8302dd8..bfd326c 100644 --- a/frontend/shell-consistency.test.js +++ b/frontend/shell-consistency.test.js @@ -98,3 +98,14 @@ test('classic first-visit style preload is discovered before the head loader wit assert.ok(html.indexOf(preload[0])]*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()=>")); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 0551c1b..410430c 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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', diff --git a/perf/README.md b/perf/README.md index ee46ade..96b74cb 100644 --- a/perf/README.md +++ b/perf/README.md @@ -346,3 +346,7 @@ Every cold sample has `waterfalls`: proxy request order/start/headers/end times and page Resource Timing discovery/start/end times. Proxy times start when tracking begins; page times start at navigation. Compare within each clock, then use the page FCP to locate the rendering dependency. + +Pass `--assert-cold` on new builds to require exactly one positive-byte app.js +response in every cold sample, including worker installation. Omit this assertion +when diagnosing historical regressions. diff --git a/perf/baseline.mjs b/perf/baseline.mjs index 3f64556..92190a2 100755 --- a/perf/baseline.mjs +++ b/perf/baseline.mjs @@ -26,7 +26,7 @@ */ import assert from 'node:assert/strict'; -import { assertChangedAssets, assertWarmAssets, waitForIdleAssets } from './measurement.mjs'; +import { assertColdAssets, assertChangedAssets, assertWarmAssets, waitForIdleAssets } from './measurement.mjs'; import { createHash } from 'node:crypto'; import { spawn, spawnSync, execFileSync } from 'node:child_process'; import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs'; @@ -63,6 +63,7 @@ function parseArgs() { compare: null, frontendCommit: null, sourceRoot: null, + assertCold: false, }; for (let i = 0; i < args.length; i++) { @@ -72,6 +73,7 @@ function parseArgs() { else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i]; else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i]; else if (a === '--out' && i + 1 < args.length) options.out = args[++i]; + else if (a === '--assert-cold') options.assertCold = true; else if (a === '--source-root' && i + 1 < args.length) options.sourceRoot = path.resolve(args[++i]); else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i]; else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i]; @@ -774,6 +776,7 @@ async function main() { for (let r = 0; r < options.runs; r++) { process.stdout.write(` run ${r + 1}/${options.runs}... `); const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof }); + if(options.assertCold)assertColdAssets(res.requests); coldRuns.push(res); console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`); } diff --git a/perf/measurement.mjs b/perf/measurement.mjs index 66035aa..f255f76 100644 --- a/perf/measurement.mjs +++ b/perf/measurement.mjs @@ -27,3 +27,8 @@ export function assertWarmAssets(requests) { const assets=requests.filter(r=>r.bytes>0&&/\.(?:js|css|woff2?|png|svg)(?:[?#]|$)/.test(r.url)&&!new URL(r.url,'http://localhost').pathname.endsWith('/sw.js')); assert.deepEqual(assets,[],'warm reload does not transfer unfinished shell assets'); } + +export function assertColdAssets(requests) { + const transferred=requests.filter(r=>r.bytes>0&&new URL(r.url,'http://localhost').pathname==='/app.js'); + assert.equal(transferred.length,1,'cold app.js transfers its body exactly once across page and worker'); +} diff --git a/perf/measurement.test.mjs b/perf/measurement.test.mjs index 6fd0109..0e2c361 100644 --- a/perf/measurement.test.mjs +++ b/perf/measurement.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { assertChangedAssets, assertWarmAssets } from './measurement.mjs'; +import { assertChangedAssets, assertWarmAssets, assertColdAssets } from './measurement.mjs'; test('changed CSS and feature measurements reject unrelated application transfers',()=>{ const requests=[{url:'/sw.js',bytes:7000},{url:'/asset-sync-core.js',bytes:2000},{url:'/index.html',bytes:12000},{url:'/api/manifest',bytes:3000},{url:'/theme-glass.css?v=new',bytes:5000},{url:'/fonts/a.woff2',bytes:0}]; assertChangedAssets(requests,'/theme-glass.css'); @@ -14,3 +14,10 @@ test('warm measurements reject unfinished shell downloads but allow API checks', assertWarmAssets([{url:'/api/version',bytes:62},{url:'/app.js?v=current',bytes:0}]); assert.throws(()=>assertWarmAssets([{url:'/theme-glass.css?v=current',bytes:5000}])); }); + + +test('cold acceptance rejects a second positive app response while allowing cache revalidation',()=>{ + assertColdAssets([{url:'/app.js?v=current',bytes:12000},{url:'/app.js?v=current',bytes:0}]); + assert.throws(()=>assertColdAssets([{url:'/app.js?v=current',bytes:12000},{url:'/app.js?v=current',bytes:12000}])); + assert.throws(()=>assertColdAssets([])); +}); diff --git a/server/asset-manifest.js b/server/asset-manifest.js index 82127d2..47eff33 100644 --- a/server/asset-manifest.js +++ b/server/asset-manifest.js @@ -8,12 +8,12 @@ const tagHash = bytes => createHash('sha256').update(bytes).digest('hex').slice( export const injectBuildTag = (source, tag) => source.replace( /typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/, JSON.stringify(tag)); -function embedAssets(source, files, groups, buildTag, hashing) { +function embedAssets(source, files, groups, buildTag, hashing, assetSync) { const pageFiles = {}; for (const group of Object.values(groups)) for (const path of group.files) { if (path !== '/index.html' && path !== '/sw.js') pageFiles[path] = { ...files[path], h: hashing ? files[path].h : buildTag }; } - const json = JSON.stringify({ buildTag, groups, files: pageFiles }).replace(/)[\s\S]*?(<\/script>)/, (_, open, close) => open + json + close); } @@ -70,16 +70,17 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu } const files = {}; for (const url of [...bytes.keys()].sort()) files[url] = { h: assetHash(bytes.get(url)), s: bytes.get(url).length, g: membership.get(url) || 'core' }; - const source = bytes.get('/index.html')?.toString() ?? null; + const source = bytes.get('/index.html')?.toString() + .replaceAll('__APP_SCRIPT_HASH__', createHash('sha256').update(bytes.get('/app.js') || '').digest('base64')) ?? null; const swSource = bytes.get('/sw.js')?.toString() ?? null; // Derived build metadata cannot be an input to its own hash. Canonicalize // the index meta and SW injected tag, then publish hashes of the final bytes. // All original bytes (including index/SW source) remain inputs via source hashes. - let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing), files, { hashing }); + let canonicalIndex = source === null ? null : stampIndex(embedAssets(source, files, groups, '__BUILD_TAG__', hashing, assetSync), files, { hashing }); const canonical = { assetSync, files: { ...files }, groups, contracts }; if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h }; const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12)); - const index = source === null ? null : embedAssets(stampIndex(source, files, { hashing, buildTag }), files, groups, buildTag, hashing); + const index = source === null ? null : embedAssets(stampIndex(source, files, { hashing, buildTag }), files, groups, buildTag, hashing, assetSync); const sw = swSource === null ? null : injectBuildTag(swSource, buildTag) .replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync)) .replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')"); diff --git a/server/asset-manifest.test.js b/server/asset-manifest.test.js index ca3a58e..b71bbcc 100644 --- a/server/asset-manifest.test.js +++ b/server/asset-manifest.test.js @@ -88,3 +88,15 @@ test('index embeds build-local group hashes without recursively embedding index/ const legacy=JSON.parse(off.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]); expect(legacy.files['/app.js'].h).toBe(off.manifest.buildTag); })); + +test('app bootstrap CSP hash follows exact app bytes and rollback keeps external execution',()=>fixture(dir=>{ + writeFileSync(join(dir,'index.html'),''); + const result=createAssetManifest(dir),digest=createHash('sha256').update('window.app=1').digest('base64'); + expect(result.index).toContain("'sha256-"+digest+"'"); + + expect(JSON.parse(result.index.match(/id="ytp-assets">([^<]+)([^<]+)([^<]+)