113 lines
4.9 KiB
JavaScript
113 lines
4.9 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) {
|
|
return [...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))
|
|
.flatMap(([, group]) => group.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 keep = new Set(paths(current).map(path => url(path, current.files[path])));
|
|
if (previous) for (const path of paths(previous)) {
|
|
if (current.files[path]?.h !== previous.files[path].h) keep.add(url(path, previous.files[path]));
|
|
}
|
|
return keep;
|
|
}
|
|
|
|
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 }) {
|
|
const previous = (await state(cache))?.current;
|
|
const missing = [];
|
|
for (const path of blocking(manifest, previous, activeLayout)) {
|
|
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 (!(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, status, syncAssets, state, commit };
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
else root.AssetSyncCore = api;
|
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|