/* 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 Object.keys(manifest.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; // A stale core must understand every group contract in the new shell. if (path === "/app.js" && Object.entries(manifest.groups).some(([name, entry]) => previous.groups[name]?.contract !== entry.contract)) return null; return url(path, previous.files[path]); } async function state(cache) { const response = await cache.match(STATE); return response ? response.json() : null; } function verifiedProbe(response, hash) { if (!response) return false; const valid = response.headers.get('X-Asset-Hash') === hash; // Metadata checks must release Blob/data-pipe readers. They never consume // asset bytes, and cancelling this response does not remove its cache entry. response.body?.cancel().catch(() => {}); return valid; } async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) { const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => { const response = await cache.match(url(path, manifest.files[path])); return verifiedProbe(response, manifest.files[path].h); })); const missing = found.filter(value => !value).length; return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) }; } // All concurrent messages/layout jobs in this worker share six download slots. let downloads = 0; const waiters = []; async function downloadSlot(task) { if (downloads >= 6) await new Promise(resolve => waiters.push(resolve)); else downloads++; try { return await task(); } finally { const next = waiters.shift(); if (next) next(); else downloads--; } } const pendingDownloads = new Map(); async function sharedDownload(key, task) { while (pendingDownloads.has(key)) await pendingDownloads.get(key).catch(() => {}); const promise = downloadSlot(task); pendingDownloads.set(key, promise); try { return await promise; } finally { pendingDownloads.delete(key); } } async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) { const previous = (await state(cache))?.current; const missing = []; const selected = all ? paths(manifest) : 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 (!verifiedProbe(response, 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) { const key = missing.shift(); try { await sharedDownload(key, async () => { const held = await cache.match(key); if (verifiedProbe(held, key.split('v=')[1])) return; 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) throw error; }); } catch (error) { failure = error; } } })); if (failure) throw failure; if (!groups && !all && !(await status(manifest, cache, { previous, activeLayout, offline: false })).ready) throw new Error('Incomplete blocking assets'); return { refreshed: count, caches: 1 }; } // Verified cache entries are the durable progress journal. Counters are rebuilt // after worker termination or browser eviction; never trust a stored ready bit. async function completeness(manifest, cache) { const files = paths(manifest); const missingFiles = (await Promise.all(files.map(async path => { const response = await cache.match(url(path, manifest.files[path])); return verifiedProbe(response, manifest.files[path].h) ? null : path; }))).filter(Boolean); const total = files.length; return { offlineReady: missingFiles.length === 0, cached: total - missingFiles.length, total, missingFiles }; } const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag; async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000, maxDelay = 30000, notify = () => {}, force = false } = {}) { const key = completionKey(manifest), saved = await cache.match(key); let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 }; let snapshot = await completeness(manifest, cache); const publish = async running => { job = { ...job, ...snapshot, running, version: manifest.buildTag }; await cache.put(key, Response.json(job)); await notify(job); }; if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; await publish(false); return job; } if (!force && job.nextRetryAt > now()) await sleep(Math.min(maxDelay, job.nextRetryAt - now())); for (let cycle = 0; cycle < cycles; cycle++) { await publish(true); let error; try { await syncAssets(manifest, { cache, fetchFn, all: true }); } catch (failure) { error = failure; } snapshot = await completeness(manifest, cache); if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; } job.failures++; const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10)); job.nextRetryAt = now() + delay; job.error = String(error?.message || 'Incomplete offline cache'); await publish(cycle + 1 < cycles); if (cycle + 1 < cycles) await sleep(delay); } return job; } async function commit(manifest, cache, { previousClients, activeLayout } = {}) { const old = await state(cache); if (!(await status(manifest, cache, { previous: old?.current, activeLayout, offline: false })).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, completeness, completionKey, complete, syncAssets, state, commit }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.AssetSyncCore = api; })(typeof globalThis !== 'undefined' ? globalThis : this);