Add contract-checked fallback and background asset synchronization

This commit is contained in:
Jonathan Sykes
2026-10-08 00:30:31 +08:00
parent 895f20a4c9
commit f804a23761
2 changed files with 38 additions and 4 deletions

View File

@@ -33,6 +33,14 @@
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;
@@ -47,10 +55,12 @@
return { ready: missing === 0, missing, version: manifest.buildTag };
}
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout }) {
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups }) {
const previous = (await state(cache))?.current;
const missing = [];
for (const path of blocking(manifest, previous, activeLayout)) {
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);
@@ -80,7 +90,7 @@
}
}));
if (failure) throw failure;
if (!(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets');
if (!groups && !(await status(manifest, cache, { previous, activeLayout })).ready) throw new Error('Incomplete blocking assets');
return { refreshed: count, caches: 1 };
}
@@ -106,7 +116,7 @@
return next;
}
const api = { CACHE, STATE, url, paths, blocking, plan, retained, status, syncAssets, state, commit };
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);

View File

@@ -10,3 +10,27 @@ test('commit is atomic and pruning keeps only current and previous changed versi
test('downloads are capped at six and each failure gets three attempts',async()=>{const c=cache(),files={},list=[];for(let i=0;i<13;i++){const p='/'+i+'.js';files[p]={h:String(i)};list.push(p)}const m={buildTag:'pool',files,groups:{core:{files:list}}};let active=0,max=0;const tries={};await core.syncAssets(m,{cache:c,concurrency:20,fetchFn:async u=>{active++;max=Math.max(max,active);await new Promise(r=>setTimeout(r,5));active--;tries[u]=(tries[u]||0)+1;if(tries[u]<3)throw Error('drop');return response(u.split('=')[1])}});assert.equal(max,6);assert.ok(Object.values(tries).every(n=>n===3));});
test('background files do not block, but active layout and contract changes do',()=>{const m=manifest();m.groups.extra={background:true,contract:2,files:['/extra.js']};m.files['/extra.js']={h:'e'};assert.deepEqual(core.blocking(m),['/a.js','/index.html']);assert.ok(core.blocking(m,{groups:{extra:{contract:1}}}).includes('/extra.js'));m.groups['layout:classic']={background:true,files:['/classic.css']};m.files['/classic.css']={h:'c'};assert.ok(core.blocking(m,null,'classic').includes('/classic.css'));});
test('removed files survive one previous build for open tabs',()=>{const previous=manifest(),current=manifest('c');delete current.files['/a.js'];current.groups.core.files=['/index.html'];assert.ok(core.retained(current,previous).has('/a.js?v=a'));});
test('staged fallback permits only the previous same-contract version of a shipped group file', () => {
const old = manifest(), next = manifest('c');
old.groups.core.contract = next.groups.core.contract = 1;
assert.equal(core.fallback(next, old, '/a.js'), '/a.js?v=a');
next.groups.core.contract = 2;
assert.equal(core.fallback(next, old, '/a.js'), null);
assert.equal(core.fallback(next, old, '/missing.js'), null);
});
test('background synchronization fills all groups without publishing a new current manifest', async () => {
const c = cache(), m = manifest();
m.groups.optional = { contract: 1, background: true, files: ['/extra.js'] };
m.files['/extra.js'] = { h: 'e' };
const fetchFn = async key => response(key.split('=')[1]);
await core.syncAssets(m, { cache: c, fetchFn });
assert.equal(await c.match('/extra.js?v=e'), undefined);
await core.commit(m, c);
const before = await core.state(c);
const result = await core.syncAssets(m, { cache: c, fetchFn, groups: ['optional'] });
assert.equal(result.refreshed, 1);
assert.ok(await c.match('/extra.js?v=e'));
assert.deepEqual(await core.state(c), before);
});