diff --git a/server/asset-manifest.js b/server/asset-manifest.js index c9734e2..b7a5104 100644 --- a/server/asset-manifest.js +++ b/server/asset-manifest.js @@ -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'; diff --git a/server/server.js b/server/server.js index d98bcc0..43e9654 100644 --- a/server/server.js +++ b/server/server.js @@ -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'), diff --git a/server/static-delivery.test.js b/server/static-delivery.test.js index 0aafb79..4a9231d 100644 --- a/server/static-delivery.test.js +++ b/server/static-delivery.test.js @@ -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']) {