From ada2309721726aec9582110c9fc8c9c4e64d3e23 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:59:39 +0200 Subject: [PATCH] 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) --- src/web/server.ts | 11 ++++++++++- test/static-cache-headers.test.ts | 14 +++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/web/server.ts b/src/web/server.ts index f388268c..c26366e3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -964,7 +964,7 @@ export class WebServer extends EventEmitter { // `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and // 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. - setHeaders: (reply) => { + setHeaders: (reply, filePath) => { // ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this // 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() @@ -973,6 +973,15 @@ export class WebServer extends EventEmitter { // no-store` its route asks for — a service worker that can never update. // So: a route that already decided keeps its answer. 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'); }, }); diff --git a/test/static-cache-headers.test.ts b/test/static-cache-headers.test.ts index eec9a2fc..c92546b0 100644 --- a/test/static-cache-headers.test.ts +++ b/test/static-cache-headers.test.ts @@ -11,7 +11,8 @@ * * 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 - * 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. * * 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'); }); + 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 () => { // 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