Preserve legacy cache rules and pin service worker bytes at boot
This commit is contained in:
@@ -41,7 +41,7 @@ export function createAssetManifest(publicDir = './public', { hashing = true, bu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Missing public is the historical fixed fallback used by local tests.
|
// 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);
|
walk(publicDir);
|
||||||
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
|
const definition = bytes.has('/assets.json') ? JSON.parse(bytes.get('/assets.json').toString()) : {
|
||||||
groups: { core: { contract: 1, eager: true, files: [...bytes.keys()].sort() } },
|
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 (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 (index !== null) bytes.set('/index.html', Buffer.from(index));
|
||||||
if (sw !== null) bytes.set('/sw.js', Buffer.from(sw));
|
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) {
|
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;
|
const current = hashing ? manifest.files[path]?.h : manifest.buildTag;
|
||||||
if (version !== undefined) {
|
if (version !== undefined) {
|
||||||
if (version === current) return 'public, max-age=31536000, immutable';
|
if (version === current) return 'public, max-age=31536000, immutable';
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ registerCatalogRoutes(app, { resolveListener: recommendationListener });
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// GET /api/version
|
// 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.
|
// Clients poll this to detect when a new build is live and prompt a reload.
|
||||||
app.get('/api/manifest', (c) => {
|
app.get('/api/manifest', (c) => {
|
||||||
const etag = `"${BUILD_TAG}"`;
|
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
|
// 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.
|
// pick up the substituted value rather than a browser-cached stale copy.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
let _swSource = null;
|
const _swSource = assets.swSource;
|
||||||
app.get('/sw.js', (c) => {
|
app.get('/sw.js', (c) => {
|
||||||
if (!_swSource) {
|
if (_swSource === null) return c.text('Service worker not found', 404);
|
||||||
try {
|
|
||||||
_swSource = readFileSync('./public/sw.js', 'utf8');
|
|
||||||
} catch {
|
|
||||||
return c.text('Service worker not found', 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inject the build tag: replace the whole fallback expression with the
|
// Inject the build tag: replace the whole fallback expression with the
|
||||||
// real value. Matched by REGEX, not an exact string — an exact match broke
|
// 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'),
|
// the moment the fallback literal in sw.js was bumped ('v1.0.3' → 'v1.0.4'),
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('static delivery characterization', () => {
|
|||||||
expect(fallback.buildTag).toBe('dev-build');
|
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 index = await fetch(`${server.base}/`);
|
||||||
const html = await index.text();
|
const html = await index.text();
|
||||||
const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json();
|
const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json();
|
||||||
@@ -123,7 +123,6 @@ describe('static delivery characterization', () => {
|
|||||||
expect(await notModified.text()).toBe('');
|
expect(await notModified.text()).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => {
|
test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => {
|
||||||
const response = await fetch(`${server.base}/api/manifest`);
|
const response = await fetch(`${server.base}/api/manifest`);
|
||||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
@@ -163,6 +162,9 @@ describe('static delivery characterization', () => {
|
|||||||
expect(html).toContain('href="/root.css"');
|
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=${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}/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(); }
|
} finally { await stopServer(server); server = await startServer(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,6 +192,20 @@ describe('static delivery characterization', () => {
|
|||||||
server = await startServer();
|
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 () => {
|
test('SPA routes fall back to the stamped index', async () => {
|
||||||
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
for (const path of ['/search', '/playlist/x']) {
|
for (const path of ['/search', '/playlist/x']) {
|
||||||
|
|||||||
Reference in New Issue
Block a user