Synchronize verified assets incrementally and guard update activation
This commit is contained in:
@@ -26,7 +26,7 @@ export function stampIndex(source, files, { hashing = true, buildTag = '__BUILD_
|
||||
})).replace('__BUILD_TAG__', buildTag);
|
||||
}
|
||||
|
||||
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override } = {}) {
|
||||
export function createAssetManifest(publicDir = './public', { hashing = true, buildTag: override, assetSync = true } = {}) {
|
||||
const bytes = new Map();
|
||||
const legacy = createHash('sha256');
|
||||
function walk(dir, prefix = '') {
|
||||
@@ -65,11 +65,13 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
|
||||
// the index meta and SW injected tag, then publish hashes of the final bytes.
|
||||
// All original bytes (including index/SW source) remain inputs via source hashes.
|
||||
let canonicalIndex = source === null ? null : stampIndex(source, files, { hashing });
|
||||
const canonical = { files: { ...files }, groups, contracts };
|
||||
const canonical = { assetSync, files: { ...files }, groups, contracts };
|
||||
if (canonicalIndex !== null && hashing) canonical.files['/index.html'] = { ...files['/index.html'], h: assetHash(canonicalIndex), s: Buffer.byteLength(canonicalIndex), source: files['/index.html'].h };
|
||||
const buildTag = hashing ? tagHash(JSON.stringify(canonical)) : (override || legacy.digest('hex').slice(0, 12));
|
||||
const index = source === null ? null : stampIndex(source, files, { hashing, buildTag });
|
||||
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag);
|
||||
const sw = swSource === null ? null : injectBuildTag(swSource, buildTag)
|
||||
.replace("typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true", JSON.stringify(assetSync))
|
||||
.replace("importScripts('/asset-sync-core.js')", "importScripts('/asset-sync-core.js?v=" + (files['/asset-sync-core.js']?.h || '') + "')");
|
||||
if (index !== null) files['/index.html'] = { ...files['/index.html'], h: assetHash(index), s: Buffer.byteLength(index) };
|
||||
if (sw !== null) files['/sw.js'] = { ...files['/sw.js'], h: assetHash(sw), s: Buffer.byteLength(sw) };
|
||||
if (index !== null) bytes.set('/index.html', Buffer.from(index));
|
||||
|
||||
@@ -60,3 +60,11 @@ test('rollback retains legacy tag algorithm and relative-only single-tag stampin
|
||||
expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
|
||||
expect(result.index).toContain('href="/app.css"');
|
||||
}));
|
||||
|
||||
test('sync rollback changes build identity and injected worker byte hash', () => fixture(dir => {
|
||||
writeFileSync(join(dir,'sw.js'), "const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
|
||||
const on=createAssetManifest(dir),off=createAssetManifest(dir,{assetSync:false});
|
||||
expect(on.sw).toContain('const SYNC = true');expect(off.sw).toContain('const SYNC = false');
|
||||
expect(on.manifest.buildTag).not.toBe(off.manifest.buildTag);
|
||||
expect(off.manifest.files['/sw.js'].h).toBe(hash(off.sw));
|
||||
}));
|
||||
|
||||
@@ -99,7 +99,7 @@ function withSaveSlot(fn) {
|
||||
// Snapshot every shipped public file. Hashes describe the served HTML/SW bytes;
|
||||
// rollback retains the historical recursive tag and single-tag URL stamping.
|
||||
const ASSET_HASHING = process.env.ASSET_HASHING !== '0';
|
||||
const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, buildTag: process.env.BUILD_TAG });
|
||||
const assets = createAssetManifest('./public', { hashing: ASSET_HASHING, assetSync: process.env.ASSET_SYNC !== '0', buildTag: process.env.BUILD_TAG });
|
||||
const BUILD_TAG = assets.manifest.buildTag;
|
||||
|
||||
// BUILD_TIME — human-readable "when was this image built". Written by the
|
||||
@@ -357,7 +357,8 @@ app.get('/api/manifest', (c) => {
|
||||
const headers = { 'Cache-Control': 'no-store', ETag: etag };
|
||||
const matches = (c.req.header('if-none-match') || '').split(',').map(value => value.trim().replace(/^W\//, ''));
|
||||
if (matches.includes(etag) || matches.includes('*')) return new Response(null, { status: 304, headers });
|
||||
return c.json(assets.manifest, 200, headers);
|
||||
const entry = compressedEntry('api-manifest',Buffer.from(JSON.stringify(assets.manifest)), 'application/json; charset=utf-8');
|
||||
return sendCompressed(c, { ...entry, etag }, 'no-store');
|
||||
});
|
||||
|
||||
app.get('/api/version', (c) =>
|
||||
@@ -2440,13 +2441,8 @@ app.get('/sw.js', (c) => {
|
||||
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
|
||||
// after which the replacement silently did nothing, the SW version froze,
|
||||
// and clients never saw another update no matter how many times we deployed.
|
||||
const src = _swSource.replace(
|
||||
/typeof __BUILD_TAG__ !== 'undefined' \? __BUILD_TAG__ : '[^']*'/,
|
||||
JSON.stringify(BUILD_TAG)
|
||||
);
|
||||
if (src === _swSource) {
|
||||
console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||
}
|
||||
const src = assets.sw;
|
||||
if (!/typeof __BUILD_TAG__/.test(_swSource)) console.error('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||
return sendCompressed(c, compressedEntry('sw.js', Buffer.from(src), MIME.js), 'no-store, no-cache, must-revalidate', assets.manifest.files['/sw.js']?.h);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,11 @@ async function freePort() {
|
||||
});
|
||||
}
|
||||
|
||||
async function startServer(hashing = true) {
|
||||
async function startServer(hashing = true, sync = true) {
|
||||
const port = await freePort();
|
||||
const child = Bun.spawn([process.execPath, serverFile], {
|
||||
cwd: root,
|
||||
env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
|
||||
env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', ASSET_SYNC: sync ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
|
||||
stdout: 'pipe', stderr: 'pipe',
|
||||
});
|
||||
const stdout = new Response(child.stdout).text();
|
||||
@@ -168,6 +168,19 @@ describe('static delivery characterization', () => {
|
||||
} finally { await stopServer(server); server = await startServer(); }
|
||||
});
|
||||
|
||||
test('manifest compression preserves build ETag and sync mode is injected', async () => {
|
||||
const version=await (await fetch(`${server.base}/api/version`)).json();
|
||||
const response=await fetch(`${server.base}/api/manifest`,{headers:{'Accept-Encoding':'gzip'}});
|
||||
expect(response.headers.get('etag')).toBe(`"${version.buildTag}"`);
|
||||
expect(response.headers.get('content-encoding')).toBe('gzip');
|
||||
expect((await response.json()).buildTag).toBe(version.buildTag);
|
||||
writeFileSync(join(publicDir,'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
|
||||
await stopServer(server);server=await startServer(true,false);
|
||||
expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = false');
|
||||
await stopServer(server);server=await startServer(true,true);
|
||||
expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = true');
|
||||
});
|
||||
|
||||
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
||||
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||
|
||||
Reference in New Issue
Block a user