From cd135d4e7d9b160d93d2bbb9fac2d9771a856820 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 16:19:02 +0000 Subject: [PATCH 1/2] feat(static): add opt-in `Cache-Control` via `maxAge`/`immutable` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serveStatic()` gains two options, both off by default: - `maxAge` (seconds) emits `Cache-Control: max-age=` - `immutable` appends `, immutable` (only alongside `maxAge`) The header rides along on `304 Not Modified` responses too, so a revalidation refreshes the client's stored freshness (RFC 9110 §15.4.5), but is omitted on `412` and on `renderHTML` routes. A fractional or negative `maxAge` is floored and clamped to a valid delta-seconds. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/4.middleware.md | 4 +++ src/static.ts | 46 ++++++++++++++++++++++++++ test/static.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 133abe5..6638bf9 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -99,6 +99,8 @@ When no file matches the request, it calls `next()` — so your handler acts as - `compress`: Compress a response on the fly when no precompressed variant is served (default `true`). Pass `false` to serve only what is already on disk. - `lastModified`: Emit a `Last-Modified` header from the file's modification time, and answer a matching `If-Modified-Since` request with `304 Not Modified` (default `true`). - `etag`: Emit a weak `ETag` validator, and answer a matching `If-None-Match` request with `304 Not Modified` (default `true`). +- `maxAge`: Freshness lifetime in **seconds**, emitted as `Cache-Control: max-age=` (default `undefined`, no header). Lets a client reuse a response without a request until it goes stale. +- `immutable`: Add the `immutable` directive to `Cache-Control`, so a client does not revalidate a still-fresh response even on reload (default `false`). Only takes effect alongside `maxAge`, and only makes sense for fingerprinted (content-hashed) assets. - `renderHTML`: A function receiving `{ request, html, filename }` for every HTML file (`.html`, `.htm`), returning the `Response` to send. Use it to inject or template markup before serving. A request resolves in order: the path itself, then `.html`, then `/index.html`. So `/about` serves `about.html`, while an extension-less file (`LICENSE`, an ACME challenge token) is served at its exact name. A trailing-slash request names a directory, so `/sub/` resolves only `sub/index.html` — never `sub.html` or a file named `sub`. @@ -111,6 +113,8 @@ Compression applies to compressible types only, so a `.br` next to an image or f Every file served without `renderHTML` carries an `ETag` and a `Last-Modified` header, and a conditional request that still matches is answered with an empty `304 Not Modified` before the body is ever read. `If-None-Match` takes precedence over `If-Modified-Since`, matching [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#section-13.2.2). The `ETag` is weak (`W/"…"`): it is derived from the file's size and modification time rather than its bytes, and folds in the `Content-Encoding` so a brotli and a gzip response under one URL get distinct validators. Pass `etag: false` or `lastModified: false` to drop either header and stop honoring its conditional. `renderHTML` routes carry neither, since the rendered body is the caller's to validate. +`Cache-Control` is opt-in and off by default, so a client revalidates with those validators on every use. Set `maxAge` (in seconds) to send `Cache-Control: max-age=` and let a client reuse a response without a request until it goes stale; add `immutable: true` to send `max-age=, immutable`, which also skips revalidation on an explicit reload — appropriate for a fingerprinted asset whose URL changes when its bytes do. The header rides along on the `304` too, so a revalidation refreshes the stored freshness. Like the validators, it is omitted on `renderHTML` routes. + `/.well-known/` is served by default because [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) reserves it for public metadata: ACME HTTP-01 challenges and `security.txt` live there. Allow-listing is by exact segment name, so `[".well-known"]` serves neither a sibling sharing its prefix (`.well-known-backup`) nor a dot segment nested under it (`.well-known/.env`). Text responses declare `charset=utf-8`; without it a browser decodes them with a fallback of its own choosing, mangling any non-ASCII byte the file does not declare inline. diff --git a/src/static.ts b/src/static.ts index 3b6513d..7ab7757 100644 --- a/src/static.ts +++ b/src/static.ts @@ -76,6 +76,28 @@ export interface ServeStaticOptions { */ etag?: boolean; + /** + * Freshness lifetime, in **seconds**, emitted as `Cache-Control: max-age=`. + * + * Off by default: no `Cache-Control` header is sent, so a client revalidates + * with the `ETag`/`Last-Modified` validators on every use. Set it to let a + * client reuse a response without a request until it goes stale. + * + * @default undefined + */ + maxAge?: number; + + /** + * Add the `immutable` directive to `Cache-Control`, telling a client not to + * revalidate a still-fresh response even on an explicit reload. + * + * Only takes effect alongside `maxAge`, and only makes sense for a + * fingerprinted (content-hashed) asset, whose URL changes when its bytes do. + * + * @default false + */ + immutable?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -200,6 +222,10 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const lastModified = options.lastModified ?? true; const etag = options.etag ?? true; + // Depends only on the options, so it is built once. Empty when `maxAge` is + // unset — the header is fully opt-in. + const cacheControl = buildCacheControl(options.maxAge, options.immutable); + // Encodings served, in server-preference order. Disk variants lead: their order // is the documented preference, and a variant costs no CPU. An encoding reachable // only by compressing follows, so the default (no `encodings`, `compress` on) is @@ -434,6 +460,9 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // header either way. headers["Vary"] = "Accept-Encoding"; } + if (cacheControl) { + headers["Cache-Control"] = cacheControl; + } // Validators over the representation actually served (`file`): the variant // when one won, the identity file otherwise, with the negotiated encoding // folded into the ETag. HTTP dates are second-granular, so `Last-Modified` @@ -489,6 +518,11 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (headers["Vary"]) { conditionalHeaders["Vary"] = headers["Vary"]; } + // A 304 refreshes the client's stored freshness, so carry Cache-Control + // (RFC 9110 §15.4.5). A 412 is a plain error and gets none. + if (cacheControl && conditionalStatus === 304) { + conditionalHeaders["Cache-Control"] = cacheControl; + } return new FastResponse(null, { status: conditionalStatus, headers: conditionalHeaders }); } if (req.method === "HEAD") { @@ -519,6 +553,18 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // --- internal --- +// The `Cache-Control` value, or "" when `maxAge` is unset so the header is +// omitted entirely. `max-age` takes non-negative integer seconds, so a +// fractional or negative `maxAge` is floored and clamped. `immutable` only has +// meaning next to a lifetime, so it is dropped when `maxAge` is unset. +function buildCacheControl(maxAge: number | undefined, immutable: boolean | undefined): string { + if (maxAge === undefined) { + return ""; + } + const seconds = Math.max(0, Math.floor(maxAge)); + return immutable ? `max-age=${seconds}, immutable` : `max-age=${seconds}`; +} + // Types that benefit from compression — everything else (images, video, audio, // archives, fonts) is already compressed and would not have a `.br`/`.gz` variant. function isCompressible(mimeType: string): boolean { diff --git a/test/static.test.ts b/test/static.test.ts index 2b6c1c6..2e5f518 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -958,6 +958,69 @@ describe("serveStatic", () => { }); }); + describe("Cache-Control", () => { + test("omits the header by default", async () => { + const res = await fetchStatic("/app.js"); + expect(res.headers.get("cache-control")).toBe(null); + }); + + test("emits max-age from maxAge (seconds)", async () => { + const res = await fetchStatic("/app.js", { maxAge: 3600 }); + expect(res.headers.get("cache-control")).toBe("max-age=3600"); + }); + + test("adds immutable alongside maxAge", async () => { + const res = await fetchStatic("/app.js", { maxAge: 31536000, immutable: true }); + expect(res.headers.get("cache-control")).toBe("max-age=31536000, immutable"); + }); + + test("floors a fractional maxAge and clamps a negative one to 0", async () => { + expect((await fetchStatic("/app.js", { maxAge: 59.9 })).headers.get("cache-control")).toBe( + "max-age=59", + ); + expect((await fetchStatic("/app.js", { maxAge: -10 })).headers.get("cache-control")).toBe( + "max-age=0", + ); + }); + + test("ignores immutable without a maxAge", async () => { + const res = await fetchStatic("/app.js", { immutable: true }); + expect(res.headers.get("cache-control")).toBe(null); + }); + + test("carries Cache-Control on a 304 but not a 412", async () => { + const etag = (await fetchStatic("/app.js", { maxAge: 600 })).headers.get("etag")!; + const notModified = await fetchStatic( + "/app.js", + { maxAge: 600 }, + { headers: { "if-none-match": etag } }, + ); + expect(notModified.status).toBe(304); + expect(notModified.headers.get("cache-control")).toBe("max-age=600"); + + const precondition = await fetchStatic( + "/app.js", + { maxAge: 600, methods: ["POST"] }, + { method: "POST", headers: { "if-none-match": etag } }, + ); + expect(precondition.status).toBe(412); + expect(precondition.headers.get("cache-control")).toBe(null); + }); + + test("sets Cache-Control on a HEAD response", async () => { + const res = await fetchStatic("/app.js", { maxAge: 120 }, { method: "HEAD" }); + expect(res.headers.get("cache-control")).toBe("max-age=120"); + }); + + test("does not set Cache-Control on a renderHTML route", async () => { + const res = await fetchStatic("/index.html", { + maxAge: 3600, + renderHTML: ({ html }: { html: string }) => new Response(html), + }); + expect(res.headers.get("cache-control")).toBe(null); + }); + }); + describe("HEAD", () => { test("returns headers with no body", async () => { const res = await fetchStatic("/app.js", {}, { method: "HEAD" }); From 948d9d231683e6d78ced63fe8c39701f95f325fb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 16:23:36 +0000 Subject: [PATCH 2/2] fix(static): guard `maxAge` against non-finite and out-of-range values `buildCacheControl` floored and clamped negative/fractional `maxAge` but passed `NaN`, `Infinity`, and very large numbers straight through, emitting malformed headers like `max-age=NaN` or `max-age=1e+21`. Fall back to 0 for non-finite input and cap at the RFC 9111 recommended ceiling of 2^31 seconds. Co-Authored-By: Claude Opus 4.8 --- src/static.ts | 10 +++++++--- test/static.test.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/static.ts b/src/static.ts index 7ab7757..424c5e0 100644 --- a/src/static.ts +++ b/src/static.ts @@ -555,13 +555,17 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // The `Cache-Control` value, or "" when `maxAge` is unset so the header is // omitted entirely. `max-age` takes non-negative integer seconds, so a -// fractional or negative `maxAge` is floored and clamped. `immutable` only has -// meaning next to a lifetime, so it is dropped when `maxAge` is unset. +// fractional or negative `maxAge` is floored and clamped, non-finite values fall +// back to 0, and the result is capped at the RFC 9111 recommended ceiling of +// 2^31 seconds. `immutable` only has meaning next to a lifetime, so it is +// dropped when `maxAge` is unset. function buildCacheControl(maxAge: number | undefined, immutable: boolean | undefined): string { if (maxAge === undefined) { return ""; } - const seconds = Math.max(0, Math.floor(maxAge)); + const seconds = Number.isFinite(maxAge) + ? Math.min(2147483648, Math.max(0, Math.floor(maxAge))) + : 0; return immutable ? `max-age=${seconds}, immutable` : `max-age=${seconds}`; } diff --git a/test/static.test.ts b/test/static.test.ts index 2e5f518..1034fff 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -983,6 +983,18 @@ describe("serveStatic", () => { ); }); + test("falls back to 0 for non-finite maxAge and caps huge values", async () => { + expect((await fetchStatic("/app.js", { maxAge: NaN })).headers.get("cache-control")).toBe( + "max-age=0", + ); + expect( + (await fetchStatic("/app.js", { maxAge: Infinity })).headers.get("cache-control"), + ).toBe("max-age=0"); + expect((await fetchStatic("/app.js", { maxAge: 1e21 })).headers.get("cache-control")).toBe( + "max-age=2147483648", + ); + }); + test("ignores immutable without a maxAge", async () => { const res = await fetchStatic("/app.js", { immutable: true }); expect(res.headers.get("cache-control")).toBe(null);