Files
ytplayer/frontend/asset-sync-core.js

126 lines
5.7 KiB
JavaScript

/* 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);