Add contract-checked fallback and background asset synchronization
This commit is contained in:
@@ -33,6 +33,14 @@
|
|||||||
return keep;
|
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) {
|
async function state(cache) {
|
||||||
const response = await cache.match(STATE);
|
const response = await cache.match(STATE);
|
||||||
return response ? response.json() : null;
|
return response ? response.json() : null;
|
||||||
@@ -47,10 +55,12 @@
|
|||||||
return { ready: missing === 0, missing, version: manifest.buildTag };
|
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 previous = (await state(cache))?.current;
|
||||||
const missing = [];
|
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 key = url(path, manifest.files[path]);
|
||||||
const response = await cache.match(key);
|
const response = await cache.match(key);
|
||||||
if (!response || response.headers.get('X-Asset-Hash') !== manifest.files[path].h) missing.push(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 (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 };
|
return { refreshed: count, caches: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +116,7 @@
|
|||||||
return next;
|
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;
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
else root.AssetSyncCore = api;
|
else root.AssetSyncCore = api;
|
||||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
|
|||||||
@@ -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('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('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('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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user