From 16a3523f9a47175165c431f8480e74826bca2297 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 17:51:38 -0600 Subject: [PATCH 1/6] Configurable cache headers for the static plugin (maxAge, immutable, cacheControl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static handler passed no options to send(), hardcoding Cache-Control: public, max-age=0 — every CDN/edge request revalidated at origin with no way to mark hashed assets immutable. New options (read live from component config, like fallthrough): maxAge (seconds or duration string), immutable, and a full cacheControl override string (or false to suppress). Applies to the main file serve only; the notFound fallback keeps max-age=0, which is right for SPA index fallbacks. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/static-cache-headers.test.ts | 99 +++++++++++++++++++ .../fixtures/static-cache-headers/config.yaml | 2 + .../static-cache-headers/web/test.css | 1 + server/static.ts | 46 ++++++++- 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 integrationTests/components/static-cache-headers.test.ts create mode 100644 integrationTests/fixtures/static-cache-headers/config.yaml create mode 100644 integrationTests/fixtures/static-cache-headers/web/test.css diff --git a/integrationTests/components/static-cache-headers.test.ts b/integrationTests/components/static-cache-headers.test.ts new file mode 100644 index 0000000000..5fdeb9701c --- /dev/null +++ b/integrationTests/components/static-cache-headers.test.ts @@ -0,0 +1,99 @@ +/** + * 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 getCss(): Promise { + const res = await fetch(new URL('/test.css', ctx.harper.httpURL)); + strictEqual(res.status, 200); + await res.text(); // drain + return res; + } + + async function setStaticConfig(yaml: string): Promise { + 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 { + 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); + }); +}); diff --git a/integrationTests/fixtures/static-cache-headers/config.yaml b/integrationTests/fixtures/static-cache-headers/config.yaml new file mode 100644 index 0000000000..14839f7824 --- /dev/null +++ b/integrationTests/fixtures/static-cache-headers/config.yaml @@ -0,0 +1,2 @@ +static: + files: 'web/**' diff --git a/integrationTests/fixtures/static-cache-headers/web/test.css b/integrationTests/fixtures/static-cache-headers/web/test.css new file mode 100644 index 0000000000..932df8c320 --- /dev/null +++ b/integrationTests/fixtures/static-cache-headers/web/test.css @@ -0,0 +1 @@ +body { color: teal; } diff --git a/server/static.ts b/server/static.ts index f555f270f8..9ebd867d7c 100644 --- a/server/static.ts +++ b/server/static.ts @@ -2,6 +2,7 @@ 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 send from 'send'; /** @@ -12,6 +13,14 @@ 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=`. 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. * - `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 @@ -28,6 +37,41 @@ 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). */ +/** + * Serve a file through `send`, applying the plugin's cache-header options (read live from the + * component config, like `fallthrough`). `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) { + const maxAge = scope.options.get(['maxAge']); + const immutable = scope.options.get(['immutable']) ?? false; + const cacheControl = scope.options.get(['cacheControl']); + 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'.`); + } + 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; + const stream = send(req, path, { + // suppress send's own header when we set a full override below (or when disabled) + cacheControl: cacheControl === false || customCacheControl !== undefined ? false : true, + maxAge: maxAge === undefined ? 0 : convertToMS(maxAge), + immutable, + }); + if (customCacheControl) { + 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 @@ -218,7 +262,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), }; } From c289c6a2b1231ea9574f9f858b4fa7c3391b6bcf Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 17:57:07 -0600 Subject: [PATCH 2/6] Fail loudly on malformed maxAge duration string Co-Authored-By: Claude Opus 4.8 (1M context) --- server/static.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/static.ts b/server/static.ts index 9ebd867d7c..7bb961e3f4 100644 --- a/server/static.ts +++ b/server/static.ts @@ -48,7 +48,8 @@ function serveFile(req, path: string, scope: Scope) { const maxAge = scope.options.get(['maxAge']); const immutable = scope.options.get(['immutable']) ?? false; const cacheControl = scope.options.get(['cacheControl']); - if (maxAge !== undefined && typeof maxAge !== 'number' && typeof maxAge !== 'string') { + const maxAgeMs = maxAge === undefined ? 0 : convertToMS(maxAge); + if ((typeof maxAge !== 'number' && typeof maxAge !== 'string' && maxAge !== undefined) || Number.isNaN(maxAgeMs)) { throw new Error(`Invalid maxAge option: ${maxAge}. Must be a number of seconds or a duration string like '5m'.`); } if (typeof immutable !== 'boolean') { @@ -61,7 +62,7 @@ function serveFile(req, path: string, scope: Scope) { const stream = send(req, path, { // suppress send's own header when we set a full override below (or when disabled) cacheControl: cacheControl === false || customCacheControl !== undefined ? false : true, - maxAge: maxAge === undefined ? 0 : convertToMS(maxAge), + maxAge: maxAgeMs, immutable, }); if (customCacheControl) { From 6be5cd4da1ffa17d83199fa43e0bf40f803da125 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 18:20:44 -0600 Subject: [PATCH 3/6] Format fixture css with prettier 3.9 Co-Authored-By: Claude Opus 4.8 (1M context) --- integrationTests/fixtures/static-cache-headers/web/test.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integrationTests/fixtures/static-cache-headers/web/test.css b/integrationTests/fixtures/static-cache-headers/web/test.css index 932df8c320..1fa6a8f242 100644 --- a/integrationTests/fixtures/static-cache-headers/web/test.css +++ b/integrationTests/fixtures/static-cache-headers/web/test.css @@ -1 +1,3 @@ -body { color: teal; } +body { + color: teal; +} From de7f78a1cbf02f1bcae1b8809e59e18687a13e24 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Thu, 9 Jul 2026 18:22:43 -0600 Subject: [PATCH 4/6] Address bot review: validate maxAge type before conversion, document '' cacheControl semantics Co-Authored-By: Claude Opus 4.8 (1M context) --- server/static.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/static.ts b/server/static.ts index 7bb961e3f4..e42175bb17 100644 --- a/server/static.ts +++ b/server/static.ts @@ -48,8 +48,11 @@ function serveFile(req, path: string, scope: Scope) { const maxAge = scope.options.get(['maxAge']); const immutable = scope.options.get(['immutable']) ?? false; const cacheControl = scope.options.get(['cacheControl']); + 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 ((typeof maxAge !== 'number' && typeof maxAge !== 'string' && maxAge !== undefined) || Number.isNaN(maxAgeMs)) { + if (Number.isNaN(maxAgeMs)) { throw new Error(`Invalid maxAge option: ${maxAge}. Must be a number of seconds or a duration string like '5m'.`); } if (typeof immutable !== 'boolean') { @@ -65,6 +68,8 @@ function serveFile(req, path: string, scope: Scope) { maxAge: maxAgeMs, immutable, }); + // an empty-string override intentionally behaves like `false` (send's header is suppressed + // above and no override is written) if (customCacheControl) { stream.on('headers', (response) => { response.setHeader('Cache-Control', customCacheControl); From 117ad1ae429ca3665c9dfe15306541ff28775bb0 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Fri, 10 Jul 2026 15:03:08 -0600 Subject: [PATCH 5/6] Per-file cacheOverrides map for the static plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to Dawson's suggestion on this PR: layer a glob->partial cache options map over the top-level maxAge/immutable/cacheControl defaults, so content-hashed assets can stay `immutable` while index.html gets a short window or stale-while-revalidate. - Entries tested in config order (first match wins); each is a partial — keys present replace the default, absent keys inherit, same cacheControl-beats-maxAge/immutable precedence as the top level. - Patterns matched (micromatch, the 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. - Read live per request; malformed map/entry throws loudly, consistent with the existing option validation. Option reading/validation refactored out of serveFile into resolveCacheOptions(scope, urlKey, basename). Integration suite gains a 6th case + an index.html fixture proving partial-merge, basename match on `/`, and full-string override precedence. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/static-cache-headers.test.ts | 28 ++++++- .../static-cache-headers/web/index.html | 9 ++ server/static.ts | 84 ++++++++++++++++--- 3 files changed, 109 insertions(+), 12 deletions(-) create mode 100644 integrationTests/fixtures/static-cache-headers/web/index.html diff --git a/integrationTests/components/static-cache-headers.test.ts b/integrationTests/components/static-cache-headers.test.ts index 5fdeb9701c..857d7c4b11 100644 --- a/integrationTests/components/static-cache-headers.test.ts +++ b/integrationTests/components/static-cache-headers.test.ts @@ -29,13 +29,17 @@ suite('static plugin cache-header options', (ctx: ContextWithHarper) => { await teardownHarper(ctx); }); - async function getCss(): Promise { - const res = await fetch(new URL('/test.css', ctx.harper.httpURL)); + async function getPath(path: string): Promise { + const res = await fetch(new URL(path, ctx.harper.httpURL)); strictEqual(res.status, 200); await res.text(); // drain return res; } + async function getCss(): Promise { + return getPath('/test.css'); + } + async function setStaticConfig(yaml: string): Promise { await client.req().send({ operation: 'set_component_file', project: PROJECT, file: 'config.yaml', payload: yaml }); } @@ -96,4 +100,24 @@ suite('static plugin cache-header options', (ctx: ContextWithHarper) => { 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'); + }); }); diff --git a/integrationTests/fixtures/static-cache-headers/web/index.html b/integrationTests/fixtures/static-cache-headers/web/index.html new file mode 100644 index 0000000000..29048175dc --- /dev/null +++ b/integrationTests/fixtures/static-cache-headers/web/index.html @@ -0,0 +1,9 @@ + + + + static cache headers fixture + + + index + + diff --git a/server/static.ts b/server/static.ts index e42175bb17..df9a161b25 100644 --- a/server/static.ts +++ b/server/static.ts @@ -3,6 +3,7 @@ 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'; /** @@ -21,6 +22,23 @@ import send from 'send'; * - `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 + * 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 @@ -38,16 +56,40 @@ import send from 'send'; * 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). */ /** - * Serve a file through `send`, applying the plugin's cache-header options (read live from the - * component config, like `fallthrough`). `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. + * 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 serveFile(req, path: string, scope: Scope) { - const maxAge = scope.options.get(['maxAge']); - const immutable = scope.options.get(['immutable']) ?? false; - const cacheControl = scope.options.get(['cacheControl']); +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'.`); } @@ -62,9 +104,31 @@ function serveFile(req, path: string, scope: Scope) { 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: cacheControl === false || customCacheControl !== undefined ? false : true, + cacheControl: cacheControlDisabled || customCacheControl !== undefined ? false : true, maxAge: maxAgeMs, immutable, }); From 019254fd3c890723b2d0ac0d0a1eb2a9688e6ad1 Mon Sep 17 00:00:00 2001 From: Kris Zyp Date: Sat, 11 Jul 2026 06:04:27 -0600 Subject: [PATCH 6/6] Add year (y/Y) suffix to convertToMS The static plugin's documented `maxAge: 1y` silently resolved to 1 second: convertToMS had `M` (30-day month) but no year unit, so parseFloat('1y') matched no case and returned 1s. Add `y`/`Y` = 365 days, alongside a note on the case-sensitive M/m distinction, and unit coverage for the units. Co-Authored-By: Claude Opus 4.8 (1M context) --- unitTests/utility/common_utils.test.js | 19 +++++++++++++++++++ utility/common_utils.ts | 5 +++++ 2 files changed, 24 insertions(+) diff --git a/unitTests/utility/common_utils.test.js b/unitTests/utility/common_utils.test.js index bf38779a22..707c180a4c 100644 --- a/unitTests/utility/common_utils.test.js +++ b/unitTests/utility/common_utils.test.js @@ -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); + }); + }); }); diff --git a/utility/common_utils.ts b/utility/common_utils.ts index 26b5e8dd1a..3b06156d1b 100644 --- a/utility/common_utils.ts +++ b/utility/common_utils.ts @@ -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;