From bc472d8295974bc8a8690b752899eaf67bddb7fe Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Thu, 24 Sep 2026 16:30:24 +0200 Subject: [PATCH] fix(nginx): cache hashed assets as immutable, revalidate the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every asset is served from a ^~ prefix location, and ^~ skips regex locations, so the `location ~* \.(css|js|…)$` long-cache block never ran: hashed _astro/ files went out with only `no-transform` under /knowledge-base/ and no Cache-Control at all under /__wf/knowledge-base/. A map on $request_uri now picks the policy per asset class, applied in both prefix locations (each re-including kb-headers.conf): - _astro/ and the content-addressed _kb-inline/ scripts: public, max-age=31536000, immutable, no-transform - everything else (HTML, style.css, sub-app assets at stable names): no-cache, no-transform — revalidated by ETag, 304 when unchanged Not `always`, so a 404 under _astro/ is never cached for a year. The dead regex location is removed. The Express mirror serves the same map; nginx-config.spec asserts the two agree, container.spec asserts every class against the real image. Closes #66 Co-Authored-By: Claude Opus 5.5 (1M context) --- nginx.conf | 40 ++++++++++++++++------ tests/container.spec.js | 68 +++++++++++++++++++++++++++++++++++--- tests/fragment-server.mjs | 26 +++++++++++++-- tests/nginx-config.spec.js | 35 ++++++++++++++++++++ tests/standalone.spec.js | 15 +++++++++ 5 files changed, 165 insertions(+), 19 deletions(-) diff --git a/nginx.conf b/nginx.conf index 78bb401..1e281b2 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,3 +1,27 @@ +# ── Cache-Control by asset class ────────────────────────────────────────────── +# Every asset is served from a ^~ prefix location below, and ^~ stops nginx from +# evaluating regex locations — so a caching policy has to be set inside those +# locations, not in a `location ~* \.(css|js)$` that can never be reached. +# +# Keyed on $request_uri, not $uri: add_header is evaluated after the prefix +# rewrite, when $uri has already lost /knowledge-base/. +# +# immutable — only names that change when their bytes do: Astro's hashed +# _astro/ output and the build's content-addressed _kb-inline/ scripts +# no-cache — everything else: HTML, style.css (a stable name pages and +# external consumers reference literally) and sub-app assets, which +# keep whatever names their generator gave them. Revalidated with +# the ETag nginx sends, so an unchanged file is a 304, not a download. +# +# no-transform rides on both: an intermediate proxy (the web-fragments +# FragmentGateway) must not re-encode the body, or it leaks a Content-Encoding +# header the browser then fails to decode. +map $request_uri $kb_cache_control { + default "no-cache, no-transform"; + ~^/(__wf/)?knowledge-base/_astro/ "public, max-age=31536000, immutable, no-transform"; + "~^/(__wf/)?knowledge-base/[^?]*/_kb-inline/[0-9a-f]{16}\.js(\?|$)" "public, max-age=31536000, immutable, no-transform"; +} + server { listen 8080; server_name _; @@ -34,13 +58,6 @@ server { return 204; } - # ── Long cache for immutable assets (hashed filenames) ─────────────────── - location ~* \.(css|js|woff2?|ttf|eot|ico|svg|png|jpg|gif|webp)$ { - include /etc/nginx/kb-headers.conf; - expires 1y; - add_header Cache-Control "public, immutable"; - } - # ── Health check endpoint ──────────────────────────────────────────────── location = /healthz { access_log off; @@ -56,7 +73,10 @@ server { # Rewrite strips the /__wf/knowledge-base prefix so try_files resolves against # the server root (e.g. /__wf/knowledge-base/style.css → /style.css). location ^~ /__wf/knowledge-base/ { + include /etc/nginx/kb-headers.conf; rewrite ^/__wf/knowledge-base/(.*)$ /$1 break; + # Not `always`: a 404 must not be cached for a year under an _astro/ name. + add_header Cache-Control $kb_cache_control; try_files $uri =404; } @@ -66,10 +86,8 @@ server { location ^~ /knowledge-base/ { include /etc/nginx/kb-headers.conf; rewrite ^/knowledge-base/(.*)$ /$1 break; - # Tell any intermediate proxy (e.g. web-fragments FragmentGateway) not to - # transcode/re-encode this response. Prevents Content-Encoding header - # leakage when the gateway auto-decompresses but still forwards the header. - add_header Cache-Control "no-transform" always; + # Per asset class — see the map at the top of this file. + add_header Cache-Control $kb_cache_control; try_files $uri $uri/index.html =404; } diff --git a/tests/container.spec.js b/tests/container.spec.js index 9f353a0..96865af 100644 --- a/tests/container.spec.js +++ b/tests/container.spec.js @@ -176,11 +176,69 @@ test.describe('response headers', () => { expect(res.headers()['content-security-policy']).toBeTruthy(); }); - test('knowledge-base responses carry Cache-Control: no-transform', async ({ request }) => { - // Stops an intermediate proxy (the FragmentGateway) re-encoding the body and - // leaking a Content-Encoding header the browser then fails to decode. - const res = await request.get('/knowledge-base/'); - expect(res.headers()['cache-control']).toContain('no-transform'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Caching policy per asset class +// +// Both prefixes are ^~ locations, which skip regex locations — a policy set +// anywhere but inside them is never reached. Only the real nginx settles that. +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('caching', () => { + const IMMUTABLE = /^public, max-age=31536000, immutable, no-transform$/; + const REVALIDATE = /^no-cache, no-transform$/; + + /** A URL of each immutable class, taken from what the pages actually reference. */ + async function hashedUrls(request) { + const landing = await (await request.get('/knowledge-base/')).text(); + const astro = landing.match(/(?:src|href)="(\/knowledge-base\/_astro\/[^"]+)"/)?.[1]; + const admin = await (await request.get('/knowledge-base/user-guide/admin/')).text(); + const inline = admin.match(/src="(\/knowledge-base\/user-guide\/_kb-inline\/[0-9a-f]{16}\.js)"/)?.[1]; + expect(astro, 'the landing page references no _astro/ asset').toBeTruthy(); + expect(inline, 'user-guide/admin references no hoisted script').toBeTruthy(); + return { astro, inline }; + } + + test('hashed assets are cached for a year as immutable, under both prefixes', async ({ request }) => { + const { astro, inline } = await hashedUrls(request); + for (const path of [astro, inline, astro.replace(/^\//, '/__wf/'), inline.replace(/^\//, '/__wf/')]) { + const res = await request.get(path); + expect(res.status(), path).toBe(200); + expect(res.headers()['cache-control'], path).toMatch(IMMUTABLE); + } + }); + + // style.css is the one knowledge base asset that cannot be content-addressed: + // external consumers fetch it by that name. Immutable would pin a stale copy + // for a year after a deploy. + for (const [label, path] of [ + ['the landing page', '/knowledge-base/'], + ['a sub-app page', '/knowledge-base/user-guide/'], + ['the no-trailing-slash path', '/knowledge-base'], + ['style.css', '/knowledge-base/style.css'], + ['fragment-prefixed style.css', '/__wf/knowledge-base/style.css'], + ['a sub-app asset with a stable name', '/knowledge-base/user-guide/docs/style.css'], + ]) { + test(`${label} is revalidated on every use`, async ({ request }) => { + const res = await request.get(path, { maxRedirects: 0 }); + expect(res.status()).toBe(200); + expect(res.headers()['cache-control']).toMatch(REVALIDATE); + }); + } + + test('a missing hashed asset is not cached as immutable', async ({ request }) => { + const res = await request.get('/knowledge-base/_astro/no-such-file.DEADBEEF.js'); + expect(res.status()).toBe(404); + expect(res.headers()['cache-control'] ?? '').not.toContain('immutable'); + }); + + test('an unchanged asset revalidates to a 304', async ({ request }) => { + const first = await request.get('/knowledge-base/style.css'); + const etag = first.headers()['etag']; + expect(etag, 'nginx sends an ETag for static files').toBeTruthy(); + const again = await request.get('/knowledge-base/style.css', { headers: { 'If-None-Match': etag } }); + expect(again.status()).toBe(304); }); }); diff --git a/tests/fragment-server.mjs b/tests/fragment-server.mjs index a9cce11..6e2baae 100644 --- a/tests/fragment-server.mjs +++ b/tests/fragment-server.mjs @@ -51,13 +51,27 @@ const CSP = [ "object-src 'none'", ].join('; '); +/** + * nginx: map $request_uri $kb_cache_control — the caching policy per asset + * class. Kept in step with nginx.conf; tests/nginx-config.spec.js asserts the + * two agree on every value. + */ +const IMMUTABLE = 'public, max-age=31536000, immutable, no-transform'; +const REVALIDATE = 'no-cache, no-transform'; +const IMMUTABLE_PATHS = [ + /^\/(__wf\/)?knowledge-base\/_astro\//, + /^\/(__wf\/)?knowledge-base\/[^?]*\/_kb-inline\/[0-9a-f]{16}\.js(\?|$)/, +]; +const cacheControl = (requestUri) => + IMMUTABLE_PATHS.some((re) => re.test(requestUri)) ? IMMUTABLE : REVALIDATE; + const app = express(); // nginx: include /etc/nginx/kb-headers.conf — the shared CORS + security set. // Applied to every response, which is what the nginx config does now that each // location declaring an add_header re-includes the snippet. Kept in sync with // nginx.headers.conf; tests/nginx-config.spec.js asserts the nginx side. -app.use((_req, res, next) => { +app.use((req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', @@ -70,6 +84,12 @@ app.use((_req, res, next) => { // breaks reframing fails silently, and this is where that would show up. 'Content-Security-Policy': CSP, }); + // Only on the prefixed locations, as nginx sets it. An error response drops + // it again: Express's final 404 handler clears every header, much as nginx's + // add_header without `always` skips one. + if (req.url.startsWith(`/${PREFIX}`) || req.url.startsWith(`/__wf/${PREFIX}/`)) { + res.set('Cache-Control', cacheControl(req.url)); + } next(); }); @@ -105,7 +125,7 @@ app.use((req, _res, next) => { // production server does not have. app.use( `/${PREFIX}`, - express.static(DIST, { extensions: ['html'], index: 'index.html', redirect: false }), + express.static(DIST, { extensions: ['html'], index: 'index.html', redirect: false, cacheControl: false }), ); // nginx: the `$uri/index.html` half of try_files — a directory path resolves to @@ -115,7 +135,7 @@ app.use(`/${PREFIX}`, (req, res, next) => { // Never serve outside dist/, whatever the request path claims. if (!candidate.startsWith(DIST)) return next(); if (!existsSync(candidate)) return next(); - res.sendFile(candidate); + res.sendFile(candidate, { cacheControl: false }); }); app.get('/healthz', (_req, res) => res.type('text/plain').send('ok')); diff --git a/tests/nginx-config.spec.js b/tests/nginx-config.spec.js index 59d7762..3580cc0 100644 --- a/tests/nginx-config.spec.js +++ b/tests/nginx-config.spec.js @@ -126,6 +126,41 @@ test.describe('nginx.conf header inheritance', () => { expect(mirrorCsp, 'the mirror and nginx must serve the same policy').toBe(nginxCsp); }); + test('both prefix locations set Cache-Control from the per-asset-class map', () => { + // Every asset is served from one of these ^~ locations, and ^~ skips regex + // locations — a caching policy anywhere else is never reached. + const blocks = locationBlocks(uncomment(CONF)); + for (const selector of ['^~ /knowledge-base/', '^~ /__wf/knowledge-base/']) { + const block = blocks.find((b) => b.selector === selector); + expect(block, `${selector} location block`).toBeTruthy(); + expect(block.body).toMatch(/add_header\s+Cache-Control\s+\$kb_cache_control\s*;/); + expect(block.body, 'without `always`: an error response must not be cached as immutable') + .not.toMatch(/add_header\s+Cache-Control[^;]*\balways\b/); + } + const unreachable = blocks.filter((b) => /^~/.test(b.selector) && /Cache-Control|expires/.test(b.body)); + expect(unreachable.map((b) => b.selector), 'a regex location never runs under the ^~ prefixes').toEqual([]); + }); + + test('the cache map and the test mirror agree on every class', () => { + const map = uncomment(CONF).match(/map\s+\$request_uri\s+\$kb_cache_control\s*\{([\s\S]*?)\n\}/)?.[1]; + expect(map, 'no $kb_cache_control map in nginx.conf').toBeTruthy(); + const entries = [...map.matchAll(/^\s*("?)(\S+?)\1\s+"([^"]+)";/gm)].map((m) => [m[2], m[3]]); + const nginxDefault = entries.find(([k]) => k === 'default')?.[1]; + const nginxImmutable = entries.filter(([k]) => k !== 'default'); + + const mirror = readFileSync(join(ROOT, 'tests', 'fragment-server.mjs'), 'utf8'); + const constant = (name) => mirror.match(new RegExp(`const ${name} = '([^']+)'`))?.[1]; + const mirrorPatterns = [...(mirror.match(/const IMMUTABLE_PATHS = \[([\s\S]*?)\];/)?.[1] ?? '') + .matchAll(/^\s*\/(.+)\/,\r?$/gm)].map((m) => m[1].replace(/\\\//g, '/')); + + expect(nginxDefault).toBe(constant('REVALIDATE')); + expect(new Set(nginxImmutable.map(([, v]) => v))).toEqual(new Set([constant('IMMUTABLE')])); + expect(nginxImmutable.map(([k]) => k.replace(/^~/, ''))).toEqual(mirrorPatterns); + for (const policy of [nginxDefault, constant('IMMUTABLE')]) { + expect(policy, 'the gateway must not re-encode any response').toContain('no-transform'); + } + }); + test('healthz sets its content type with default_type, not a post-return add_header', () => { const healthz = locationBlocks(uncomment(CONF)).find((b) => b.selector === '= /healthz'); expect(healthz, '/healthz location block').toBeTruthy(); diff --git a/tests/standalone.spec.js b/tests/standalone.spec.js index abca669..01d3d07 100644 --- a/tests/standalone.spec.js +++ b/tests/standalone.spec.js @@ -103,6 +103,21 @@ test.describe('HTTP headers', () => { expect(res.status()).toBe(200); expect(res.headers()['location']).toBeUndefined(); }); + + // The mirror's half of nginx's per-asset-class Cache-Control map; + // tests/container.spec.js asserts the shipped config. + test('caches hashed assets as immutable and revalidates everything else', async ({ request }) => { + const landing = await (await request.get('/knowledge-base/')).text(); + const astro = landing.match(/src="(\/knowledge-base\/_astro\/[^"]+)"/)?.[1]; + expect(astro, 'the landing page references no _astro/ asset').toBeTruthy(); + for (const path of [astro, astro.replace(/^\//, '/__wf/')]) { + expect((await request.get(path)).headers()['cache-control'], path) + .toBe('public, max-age=31536000, immutable, no-transform'); + } + for (const path of ['/knowledge-base/', '/knowledge-base/user-guide', '/knowledge-base/style.css', '/__wf/knowledge-base/style.css']) { + expect((await request.get(path)).headers()['cache-control'], path).toBe('no-cache, no-transform'); + } + }); }); // ─────────────────────────────────────────────────────────────────────────────