Preserve legacy cache rules and pin service worker bytes at boot

This commit is contained in:
Jonathan Sykes
2026-10-07 23:01:55 +08:00
parent 6cb00d5073
commit d18d17cf29
3 changed files with 27 additions and 13 deletions

View File

@@ -41,7 +41,7 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
}
}
// Missing public is the historical fixed fallback used by local tests.
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, bytes };
if (!existsSync(publicDir)) return { manifest: { buildTag: override || 'dev-build', files: {}, groups: {}, contracts: {} }, index: null, sw: null, swSource: null, bytes };
walk(publicDir);
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } },
@@ -74,10 +74,14 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
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));
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw));
return { manifest: { buildTag, files, groups, contracts }, index, sw, bytes };
return { manifest: { buildTag, files, groups, contracts }, index, sw, swSource, bytes };
}
export function assetCacheControl(path, version, manifest, hashing = true) {
const shortCache = /(^|\/)(fonts|icons)\//.test(path) ? 'public, max-age=2592000' : 'no-cache';
// The legacy index route always revalidates; legacy binary delivery ignored v.
if (!hashing && path === '/index.html') return 'no-cache';
if (!hashing && !/\.(?:js|css|html|json|webmanifest|svg|txt)$/i.test(path)) return shortCache;
const current = hashing ? manifest.files[path]?.h : manifest.buildTag;
if (version !== undefined) {
if (version === current) return 'public, max-age=31536000, immutable';

View File

@@ -350,7 +350,7 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener });
// ============================================================================
// GET /api/version
// Returns version string + a build tag that changes on every server restart/deploy.
// Returns version and a deterministic tag that changes only with shipped files.
// Clients poll this to detect when a new build is live and prompt a reload.
app.get('/api/manifest', (c) => {
const etag = `"${BUILD_TAG}"`;
@@ -2432,15 +2432,9 @@ app.post('/api/admin/p2p/revoke', notes.requireAdminOrToken, async (c) => {
// Served with no-store cache headers so browsers always re-fetch it and
// pick up the substituted value rather than a browser-cached stale copy.
// ============================================================================
let _swSource = null;
const _swSource = assets.swSource;
app.get('/sw.js', (c) => {
if (!_swSource) {
try {
_swSource = readFileSync('./public/sw.js', 'utf8');
} catch {
return c.text('Service worker not found', 404);
}
}
if (_swSource === null) return c.text('Service worker not found', 404);
// Inject the build tag: replace the whole fallback expression with the
// real value. Matched by REGEX, not an exact string — an exact match broke
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),

View File

@@ -94,7 +94,7 @@ describe('static delivery characterization', () => {
expect(fallback.buildTag).toBe('dev-build');
});
test('index stamps only relative local css/js and serves expected cache headers', async () => {
test('index stamps relative and root-local css/js and serves expected cache headers', async () => {
const index = await fetch(`${server.base}/`);
const html = await index.text();
const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json();
@@ -123,7 +123,6 @@ describe('static delivery characterization', () => {
expect(await notModified.text()).toBe('');
});
test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => {
const response = await fetch(`${server.base}/api/manifest`);
expect(response.headers.get('cache-control')).toBe('no-store');
@@ -163,6 +162,9 @@ describe('static delivery characterization', () => {
expect(html).toContain('href="/root.css"');
expect((await fetch(`${server.base}/app.js?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/index.html?v=${buildTag}`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/fonts/font.woff2?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=2592000');
expect((await fetch(`${server.base}/fonts/font.woff2?v=stale`)).headers.get('cache-control')).toBe('public, max-age=2592000');
} finally { await stopServer(server); server = await startServer(); }
});
@@ -190,6 +192,20 @@ describe('static delivery characterization', () => {
server = await startServer();
});
test('served bytes remain pinned to their boot-time hashes', async () => {
const before = await (await fetch(`${server.base}/app.js`)).text();
const swBefore = await (await fetch(`${server.base}/sw.js`)).text();
writeFileSync(join(publicDir, 'app.js'), 'changed after boot');
writeFileSync(join(publicDir, 'sw.js'), 'changed after boot');
try {
expect(await (await fetch(`${server.base}/app.js`)).text()).toBe(before);
expect(await (await fetch(`${server.base}/sw.js`)).text()).toBe(swBefore);
} finally {
writeFileSync(join(publicDir, 'app.js'), before);
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n");
}
});
test('SPA routes fall back to the stamped index', async () => {
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
for (const path of ['/search', '/playlist/x']) {