From ed2870801220a8507a2cf28b97afa3e7f144d747 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 15:47:30 +0000 Subject: [PATCH 1/2] feat(static): add Last-Modified and ETag conditional caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every served file now carries `Last-Modified` (from mtime) and a weak `ETag`, and a matching `If-Modified-Since`/`If-None-Match` request is answered with an empty `304 Not Modified` before the body is read. - `If-None-Match` takes precedence over `If-Modified-Since` (RFC 9110 §13.2.2): present-and-unmatched is final. - The `ETag` is weak and folds in `Content-Encoding`, so brotli and gzip responses under one URL get distinct validators — which a cache keying on `Vary: Accept-Encoding` relies on. Weak because on-the-fly encodes are not byte-stable and no byte ranges are served. - New `lastModified` and `etag` options (both default `true`). - `renderHTML` routes carry neither: the rendered body is the caller's. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/4.middleware.md | 4 ++ src/static.ts | 99 +++++++++++++++++++++++++- test/static.test.ts | 132 +++++++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index 96fb795..eba0239 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -97,6 +97,8 @@ When no file matches the request, it calls `next()` — so your handler acts as - `dotfiles`: Dot segments (path segments starting with `.`) that may be served (default `[".well-known"]`). A path containing any other dot segment — `/.env`, `/.git/config` — falls through to `next()`. Pass `true` to serve every dot segment, or `false` (or `[]`) to serve none, including `/.well-known/`. - `encodings`: Serve precompressed variants from disk (default `false`). Pass `true` for `{ br: ".br", gzip: ".gz" }`, or a map setting the extension per encoding (keys tried in order, preferred first). Off by default because most deployments ship no precompressed files, so the lookup is a `stat` that always misses. - `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`). - `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`. @@ -107,6 +109,8 @@ Brotli compresses at quality 4 rather than the `node:zlib` default of 11, which Compression applies to compressible types only, so a `.br` next to an image or font is ignored, and those responses omit `Vary: Accept-Encoding` — which compressible ones always set, including uncompressed ones, as a shared cache must key on the header either way. `renderHTML` routes are never compressed and always read the source file: a variant on disk would not match the rendered output, and the `Response` the hook returns is the caller's to encode. +Every served file 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. + `/.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 e06aed2..b7c956f 100644 --- a/src/static.ts +++ b/src/static.ts @@ -56,6 +56,26 @@ export interface ServeStaticOptions { */ compress?: boolean; + /** + * Emit a `Last-Modified` header from the file's modification time, and answer an + * `If-Modified-Since` conditional request that still matches with `304 Not Modified`. + * + * @default true + */ + lastModified?: boolean; + + /** + * Emit an `ETag` validator, and answer an `If-None-Match` conditional request that still + * matches with `304 Not Modified`. + * + * The tag 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 never share one — which a cache keying on `Vary` relies on. + * + * @default true + */ + etag?: boolean; + /** * A function to modify the HTML content before serving it. */ @@ -146,7 +166,7 @@ const asPrefix = (path: string): string => (path.endsWith(sep) ? path : path + s // cannot block this way: the `?? 0` leaves plain `O_RDONLY` there. const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0); -type ServableFile = { handle: FileHandle; size: number }; +type ServableFile = { handle: FileHandle; size: number; mtimeMs: number }; // An encoding this middleware can answer with: by serving a precompressed variant // beside the file (`ext`), by encoding on the fly (`compressor`), or either. @@ -177,6 +197,9 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { const encodings = options.encodings === true ? DEFAULT_ENCODINGS : options.encodings || {}; const compress = options.compress ?? true; + const lastModified = options.lastModified ?? true; + const etag = options.etag ?? true; + // 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 @@ -239,7 +262,7 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { if (realPath.startsWith(root) && !isDeniedDotPath(realPath.slice(root.length))) { const realStat = await stat(realPath).catch(() => null); if (realStat && realStat.ino === fileStat.ino && realStat.dev === fileStat.dev) { - return { handle, size: fileStat.size }; + return { handle, size: fileStat.size, mtimeMs: fileStat.mtimeMs }; } } } @@ -411,6 +434,46 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { // header either way. headers["Vary"] = "Accept-Encoding"; } + // 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` + // and every comparison against it run at that precision. + let etagValue = ""; + if (etag) { + etagValue = computeETag(file.size, file.mtimeMs, encoding); + headers["ETag"] = etagValue; + } + const lastModifiedMs = Math.floor(file.mtimeMs / 1000) * 1000; + if (lastModified) { + headers["Last-Modified"] = new Date(lastModifiedMs).toUTCString(); + } + // A still-fresh conditional request skips the body entirely. `If-None-Match` + // takes precedence (RFC 9110 §13.2.2): when it is present, a non-match is + // final and `If-Modified-Since` is never consulted. + let notModified = false; + const ifNoneMatch = etagValue ? req.headers.get("if-none-match") : null; + if (ifNoneMatch !== null) { + notModified = matchesIfNoneMatch(ifNoneMatch, etagValue); + } else if (lastModified) { + notModified = matchesIfModifiedSince(req.headers.get("if-modified-since"), lastModifiedMs); + } + if (notModified) { + await file.handle.close().catch(() => {}); + // A 304 carries the validators and `Vary` a 200 would, but none of the + // representation headers (`Content-Type`/`-Length`/`-Encoding`): the + // client is being told to reuse the body it already has. + const notModifiedHeaders: Record = {}; + if (etagValue) { + notModifiedHeaders["ETag"] = etagValue; + } + if (headers["Last-Modified"]) { + notModifiedHeaders["Last-Modified"] = headers["Last-Modified"]; + } + if (headers["Vary"]) { + notModifiedHeaders["Vary"] = headers["Vary"]; + } + return new FastResponse(null, { status: 304, headers: notModifiedHeaders }); + } if (req.method === "HEAD") { // Node discards a HEAD body at the http layer, so skip the read — and // with it the compression a GET would pay for. The headers still @@ -452,6 +515,38 @@ function isCompressible(mimeType: string): boolean { ); } +// A weak validator over the served representation. Weak (`W/`), not strong: an +// on-the-fly encode is not byte-stable across runs, and a strong tag's one real +// advantage — byte-range requests — is something this middleware does not answer. +// Size and mtime pin the file; the encoding is folded in so a gzip and a brotli +// response under one URL never collide, which a cache keying on `Vary` relies on. +function computeETag(size: number, mtimeMs: number, encoding: string): string { + const tag = `${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}`; + return `W/"${encoding ? `${tag}-${encoding}` : tag}"`; +} + +// RFC 9110 §13.1.2 — a weak comparison, since our tags are weak, so an optional +// `W/` prefix is stripped from each candidate before matching. `*` matches any +// current representation. +function matchesIfNoneMatch(header: string, etag: string): boolean { + if (header.trim() === "*") { + return true; + } + const bare = etag.replace(/^W\//, ""); + return header.split(",").some((candidate) => candidate.trim().replace(/^W\//, "") === bare); +} + +// The file is unchanged if its mtime is at or before the client's copy. +// `lastModifiedMs` is already floored to the second (matching the HTTP date we +// send), so a sub-second mtime never reads as newer than the date it produced. +function matchesIfModifiedSince(header: string | null, lastModifiedMs: number): boolean { + if (!header) { + return false; + } + const since = Date.parse(header); + return !Number.isNaN(since) && lastModifiedMs <= since; +} + /** * Encodings from `served` the client accepts, in server-preference order. * `q=0` is honored as "not acceptable"; `*` applies to encodings not named explicitly. diff --git a/test/static.test.ts b/test/static.test.ts index 9427f0c..70b92d1 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -745,6 +745,138 @@ describe("serveStatic", () => { }); }); + describe("caching validators", () => { + test("sets a weak ETag and Last-Modified by default", async () => { + const res = await fetchStatic("/app.js"); + expect(res.headers.get("etag")).toMatch(/^W\/".+"$/); + // A parseable HTTP date, not the raw mtime. + expect(Number.isNaN(Date.parse(res.headers.get("last-modified")!))).toBe(false); + }); + + test("answers a matching If-None-Match with 304 and no body", async () => { + const etag = (await fetchStatic("/app.js")).headers.get("etag")!; + const res = await fetchStatic("/app.js", {}, { headers: { "if-none-match": etag } }); + expect(res.status).toBe(304); + // The validator is echoed so the client can refresh its freshness... + expect(res.headers.get("etag")).toBe(etag); + // ...but the representation headers are not, and there is no body. + expect(res.headers.get("content-length")).toBe(null); + await expect(res.text()).resolves.toBe(""); + }); + + test("serves the body when If-None-Match does not match", async () => { + const res = await fetchStatic("/app.js", {}, { headers: { "if-none-match": 'W/"stale"' } }); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("treats If-None-Match: * as a match", async () => { + const res = await fetchStatic("/app.js", {}, { headers: { "if-none-match": "*" } }); + expect(res.status).toBe(304); + }); + + test("answers a fresh If-Modified-Since with 304", async () => { + const lastModified = (await fetchStatic("/app.js")).headers.get("last-modified")!; + const res = await fetchStatic( + "/app.js", + {}, + { headers: { "if-modified-since": lastModified } }, + ); + expect(res.status).toBe(304); + await expect(res.text()).resolves.toBe(""); + }); + + test("serves the body when If-Modified-Since predates the file", async () => { + const res = await fetchStatic( + "/app.js", + {}, + { headers: { "if-modified-since": new Date(0).toUTCString() } }, + ); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("lets a non-matching If-None-Match override a fresh If-Modified-Since", async () => { + // RFC 9110 §13.2.2: If-None-Match present and unmatched is final; the fresh + // If-Modified-Since here must not rescue it into a 304. + const lastModified = (await fetchStatic("/app.js")).headers.get("last-modified")!; + const res = await fetchStatic( + "/app.js", + {}, + { headers: { "if-none-match": 'W/"stale"', "if-modified-since": lastModified } }, + ); + expect(res.status).toBe(200); + }); + + test("gives distinct ETags to distinct encodings of one file", async () => { + // Same file, so same size and mtime — only the folded-in encoding sets the + // two tags apart, which a cache keying on Vary depends on. + const identity = (await fetchEncoded("/big.js", "")).headers.get("etag")!; + const gzip = await fetchEncoded("/big.js", "gzip"); + expect(gzip.headers.get("content-encoding")).toBe("gzip"); + expect(gzip.headers.get("etag")).not.toBe(identity); + + // The identity tag must not validate the gzip representation... + const wrongRes = await fetchStatic( + "/big.js", + {}, + { headers: { "accept-encoding": "gzip", "if-none-match": identity } }, + ); + expect(wrongRes.status).toBe(200); + // ...while the gzip tag does, and the 304 still carries Vary. + const gzipEtag = gzip.headers.get("etag")!; + const right = await fetchStatic( + "/big.js", + {}, + { headers: { "accept-encoding": "gzip", "if-none-match": gzipEtag } }, + ); + expect(right.status).toBe(304); + expect(right.headers.get("vary")).toBe("Accept-Encoding"); + }); + + test("omits ETag and ignores If-None-Match with etag: false", async () => { + const res = await fetchStatic("/app.js", { etag: false }); + expect(res.headers.get("etag")).toBe(null); + // A tag the default run would have minted no longer 304s. + const cond = await fetchStatic( + "/app.js", + { etag: false }, + { headers: { "if-none-match": "*" } }, + ); + expect(cond.status).toBe(200); + }); + + test("omits Last-Modified and ignores If-Modified-Since with lastModified: false", async () => { + const res = await fetchStatic("/app.js", { lastModified: false }); + expect(res.headers.get("last-modified")).toBe(null); + const cond = await fetchStatic( + "/app.js", + { lastModified: false }, + { headers: { "if-modified-since": new Date(Date.now() + 3600_000).toUTCString() } }, + ); + expect(cond.status).toBe(200); + }); + + test("does not set validators on a renderHTML route", async () => { + const res = await fetchStatic("/index.html", { + renderHTML: ({ html }: { html: string }) => new Response(html), + }); + expect(res.headers.get("etag")).toBe(null); + expect(res.headers.get("last-modified")).toBe(null); + }); + + test("answers a conditional HEAD with 304", async () => { + const etag = (await fetchStatic("/app.js")).headers.get("etag")!; + const res = await fetchStatic( + "/app.js", + {}, + { method: "HEAD", headers: { "if-none-match": etag } }, + ); + expect(res.status).toBe(304); + await expect(res.text()).resolves.toBe(""); + }); + }); + describe("HEAD", () => { test("returns headers with no body", async () => { const res = await fetchStatic("/app.js", {}, { method: "HEAD" }); From 121c4008cdec1e950cd443fbb81b359a9a618c95 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 17 Jul 2026 16:07:28 +0000 Subject: [PATCH 2/2] fix(static): address review on conditional caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cap `Last-Modified` at the response origination time so a future/skewed file mtime cannot 304 an `If-Modified-Since` until real time catches up (RFC 9110 §8.8.2). - Evaluate conditionals per RFC 9110 §13.2.2 precedence: a present `If-None-Match` suppresses `If-Modified-Since` regardless of the `etag` option (with ETags off, only `*` matches); a match answers GET/HEAD with `304` and any other configured method with `412`; `If-Modified-Since` is ignored for non-GET/HEAD (§13.1.3). - Qualify the docs: validators cover files served without `renderHTML`. Co-Authored-By: Claude Opus 4.8 --- docs/1.guide/4.middleware.md | 2 +- src/static.ts | 59 ++++++++++++++++-------- test/static.test.ts | 89 ++++++++++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/docs/1.guide/4.middleware.md b/docs/1.guide/4.middleware.md index eba0239..133abe5 100644 --- a/docs/1.guide/4.middleware.md +++ b/docs/1.guide/4.middleware.md @@ -109,7 +109,7 @@ Brotli compresses at quality 4 rather than the `node:zlib` default of 11, which Compression applies to compressible types only, so a `.br` next to an image or font is ignored, and those responses omit `Vary: Accept-Encoding` — which compressible ones always set, including uncompressed ones, as a shared cache must key on the header either way. `renderHTML` routes are never compressed and always read the source file: a variant on disk would not match the rendered output, and the `Response` the hook returns is the caller's to encode. -Every served file 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. +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. `/.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`). diff --git a/src/static.ts b/src/static.ts index b7c956f..3b6513d 100644 --- a/src/static.ts +++ b/src/static.ts @@ -443,36 +443,53 @@ export const serveStatic = (options: ServeStaticOptions): ServerMiddleware => { etagValue = computeETag(file.size, file.mtimeMs, encoding); headers["ETag"] = etagValue; } - const lastModifiedMs = Math.floor(file.mtimeMs / 1000) * 1000; + // A future mtime (clock skew, a deliberately post-dated file) must not + // become a future `Last-Modified`, or an `If-Modified-Since` bearing it + // would match until real time catches up. RFC 9110 §8.8.2 caps it at the + // response's origination time. + const lastModifiedMs = Math.min( + Math.floor(file.mtimeMs / 1000) * 1000, + Math.floor(Date.now() / 1000) * 1000, + ); if (lastModified) { headers["Last-Modified"] = new Date(lastModifiedMs).toUTCString(); } - // A still-fresh conditional request skips the body entirely. `If-None-Match` - // takes precedence (RFC 9110 §13.2.2): when it is present, a non-match is - // final and `If-Modified-Since` is never consulted. - let notModified = false; - const ifNoneMatch = etagValue ? req.headers.get("if-none-match") : null; + // A conditional request that still matches needs no body. `If-None-Match` + // takes precedence over `If-Modified-Since` (RFC 9110 §13.2.2): its very + // presence suppresses the date check — even with `etag` off, where no tag + // is emitted so only `*` can match — and a match answers GET/HEAD with + // `304` but any other configured method with `412` (precondition failed). + // `If-Modified-Since` is a GET/HEAD-only validator (RFC 9110 §13.1.3), so + // it is never evaluated for the other methods. + const conditionalGet = req.method === "GET" || req.method === "HEAD"; + let conditionalStatus = 0; + const ifNoneMatch = req.headers.get("if-none-match"); if (ifNoneMatch !== null) { - notModified = matchesIfNoneMatch(ifNoneMatch, etagValue); - } else if (lastModified) { - notModified = matchesIfModifiedSince(req.headers.get("if-modified-since"), lastModifiedMs); + if (matchesIfNoneMatch(ifNoneMatch, etagValue)) { + conditionalStatus = conditionalGet ? 304 : 412; + } + } else if (lastModified && conditionalGet) { + if (matchesIfModifiedSince(req.headers.get("if-modified-since"), lastModifiedMs)) { + conditionalStatus = 304; + } } - if (notModified) { + if (conditionalStatus) { await file.handle.close().catch(() => {}); - // A 304 carries the validators and `Vary` a 200 would, but none of the - // representation headers (`Content-Type`/`-Length`/`-Encoding`): the - // client is being told to reuse the body it already has. - const notModifiedHeaders: Record = {}; + // Both statuses drop the representation headers + // (`Content-Type`/`-Length`/`-Encoding`) and the body — a `304` tells the + // client to reuse the copy it has, a `412` that its precondition failed — + // while keeping the validators and `Vary` a `200` would carry. + const conditionalHeaders: Record = {}; if (etagValue) { - notModifiedHeaders["ETag"] = etagValue; + conditionalHeaders["ETag"] = etagValue; } if (headers["Last-Modified"]) { - notModifiedHeaders["Last-Modified"] = headers["Last-Modified"]; + conditionalHeaders["Last-Modified"] = headers["Last-Modified"]; } if (headers["Vary"]) { - notModifiedHeaders["Vary"] = headers["Vary"]; + conditionalHeaders["Vary"] = headers["Vary"]; } - return new FastResponse(null, { status: 304, headers: notModifiedHeaders }); + return new FastResponse(null, { status: conditionalStatus, headers: conditionalHeaders }); } if (req.method === "HEAD") { // Node discards a HEAD body at the http layer, so skip the read — and @@ -527,11 +544,15 @@ function computeETag(size: number, mtimeMs: number, encoding: string): string { // RFC 9110 §13.1.2 — a weak comparison, since our tags are weak, so an optional // `W/` prefix is stripped from each candidate before matching. `*` matches any -// current representation. +// current representation. With ETags off (`etag` empty) there is no tag to +// compare, so only `*` can match. function matchesIfNoneMatch(header: string, etag: string): boolean { if (header.trim() === "*") { return true; } + if (!etag) { + return false; + } const bare = etag.replace(/^W\//, ""); return header.split(",").some((candidate) => candidate.trim().replace(/^W\//, "") === bare); } diff --git a/test/static.test.ts b/test/static.test.ts index 70b92d1..2b6c1c6 100644 --- a/test/static.test.ts +++ b/test/static.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { mkdtemp, mkdir, rm, writeFile, symlink, truncate } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, writeFile, symlink, truncate, utimes } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; import { join, parse, relative, sep } from "node:path"; @@ -834,14 +834,16 @@ describe("serveStatic", () => { expect(right.headers.get("vary")).toBe("Accept-Encoding"); }); - test("omits ETag and ignores If-None-Match with etag: false", async () => { + test("omits ETag and does not match a client tag with etag: false", async () => { const res = await fetchStatic("/app.js", { etag: false }); expect(res.headers.get("etag")).toBe(null); - // A tag the default run would have minted no longer 304s. + // With no tag emitted, a specific If-None-Match cannot match, so the tag + // a default run would have minted no longer 304s. (`*` is the one value + // that still matches — it asks only whether a representation exists.) const cond = await fetchStatic( "/app.js", { etag: false }, - { headers: { "if-none-match": "*" } }, + { headers: { "if-none-match": 'W/"whatever"' } }, ); expect(cond.status).toBe(200); }); @@ -865,6 +867,85 @@ describe("serveStatic", () => { expect(res.headers.get("last-modified")).toBe(null); }); + test("answers a matching If-None-Match on a non-GET/HEAD method with 412", async () => { + // The tag is stable across methods (size + mtime), so a default GET mints + // the one a configured POST must fail its precondition against. + const etag = (await fetchStatic("/app.js")).headers.get("etag")!; + const res = await fetchStatic( + "/app.js", + { methods: ["POST"] }, + { method: "POST", headers: { "if-none-match": etag } }, + ); + expect(res.status).toBe(412); + expect(res.headers.get("etag")).toBe(etag); + await expect(res.text()).resolves.toBe(""); + }); + + test("serves a non-GET/HEAD body when If-None-Match does not match", async () => { + const res = await fetchStatic( + "/app.js", + { methods: ["POST"] }, + { method: "POST", headers: { "if-none-match": 'W/"stale"' } }, + ); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("ignores If-Modified-Since for a non-GET/HEAD method", async () => { + // A GET/HEAD-only validator (RFC 9110 §13.1.3): a fresh date must not + // shortcut a configured POST into a 304. + const lastModified = (await fetchStatic("/app.js")).headers.get("last-modified")!; + const res = await fetchStatic( + "/app.js", + { methods: ["POST"] }, + { method: "POST", headers: { "if-modified-since": lastModified } }, + ); + expect(res.status).toBe(200); + await expect(res.text()).resolves.toBe("PLAIN_JS"); + }); + + test("If-None-Match presence suppresses If-Modified-Since even with etag off", async () => { + // RFC 9110 §13.2.2: a present If-None-Match takes precedence. With + // `etag: false` no tag is emitted, so a specific one cannot match — the + // full body is served, not a 304 rescued by the still-parsed date. + const lastModified = (await fetchStatic("/app.js")).headers.get("last-modified")!; + const res = await fetchStatic( + "/app.js", + { etag: false }, + { headers: { "if-none-match": 'W/"whatever"', "if-modified-since": lastModified } }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("etag")).toBe(null); + }); + + test("still honors If-Modified-Since with etag off when no If-None-Match is sent", async () => { + // Nothing suppresses the date check here, so the date validator alone + // still shortcuts to a 304. + const lastModified = (await fetchStatic("/app.js")).headers.get("last-modified")!; + const res = await fetchStatic( + "/app.js", + { etag: false }, + { headers: { "if-modified-since": lastModified } }, + ); + expect(res.status).toBe(304); + }); + + test("caps Last-Modified at the response time for a future-dated file", async () => { + // A future mtime (clock skew, a deliberately post-dated file) must not + // surface as a future `Last-Modified` — RFC 9110 §8.8.2 — or an + // `If-Modified-Since` bearing it would 304 until real time catches up. + const future = join(dir, "future.js"); + await writeFile(future, "FUTURE"); + const ahead = new Date(Date.now() + 86_400_000); + await utimes(future, ahead, ahead); + try { + const res = await fetchStatic("/future.js"); + expect(Date.parse(res.headers.get("last-modified")!)).toBeLessThanOrEqual(Date.now()); + } finally { + await rm(future, { force: true }); + } + }); + test("answers a conditional HEAD with 304", async () => { const etag = (await fetchStatic("/app.js")).headers.get("etag")!; const res = await fetchStatic(