/* Exact, resumable asset synchronization shared by pages and workers. */ (function (root) { 'use strict'; const CACHE = 'ytplayer-assets'; const STATE = '/__ytp_asset_state'; const url = (path, file) => path + '?v=' + file.h; function paths(manifest) { return [...new Set(Object.values(manifest.groups).flatMap(group => group.files))].sort(); } function blocking(manifest, previous, activeLayout) { const names = new Set(Object.entries(manifest.groups) .filter(([name, group]) => name === 'core' || !group.background || name === 'layout:' + activeLayout || (previous?.groups[name] && previous.groups[name].contract !== group.contract)) .map(([name]) => name)); for (const name of names) for (const dependency of manifest.groups[name].dependencies || []) names.add(dependency); return [...new Set([...names].flatMap(name => manifest.groups[name].files))].sort(); } function plan(manifest, keys) { const held = new Set(keys); return { missing: paths(manifest).map(path => url(path, manifest.files[path])).filter(key => !held.has(key)), blocking: blocking(manifest).map(path => url(path, manifest.files[path])), }; } function retained(current, previous) { const currentPaths = new Set(paths(current)); const keep = new Set([...currentPaths].map(path => url(path, current.files[path]))); if (previous) for (const path of paths(previous)) { if (!currentPaths.has(path) || current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path])); } return keep; } function fallback(manifest, previous, path) { if (!manifest.files[path] || !previous?.files[path]) return null; const group = Object.keys(manifest.groups).find(name => manifest.groups[name].files.includes(path)); if (!group || !previous.groups[group]?.files.includes(path) || manifest.groups[group].contract !== previous.groups[group].contract) return null; return url(path, previous.files[path]); } async function state(cache) { const response = await cache.match(STATE); return response ? response.json() : null; } async function status(manifest, cache, { previous, activeLayout } = {}) { const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => { const response = await cache.match(url(path, manifest.files[path])); return !!response && response.headers.get('X-Asset-Hash') === manifest.files[path].h; })); const missing = found.filter(value => !value).length; return { ready: missing === 0, missing, version: manifest.buildTag }; } async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups }) { const previous = (await state(cache))?.current; const missing = []; const selected = groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort() : blocking(manifest, previous, activeLayout); for (const path of selected) { const key = url(path, manifest.files[path]); const response = await cache.match(key); if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(key); } const count = missing.length; let failure; await Promise.all(Array.from({ length: Math.max(1, Math.min(6, concurrency, count)) }, async () => { while (missing.length && !failure) { const key = missing.shift(); let error; for (let attempt = 0; attempt < Math.min(3, attempts); attempt++) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); try { const response = await fetchFn(key, { credentials: 'same-origin', signal: controller.signal }); if (!response.ok || response.headers.get('X-Asset-Hash') !== key.split('v=')[1]) { throw new Error('Asset hash mismatch: ' + key); } // Keep the timeout through body consumption, not just response headers. await cache.put(key, response); error = null; break; } catch (err) { error = err; } finally { clearTimeout(timer); } } if (error) failure = error; } })); if (failure) throw failure; if (!groups && !(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets'); return { refreshed: count, caches: 1 }; } async function commit(manifest, cache, { previousClients, activeLayout } = {}) { const old = await state(cache); if (!(await status(manifest, cache, { previous: old?.current, activeLayout })).ready) { throw new Error('Incomplete blocking assets'); } const sameBuild = old?.current?.buildTag === manifest.buildTag; const previous = sameBuild ? old.previous : old?.current; const next = { current: manifest, previous, previousClients: previousClients || (sameBuild ? old.previousClients : []), }; // One state write publishes the complete build and its old-tab affinity. await cache.put(STATE, new Response(JSON.stringify(next), { headers: { 'Content-Type': 'application/json' } })); const keep = retained(manifest, previous); for (const request of await cache.keys()) { const parsed = new URL(request.url); const key = parsed.pathname + parsed.search; if (parsed.searchParams.has('v') && !keep.has(key)) await cache.delete(key); } return next; } const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, syncAssets, state, commit }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.AssetSyncCore = api; })(typeof globalThis !== 'undefined' ? globalThis : this);