Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions nginx.conf
Original file line number Diff line number Diff line change
@@ -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 _;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
68 changes: 63 additions & 5 deletions tests/container.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
26 changes: 23 additions & 3 deletions tests/fragment-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
});

Expand Down Expand Up @@ -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
Expand All @@ -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'));
Expand Down
35 changes: 35 additions & 0 deletions tests/nginx-config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions tests/standalone.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
});
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading