fix(web): serve the public JSON data files no-cache, not immutable

The static plugin gave every file under public/ a year of `immutable`,
including the JSON the page fetches at runtime: whats-new.json (rewritten
every release), the new security-notices.json (edited by hand) and
manifest.json. None of them is content-hashed or `?v=`-busted, so a
browser could keep last release's copy, and a security notice added in a
later release might never reach it. A `.json` file now revalidates
(`no-cache`); every other static asset keeps its immutable year, and a
route that set its own Cache-Control still wins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:59:39 +02:00
1 parent 0ab71d61d1
commit ada2309721
2 files changed
+23 -2

No files matched your search

+10 -1
View File
@@ -964,7 +964,7 @@ export class WebServer extends EventEmitter {
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and // `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static // NOT `res.setHeader()`. A v9-style body throws TypeError on every static
// request, which is every page load. See the v10.0.0 release notes. // request, which is every page load. See the v10.0.0 release notes.
setHeaders: (reply) => { setHeaders: (reply, filePath) => {
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this // ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
// callback wrote to the raw response and Fastify's staged reply headers then // callback wrote to the raw response and Fastify's staged reply headers then
// overwrote it, so a route that set its own Cache-Control before .sendFile() // overwrote it, so a route that set its own Cache-Control before .sendFile()
@@ -973,6 +973,15 @@ export class WebServer extends EventEmitter {
// no-store` its route asks for — a service worker that can never update. // no-store` its route asks for — a service worker that can never update.
// So: a route that already decided keeps its answer. // So: a route that already decided keeps its answer.
if (reply.getHeader('Cache-Control') !== undefined) return; if (reply.getHeader('Cache-Control') !== undefined) return;
// A JSON file here is DATA the page fetches at runtime (whats-new.json is rewritten every
// release, security-notices.json by hand, manifest.json), never content-hashed and never
// `?v=`-busted, so it must revalidate. Under `immutable` a browser may keep last release's
// copy for a year, and the page's `fetch(..., { cache: 'no-cache' })` is the only thing
// asking it not to; the response header is what every browser honours.
if (filePath.endsWith('.json')) {
reply.header('Cache-Control', 'no-cache');
return;
}
reply.header('Cache-Control', 'public, max-age=31536000, immutable'); reply.header('Cache-Control', 'public, max-age=31536000, immutable');
}, },
}); });
+13 -1
View File
@@ -11,7 +11,8 @@
* *
* That contract is load-bearing: assets are served `immutable` for a year, and * That contract is load-bearing: assets are served `immutable` for a year, and
* `index.html` must revalidate every time or a deploy leaves browsers on stale * `index.html` must revalidate every time or a deploy leaves browsers on stale
* markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static * markup (see `cacheBustAssets` in server.ts). The JSON data files the page fetches at
* runtime (whats-new.json, security-notices.json) revalidate too. No HTML is left behind the static
* plugin (every page has its own route), so the HTML half is asserted on the route. * plugin (every page has its own route), so the HTML half is asserted on the route.
* *
* These tests drive a REAL WebServer on purpose. Asserting against an inline * These tests drive a REAL WebServer on purpose. Asserting against an inline
@@ -73,6 +74,17 @@ describe('static asset Cache-Control headers', () => {
expect(res.headers.get('cache-control')).toBe('no-cache, no-store'); expect(res.headers.get('cache-control')).toBe('no-cache, no-store');
}); });
it('makes the runtime JSON data files revalidate instead of pinning them for a year', async () => {
// whats-new.json is rewritten every release and security-notices.json by hand; neither is
// content-hashed or `?v=`-busted, so an `immutable` copy could outlive the release that
// replaced it, and a security notice added later would never reach that browser.
for (const file of ['security-notices.json', 'whats-new.json', 'manifest.json']) {
const res = await get(`${baseUrl}/${file}`);
expect(res.status, file).toBe(200);
expect(res.headers.get('cache-control'), file).toBe('no-cache');
}
});
it('serves the rendered index with no-cache too, via its own route', async () => { it('serves the rendered index with no-cache too, via its own route', async () => {
// Different code path from the above, same contract: index.html is generated per // Different code path from the above, same contract: index.html is generated per
// request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would // request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would