Publish asset state atomically and reject unavailable versions
This commit is contained in:
@@ -1,25 +1,112 @@
|
||||
/* Exact, resumable asset synchronization shared by pages and workers. */
|
||||
(function(root){
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const CACHE='ytplayer-assets', STATE='/__ytp_asset_state';
|
||||
const url=(path,file)=>path+'?v='+file.h;
|
||||
function paths(m){return [...new Set(Object.values(m.groups).flatMap(g=>g.files))].sort();}
|
||||
function blocking(m){return [...new Set(Object.entries(m.groups).filter(([n,g])=>n==='core'||!g.background).flatMap(([,g])=>g.files))].sort();}
|
||||
function plan(m,keys){const held=new Set(keys);return {missing:paths(m).map(p=>url(p,m.files[p])).filter(u=>!held.has(u)),blocking:blocking(m).map(p=>url(p,m.files[p]))};}
|
||||
function retained(current,previous){const keep=new Set(paths(current).map(p=>url(p,current.files[p])));if(previous)for(const p of paths(previous))if(current.files[p]&¤t.files[p].h!==previous.files[p].h)keep.add(url(p,previous.files[p]));return keep;}
|
||||
async function status(m,cache){let missing=0;for(const p of blocking(m)){const r=await cache.match(url(p,m.files[p]));if(!r||r.headers.get('X-Asset-Hash')!==m.files[p].h)missing++;}return {ready:missing===0,missing,version:m.buildTag};}
|
||||
async function syncAssets(m,{cache,fetchFn,concurrency=6,attempts=3}){
|
||||
const missing=[];for(const p of paths(m)){const u=url(p,m.files[p]),r=await cache.match(u);if(!r||r.headers.get('X-Asset-Hash')!==m.files[p].h)missing.push(u);}
|
||||
const count=missing.length;let failure;
|
||||
await Promise.all(Array.from({length:Math.min(6,concurrency,Math.max(1,count))},async()=>{
|
||||
while(missing.length&&!failure){const u=missing.shift();let error;
|
||||
for(let i=0;i<attempts;i++){try{const ctl=new AbortController();const timer=setTimeout(()=>ctl.abort(),30000);let r;try{r=await fetchFn(u,{credentials:'same-origin',signal:ctl.signal});}finally{clearTimeout(timer);}if(!r.ok||r.headers.get('X-Asset-Hash')!==u.split('v=')[1])throw Error('Asset hash mismatch: '+u);await cache.put(u,r);error=null;break;}catch(e){error=e;}}
|
||||
if(error)failure=error;
|
||||
}
|
||||
}));if(failure)throw failure;if(!(await status(m,cache)).ready)throw Error('Incomplete blocking assets');return {refreshed:count,caches:1};
|
||||
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();
|
||||
}
|
||||
async function state(cache){const r=await cache.match(STATE);return r?await r.json():null;}
|
||||
async function commit(m,cache){if(!(await status(m,cache)).ready)throw Error('Incomplete blocking assets');const old=await state(cache);const previous=old?.current?.buildTag===m.buildTag?old.previous:old?.current;await cache.put(STATE,new Response(JSON.stringify({current:m,previous}),{headers:{'Content-Type':'application/json'}}));const keep=retained(m,previous);for(const req of await cache.keys()){const u=new URL(req.url);const k=u.pathname+u.search;if(u.searchParams.has('v')&&!keep.has(k))await cache.delete(k);}return {current:m,previous};}
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user