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
4 changes: 4 additions & 0 deletions docs/1.guide/4.middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<n>` (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 `<path>.html`, then `<path>/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`.
Expand All @@ -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=<n>` and let a client reuse a response without a request until it goes stale; add `immutable: true` to send `max-age=<n>, 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.
Expand Down
50 changes: 50 additions & 0 deletions src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,28 @@ export interface ServeStaticOptions {
*/
etag?: boolean;

/**
* Freshness lifetime, in **seconds**, emitted as `Cache-Control: max-age=<n>`.
*
* 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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -519,6 +553,22 @@ 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, 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 = Number.isFinite(maxAge)
? Math.min(2147483648, Math.max(0, Math.floor(maxAge)))
: 0;
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 {
Expand Down
75 changes: 75 additions & 0 deletions test/static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,81 @@ 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("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);
});

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" });
Expand Down
Loading