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
123 changes: 123 additions & 0 deletions integrationTests/components/static-cache-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Static plugin cache-header options (`maxAge`, `immutable`, `cacheControl`).
*
* The plugin reads these options live from the component config on every request, so a single
* instance covers all variants: assert the default, then rewrite config.yaml via
* set_component_file and poll until the next request reflects the new policy.
*
* Run: npm run test:integration -- "integrationTests/components/static-cache-headers.test.ts"
*/
import { suite, test, before, after } from 'node:test';
import { strictEqual, ok } from 'node:assert';
import { resolve } from 'node:path';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
// @ts-expect-error utils/client.mjs has no type declarations; runtime resolves fine
import { createApiClient } from '../apiTests/utils/client.mjs';

const FIXTURE_PATH = resolve(import.meta.dirname, '../fixtures/static-cache-headers');
const PROJECT = 'static-cache-headers';

suite('static plugin cache-header options', (ctx: ContextWithHarper) => {
let client: any;

before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH);
client = createApiClient(ctx.harper);
});

after(async () => {
await teardownHarper(ctx);
});

async function getPath(path: string): Promise<Response> {
const res = await fetch(new URL(path, ctx.harper.httpURL));
strictEqual(res.status, 200);
await res.text(); // drain
return res;
}

async function getCss(): Promise<Response> {
return getPath('/test.css');
}

async function setStaticConfig(yaml: string): Promise<void> {
await client.req().send({ operation: 'set_component_file', project: PROJECT, file: 'config.yaml', payload: yaml });
}

/**
* Set the config and poll until the served Cache-Control matches. The config is re-sent
* periodically during the poll: a config write landing milliseconds after the previous one can
* lose its chokidar change event (watcher re-establishment race — see #1747), and the reload
* plumbing is not what this suite tests; the header options are.
*/
async function applyAndWaitForCacheControl(
yaml: string,
expected: string | null,
timeoutMs = 20_000
): Promise<Response> {
await setStaticConfig(yaml);
const deadline = Date.now() + timeoutMs;
let last: string | null = null;
let sinceResend = 0;
while (Date.now() < deadline) {
const res = await getCss();
last = res.headers.get('cache-control');
if (last === expected) return res;
if (++sinceResend >= 10) {
sinceResend = 0;
await setStaticConfig(yaml);
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Cache-Control never became ${JSON.stringify(expected)}; last seen: ${JSON.stringify(last)}`);
}

test('default: public, max-age=0 with ETag/Last-Modified', async () => {
const res = await getCss();
strictEqual(res.headers.get('cache-control'), 'public, max-age=0');
ok(res.headers.get('etag'), 'should emit an ETag');
ok(res.headers.get('last-modified'), 'should emit Last-Modified');
});

test('maxAge in seconds', async () => {
await applyAndWaitForCacheControl("static:\n files: 'web/**'\n maxAge: 300\n", 'public, max-age=300');
});

test('maxAge as duration string + immutable', async () => {
await applyAndWaitForCacheControl(
"static:\n files: 'web/**'\n maxAge: 1d\n immutable: true\n",
'public, max-age=86400, immutable'
);
});

test('cacheControl string overrides maxAge/immutable', async () => {
await applyAndWaitForCacheControl(
"static:\n files: 'web/**'\n maxAge: 300\n cacheControl: 'public, max-age=60, s-maxage=3600'\n",
'public, max-age=60, s-maxage=3600'
);
});

test('cacheControl: false suppresses the header', async () => {
await applyAndWaitForCacheControl("static:\n files: 'web/**'\n cacheControl: false\n", null);
});

test('cacheOverrides: per-file policy layered over the top-level defaults', async () => {
// Long-lived immutable default (the hashed-asset case); index.html gets its own short,
// revalidating window — Dawson's motivating example.
const yaml =
'static:\n' +
" files: 'web/**'\n" +
' maxAge: 1y\n' +
' immutable: true\n' +
' cacheOverrides:\n' +
" 'index.html': { cacheControl: 'public, max-age=0, stale-while-revalidate=60' }\n" +
' "*.css": { maxAge: 60 }\n';
// Poll on the css file until the override lands (confirms the config reload applied). The
// '*.css' override sets only maxAge, so immutable is inherited from the top level — partial merge.
await applyAndWaitForCacheControl(yaml, 'public, max-age=60, immutable');
// index.html, matched by basename on the directory-index (`/`) serve, gets its full-string
// override, which takes precedence over the inherited maxAge/immutable.
const index = await getPath('/');
strictEqual(index.headers.get('cache-control'), 'public, max-age=0, stale-while-revalidate=60');
});
});
2 changes: 2 additions & 0 deletions integrationTests/fixtures/static-cache-headers/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
static:
files: 'web/**'
9 changes: 9 additions & 0 deletions integrationTests/fixtures/static-cache-headers/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<title>static cache headers fixture</title>
</head>
<body>
index
</body>
</html>
3 changes: 3 additions & 0 deletions integrationTests/fixtures/static-cache-headers/web/test.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
body {
color: teal;
}
116 changes: 115 additions & 1 deletion server/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { realpathSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { Scope } from '../components/Scope';
import { resolveBaseURLPath } from '../components/resolveBaseURLPath.ts';
import { convertToMS } from '../utility/common_utils.ts';
import { isMatch } from 'micromatch';
import send from 'send';

/**
Expand All @@ -12,6 +14,31 @@ import send from 'send';
* - `index`: If enabled, it will serve `index.html` files from directories.
* - `extensions`: An array of file extensions to try when serving files. If a file is not found, it will try appending each extension in order. For example, if set to `['html'], and the request is `/page`, it will try `/page.html` if `/page` is not found.
* - `fallthrough`: If true, it will fall through to the next handler if the file is not found. If false, it will return a 404 error.
* - `maxAge`: Freshness lifetime for served files — a number of seconds or a duration string
* (`'5m'`, `'1d'`). Emitted as `Cache-Control: public, max-age=<seconds>`. Defaults to 0
* (revalidate every request via the ETag/Last-Modified that are always emitted).
* - `immutable`: If true, adds the `immutable` directive to `Cache-Control` — for content-hashed
* assets that never change under the same URL. Requires `maxAge` to be meaningful.
* - `cacheControl`: Full `Cache-Control` override string (takes precedence over `maxAge`/`immutable`),
* or `false` to suppress the header entirely. Static files are served before authentication, so
* they are public by construction — do not put per-user content behind this handler.
* - `cacheOverrides`: A map of glob pattern → partial cache options (`maxAge` / `immutable` /
* `cacheControl`), letting specific files opt out of the top-level defaults. The typical case is
* long-lived `immutable` defaults for content-hashed assets while `index.html` gets a short window
* or `stale-while-revalidate`. Patterns are matched (via `micromatch`, same engine as `files`)
* against the mount-relative URL path **and** the served file's basename — so `index.html` also
* targets the directory-index (`/`) serve. Entries are tested in config order and the first match
* wins; each is a partial (keys present replace the default, absent keys inherit), with the same
* `cacheControl`-beats-`maxAge`/`immutable` precedence as the top level. Example:
* ```yaml
* static:
* files: 'web/**'
* maxAge: 1y

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1y is not a recognised suffix in convertToMS — it silently produces max-age=1.

convertToMS (utility/common_utils.ts) handles 'd'/'D', 'h'/'H', 'm', 'M' (month) — but has no 'y' case. When YAML parses maxAge: 1y it delivers the string "1y". parseFloat("1y") is 1, the switch falls through without a multiplier, and convertToMS returns 1 × 1000 = 1000 ms. send then emits Cache-Control: public, max-age=1 — one second, not one year.

Because parseFloat("1y") is a finite number (not NaN), the NaN guard below (Number.isNaN(maxAgeMs)) doesn't catch it. No error is raised; the setting silently misbehaves.

The same value appears in the cacheOverrides integration test (case 6), but every file in that fixture has an override that takes precedence over the top-level maxAge, so the test passes without ever exercising the top-level 1y value.

Suggested fix: replace 1y in this example (and the test YAML at integrationTests/components/static-cache-headers.test.ts:108) with a supported format such as 365d or 31536000. Alternatively, add a 'y' case to convertToMS.

Suggested change
* maxAge: 1y
* maxAge: 365d

* immutable: true
* cacheOverrides:
* 'index.html': { cacheControl: 'public, max-age=0, stale-while-revalidate=60' }
* '*.html': { maxAge: 5m, immutable: false }
* ```
* - `notFound`: Can be specified as a string to serve a custom 404 page, or an object with `file` and `statusCode` properties to serve a custom file with a specific status code. This is useful for hosting SPAs that use client-side routing. Make sure to set `fallthrough` to `false`!
* - `before` / `after`: Position this handler in the HTTP middleware chain relative to another named
* handler. By default the handler runs `before: 'authentication'` — and therefore before the REST
Expand All @@ -28,6 +55,93 @@ import send from 'send';
* Updates to the `files` option will clear the in-memory maps and allow them to regenerate based on the new configuration (since the default EntryHandler will regenerate anyways).
* Updates to `urlPath` request a restart: the HTTP route mount is registered once at load and cannot be re-registered on a live server (#1583).
*/
/**
* Resolve the effective cache-header inputs for a given served file: the live top-level
* `maxAge`/`immutable`/`cacheControl` options, with the first matching `cacheOverrides` entry layered
* on top (a partial — keys present replace the default, absent keys inherit). Patterns are matched
* (via `micromatch`, same engine as `files`) against the mount-relative URL path and the file's
* basename, so `index.html` also targets the directory-index (`/`) serve.
*/
function resolveCacheOptions(scope: Scope, urlKey: string, basename: string) {
let maxAge = scope.options.get(['maxAge']);
let immutable = scope.options.get(['immutable']);
let cacheControl = scope.options.get(['cacheControl']);

const overrides = scope.options.get(['cacheOverrides']);
if (overrides !== undefined) {
if (typeof overrides !== 'object' || overrides === null || Array.isArray(overrides)) {
throw new Error(`Invalid cacheOverrides option: ${overrides}. Must be a map of glob pattern to cache options.`);
}
for (const pattern of Object.keys(overrides)) {
if (isMatch(urlKey, pattern) || isMatch(basename, pattern)) {
const override = overrides[pattern];
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
throw new Error(
`Invalid cacheOverrides['${pattern}'] value: ${override}. Must be an object with maxAge/immutable/cacheControl.`
);
}
// partial merge: only keys present in the override replace the top-level default
if ('maxAge' in override) maxAge = override.maxAge;
if ('immutable' in override) immutable = override.immutable;
if ('cacheControl' in override) cacheControl = override.cacheControl;
break; // first match wins
}
}
}

immutable = immutable ?? false;
if (maxAge !== undefined && typeof maxAge !== 'number' && typeof maxAge !== 'string') {
throw new Error(`Invalid maxAge option: ${maxAge}. Must be a number of seconds or a duration string like '5m'.`);
}
const maxAgeMs = maxAge === undefined ? 0 : convertToMS(maxAge);
if (Number.isNaN(maxAgeMs)) {
throw new Error(`Invalid maxAge option: ${maxAge}. Must be a number of seconds or a duration string like '5m'.`);
}
Comment thread
kriszyp marked this conversation as resolved.
if (typeof immutable !== 'boolean') {
throw new Error(`Invalid immutable option: ${immutable}. Must be a boolean.`);
}
if (cacheControl !== undefined && typeof cacheControl !== 'string' && cacheControl !== false) {
throw new Error(`Invalid cacheControl option: ${cacheControl}. Must be a string or false.`);
}
const customCacheControl = typeof cacheControl === 'string' ? cacheControl : undefined;
return { cacheControlDisabled: cacheControl === false, maxAgeMs, immutable, customCacheControl };
}

/**
* Serve a file through `send`, applying the plugin's cache-header options (read live from the
* component config, like `fallthrough`, and layered with any matching `cacheOverrides` entry).
* `cacheControl` as a string overrides `maxAge`/`immutable` via send's `headers` event;
* `cacheControl: false` suppresses the header entirely. Applied only to the main file serve — the
* `notFound` fallback keeps send's default `max-age=0`, which is the right policy for SPA index
* fallbacks.
*/
function serveFile(req, path: string, scope: Scope) {
// The staticFiles map keys are the mount-relative URL path (leading slash stripped for matching);
// for a directory-index serve req.pathname is the directory, so also match the served basename.
const urlKey = typeof req.pathname === 'string' ? req.pathname.replace(/^\//, '') : '';
const basename = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1);
const { cacheControlDisabled, maxAgeMs, immutable, customCacheControl } = resolveCacheOptions(
scope,
urlKey,
basename
);

const stream = send(req, path, {
// suppress send's own header when we set a full override below (or when disabled)
cacheControl: cacheControlDisabled || customCacheControl !== undefined ? false : true,
maxAge: maxAgeMs,
immutable,
});
// an empty-string override intentionally behaves like `false` (send's header is suppressed
// above and no override is written)
if (customCacheControl) {
Comment thread
kriszyp marked this conversation as resolved.
stream.on('headers', (response) => {
response.setHeader('Cache-Control', customCacheControl);
});
}
return stream;
}

export function handleApplication(scope: Scope) {
// in-memory map of static files
// keys are the URL paths relative to the mount base, values are the absolute paths to the files
Expand Down Expand Up @@ -218,7 +332,7 @@ export function handleApplication(scope: Scope) {
// The benefit to using `send` is that it handles a lot of edge cases and headers for us.
return {
handlesHeaders: true,
body: send(req, realpathSync(staticFile)),
body: serveFile(req, realpathSync(staticFile), scope),
};
}

Expand Down
19 changes: 19 additions & 0 deletions unitTests/utility/common_utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -566,4 +566,23 @@ describe('Test common_utils module', () => {
const c = cu_rewire.ms_to_time(1672345634534);
expect(c).to.equal('52y 27d 20h 27m 14s');
});

describe('Test convertToMS', () => {
it('bare number is treated as seconds', () => {
expect(cu.convertToMS(5)).to.equal(5000);
});
it('duration-string units (note case-sensitive M=month vs m=minute)', () => {
expect(cu.convertToMS('5m')).to.equal(300000); // minutes
expect(cu.convertToMS('2h')).to.equal(7200000);
expect(cu.convertToMS('1d')).to.equal(86400000);
expect(cu.convertToMS('1M')).to.equal(86400 * 30 * 1000); // 30-day month
});
it('year suffix (y/Y) resolves to 365 days', () => {
expect(cu.convertToMS('1y')).to.equal(86400 * 365 * 1000);
expect(cu.convertToMS('1Y')).to.equal(86400 * 365 * 1000);
});
it('non-numeric string yields NaN (so callers can reject it)', () => {
expect(Number.isNaN(cu.convertToMS('abc'))).to.equal(true);
});
});
});
5 changes: 5 additions & 0 deletions utility/common_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,12 @@ export function convertToMS(interval: any) {
if (typeof interval === 'number') seconds = interval;
if (typeof interval === 'string') {
seconds = parseFloat(interval);
// Note the case-sensitive units: `M` is a (30-day) month, `m` is a minute.
switch (interval.slice(-1)) {
case 'y':
case 'Y':
seconds *= 86400 * 365;
break;
case 'M':
seconds *= 86400 * 30;
break;
Expand Down