Synchronize verified assets incrementally and guard update activation

This commit is contained in:
Jonathan Sykes
2026-10-07 23:40:20 +08:00
parent a1f16f2e3b
commit 54a07a0a2a
15 changed files with 260 additions and 22 deletions

View File

@@ -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));

View File

@@ -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));
}));

View File

@@ -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);
});

View File

@@ -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();