205 lines
9.5 KiB
JavaScript
205 lines
9.5 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 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);
|