From 62b5dff01c417941a62f7f0ca9a5eb2e210afb78 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 27 Mar 2026 08:24:03 +0100 Subject: [PATCH 1/7] feat(http): stabilize `cache_control` --- http/deno.json | 4 +- http/mod.ts | 6 + http/unstable_cache_control.ts | 398 ---------------------------- http/unstable_cache_control_test.ts | 301 --------------------- import_map.json | 2 +- 5 files changed, 9 insertions(+), 702 deletions(-) delete mode 100644 http/unstable_cache_control.ts delete mode 100644 http/unstable_cache_control_test.ts diff --git a/http/deno.json b/http/deno.json index 254955f54d51..0eda351c46f6 100644 --- a/http/deno.json +++ b/http/deno.json @@ -3,6 +3,7 @@ "version": "1.0.25", "exports": { ".": "./mod.ts", + "./cache-control": "./cache_control.ts", "./cookie": "./cookie.ts", "./etag": "./etag.ts", "./file-server": "./file_server.ts", @@ -19,7 +20,6 @@ "./unstable-signed-cookie": "./unstable_signed_cookie.ts", "./unstable-structured-fields": "./unstable_structured_fields.ts", "./user-agent": "./user_agent.ts", - "./unstable-route": "./unstable_route.ts", - "./unstable-cache-control": "./unstable_cache_control.ts" + "./unstable-route": "./unstable_route.ts" } } diff --git a/http/mod.ts b/http/mod.ts index 67203f6e4d59..c4a3b90ecf63 100644 --- a/http/mod.ts +++ b/http/mod.ts @@ -40,6 +40,11 @@ * > {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Charset | clients omit and servers should ignore} * > therefore is not provided. * + * ## Cache-Control + * + * {@linkcode parseCacheControl} and {@linkcode formatCacheControl} parse and serialize the + * `Cache-Control` header per RFC 9111 §5.2. + * * ## User agent handling * * The {@linkcode UserAgent} class provides user agent string parsing, allowing @@ -97,6 +102,7 @@ * @module */ +export * from "./cache_control.ts"; export * from "./cookie.ts"; export * from "./etag.ts"; export * from "./status.ts"; diff --git a/http/unstable_cache_control.ts b/http/unstable_cache_control.ts deleted file mode 100644 index 53688dd37359..000000000000 --- a/http/unstable_cache_control.ts +++ /dev/null @@ -1,398 +0,0 @@ -// Copyright 2018-2026 the Deno authors. MIT license. -// This module is browser compatible. - -/** - * Parsing and formatting of the `Cache-Control` HTTP header per - * {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2 | RFC 9111 Section 5.2}. - * - * Provides {@linkcode parseCacheControl} to parse a header value into a typed - * object and {@linkcode formatCacheControl} to serialize back to a header string. - * - * @example Response with Cache-Control and ETag - * ```ts ignore - * import { formatCacheControl } from "@std/http/unstable-cache-control"; - * import { eTag } from "@std/http/etag"; - * - * Deno.serve(async (_req) => { - * const body = "hello"; - * const etag = await eTag(body); - * const headers = new Headers(); - * if (etag) headers.set("etag", etag); - * headers.set("cache-control", formatCacheControl({ - * maxAge: 3600, - * private: true, - * mustRevalidate: true, - * })); - * return new Response(body, { headers }); - * }); - * ``` - * - * @example Parse request Cache-Control - * ```ts ignore - * import { parseCacheControl } from "@std/http/unstable-cache-control"; - * - * Deno.serve((req) => { - * const cc = parseCacheControl(req.headers.get("cache-control")); - * if (cc.noStore) { - * return new Response(null, { status: 400 }); // client forbids storing - * } - * return new Response("OK"); - * }); - * ``` - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2} - * - * @module - */ - -/** - * Shared Cache-Control directives valid in both request and response. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - */ -export interface CacheControlBase { - /** When present, the cache must not store the request or response. */ - noStore?: true; - /** When present, the cache must not transform the payload. */ - noTransform?: true; - /** - * Maximum age in seconds. In a request: accept responses whose age is no - * greater than this. In a response: the response is stale after this many seconds. - */ - maxAge?: number; -} - -/** - * Cache-Control directives for requests (e.g. from a client). - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} - */ -export interface RequestCacheControl extends CacheControlBase { - /** When present, the cache must not use a stored response without revalidation. */ - noCache?: true; - /** - * Accept stale responses. If a number, accept responses that have been stale - * for no more than this many seconds. If `true`, accept any staleness. - */ - maxStale?: number | true; - /** Require the response to be fresh for at least this many seconds. */ - minFresh?: number; - /** Only return a cached response; do not forward the request to the origin. */ - onlyIfCached?: true; -} - -/** - * Cache-Control directives for responses (e.g. from a server). - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2} - */ -export interface ResponseCacheControl extends CacheControlBase { - /** - * When `true`, the response must not be used from cache without revalidation. - * When an array, only the listed response header fields require revalidation; - * the rest of the response may be used without it. - */ - noCache?: true | string[]; - /** - * When `true`, the response is intended for a single user (private cache only). - * When an array, only the listed header field names are private. - */ - private?: true | string[]; - /** The response may be stored by any cache. */ - public?: true; - /** Shared (e.g. CDN) cache maximum age in seconds. Overrides max-age for shared caches. */ - sMaxage?: number; - /** When stale, the cache must revalidate before using the response. */ - mustRevalidate?: true; - /** Same as must-revalidate but applies only to shared caches. */ - proxyRevalidate?: true; - /** The cache must not use the response if it does not understand the directive. */ - mustUnderstand?: true; - /** The response will not change while fresh; caches may reuse it without revalidation. */ - immutable?: true; - /** Allow use of stale response while revalidating in the background (seconds). */ - staleWhileRevalidate?: number; - /** Allow use of stale response if revalidation fails (seconds). */ - staleIfError?: number; -} - -/** - * Parsed Cache-Control value. Union of request and response types when direction - * is unknown (e.g. after parsing a raw header string). - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - */ -export type CacheControl = RequestCacheControl | ResponseCacheControl; - -/** Union of every directive field across both interfaces, using the widest type - * for fields that appear in both (e.g. `noCache` becomes `true | string[]`). - * Avoids per-field type assertions during parsing and formatting. */ -type AllCacheControlFields = { - [K in keyof RequestCacheControl | keyof ResponseCacheControl]?: K extends - keyof RequestCacheControl - ? K extends keyof ResponseCacheControl - ? RequestCacheControl[K] | ResponseCacheControl[K] - : RequestCacheControl[K] - : K extends keyof ResponseCacheControl ? ResponseCacheControl[K] - : never; -}; - -/** Upper bound for delta-seconds per RFC 9111 §1.2.2. Values above this are - * clamped to this sentinel which represents "infinity" (~68 years). */ -const MAX_DELTA_SECONDS = 2_147_483_648; // 2^31 - -function parseNonNegativeInt(value: string, directive: string): number { - const trimmed = value.trim(); - if (!/^\d+$/.test(trimmed)) { - throw new SyntaxError( - `Cache-Control: invalid value for ${directive}: "${value}"`, - ); - } - const n = Number(trimmed); - return n > MAX_DELTA_SECONDS ? MAX_DELTA_SECONDS : n; -} - -/** Split by comma but not inside double-quoted strings (needed for - * `no-cache` and `private` whose quoted-string arguments may contain commas). */ -function splitDirectives(value: string): string[] { - // Fast path: no quotes means a simple split is safe. - if (!value.includes('"')) return value.split(","); - - const parts: string[] = []; - let start = 0; - let inQuotes = false; - for (let i = 0; i < value.length; i++) { - const c = value.charCodeAt(i); - if (c === 34 /* " */) { - inQuotes = !inQuotes; - } else if (c === 44 /* , */ && !inQuotes) { - parts.push(value.slice(start, i)); - start = i + 1; - } - } - parts.push(value.slice(start)); - return parts; -} - -/** Parse a comma-separated list of HTTP field names from a directive argument. - * Strips surrounding double quotes if present and unescapes `\"` sequences. - * Returns an array of trimmed, non-empty field names. */ -function parseFieldNames(value: string): string[] { - const t = value.trim(); - const parsed = t.length >= 2 && t.startsWith('"') && t.endsWith('"') - ? t.slice(1, -1).replace(/\\"/g, '"') - : t; - return parsed.split(",").map((s) => s.trim()).filter(Boolean); -} - -/** - * Parses a `Cache-Control` header value into a typed object. Returns an empty - * object for `null` or empty string. Directive names are case-insensitive. - * Unknown directives are ignored per RFC 9111. Throws on malformed values for - * known directives (e.g. `max-age=abc`). - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @param value The header value (e.g. `headers.get("cache-control")`). - * @returns Parsed directives as a union of request/response types. - * - * @example Usage - * ```ts - * import { parseCacheControl } from "@std/http/unstable-cache-control"; - * import { assertEquals } from "@std/assert"; - * - * const cc = parseCacheControl("max-age=3600, no-store"); - * assertEquals(cc.maxAge, 3600); - * assertEquals(cc.noStore, true); - * ``` - * - * @throws {SyntaxError} If a known directive has a malformed value (e.g. - * `max-age=abc`) or a required value is missing (e.g. bare `max-age`). - */ -export function parseCacheControl(value: string | null): CacheControl { - const result: AllCacheControlFields = {}; - if (value === null || value.trim() === "") { - return result as CacheControl; - } - - const seen = new Set(); - const parts = splitDirectives(value); - for (const part of parts) { - const trimmed = part.trim(); - if (trimmed === "") continue; - - const eq = trimmed.indexOf("="); - const name = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim() - .toLowerCase(); - const rawValue = eq === -1 ? undefined : trimmed.slice(eq + 1).trim(); - - // RFC 9111 §4.2.1: when a directive appears more than once, use the first - // occurrence. Track seen directive names to skip subsequent duplicates. - if (seen.has(name)) continue; - seen.add(name); - - switch (name) { - case "max-age": - if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); - } - result.maxAge = parseNonNegativeInt(rawValue, name); - break; - case "max-stale": - result.maxStale = rawValue === undefined - ? true - : parseNonNegativeInt(rawValue, name); - break; - case "min-fresh": - if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); - } - result.minFresh = parseNonNegativeInt(rawValue, name); - break; - case "no-cache": - result.noCache = rawValue === undefined - ? true - : parseFieldNames(rawValue); - break; - case "no-store": - result.noStore = true; - break; - case "no-transform": - result.noTransform = true; - break; - case "only-if-cached": - result.onlyIfCached = true; - break; - case "must-revalidate": - result.mustRevalidate = true; - break; - case "must-understand": - result.mustUnderstand = true; - break; - case "proxy-revalidate": - result.proxyRevalidate = true; - break; - case "public": - result.public = true; - break; - case "s-maxage": - if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); - } - result.sMaxage = parseNonNegativeInt(rawValue, name); - break; - case "private": - result.private = rawValue === undefined - ? true - : parseFieldNames(rawValue); - break; - case "immutable": - result.immutable = true; - break; - case "stale-while-revalidate": - if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); - } - result.staleWhileRevalidate = parseNonNegativeInt(rawValue, name); - break; - case "stale-if-error": - if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); - } - result.staleIfError = parseNonNegativeInt(rawValue, name); - break; - default: - // Unknown directives are ignored per RFC 9111 §5.2.3. - continue; - } - } - - return result as CacheControl; -} - -function append( - out: string[], - directive: string, - value?: number | true | string[], -): void { - if (value === undefined) return; - if (value === true) { - out.push(directive); - return; - } - if (typeof value === "number") { - if (!Number.isInteger(value) || value < 0) { - throw new RangeError( - `Cache-Control: ${directive} must be a non-negative integer, got ${value}`, - ); - } - // Clamp to MAX_DELTA_SECONDS to match parser behavior (RFC 9111 §1.2.2). - out.push(`${directive}=${Math.min(value, MAX_DELTA_SECONDS)}`); - return; - } - if (value.length === 0) { - out.push(directive); - return; - } - out.push(`${directive}="${value.join(", ")}"`); -} - -/** - * Serializes a Cache-Control object to a header value string. Output is - * lowercase and comma-separated. Empty object produces an empty string. - * - * @experimental **UNSTABLE**: New API, yet to be vetted. - * - * @param cc The Cache-Control object (request or response). - * @returns The header value string, or empty string if no directives. - * - * @example Usage - * ```ts - * import { formatCacheControl } from "@std/http/unstable-cache-control"; - * import { assertEquals } from "@std/assert"; - * - * const value = formatCacheControl({ maxAge: 300, mustRevalidate: true }); - * assertEquals(value, "max-age=300, must-revalidate"); - * ``` - * - * @throws {RangeError} If a numeric directive value is not a non-negative - * integer (e.g. `NaN`, `Infinity`, `-1`, or `3.14`). - */ -export function formatCacheControl(cc: CacheControl): string { - const d: AllCacheControlFields = cc; - const out: string[] = []; - append(out, "max-age", d.maxAge); - append(out, "no-cache", d.noCache); - append(out, "no-store", d.noStore); - append(out, "no-transform", d.noTransform); - append(out, "max-stale", d.maxStale); - append(out, "min-fresh", d.minFresh); - append(out, "only-if-cached", d.onlyIfCached); - append(out, "s-maxage", d.sMaxage); - append(out, "private", d.private); - append(out, "public", d.public); - append(out, "must-revalidate", d.mustRevalidate); - append(out, "proxy-revalidate", d.proxyRevalidate); - append(out, "must-understand", d.mustUnderstand); - append(out, "immutable", d.immutable); - append(out, "stale-while-revalidate", d.staleWhileRevalidate); - append(out, "stale-if-error", d.staleIfError); - - return out.join(", "); -} diff --git a/http/unstable_cache_control_test.ts b/http/unstable_cache_control_test.ts deleted file mode 100644 index 20ea6de5c4a9..000000000000 --- a/http/unstable_cache_control_test.ts +++ /dev/null @@ -1,301 +0,0 @@ -// Copyright 2018-2026 the Deno authors. MIT license. - -import { assertEquals, assertThrows } from "@std/assert"; -import { assertType, type IsExact } from "@std/testing/types"; -import { - type CacheControl, - formatCacheControl, - parseCacheControl, - type RequestCacheControl, - type ResponseCacheControl, -} from "./unstable_cache_control.ts"; - -Deno.test("parseCacheControl() returns empty object for null", () => { - assertEquals(parseCacheControl(null), {}); -}); - -Deno.test("parseCacheControl() returns empty object for empty string", () => { - assertEquals(parseCacheControl(""), {}); - assertEquals(parseCacheControl(" "), {}); -}); - -Deno.test("parseCacheControl() parses single boolean directive", () => { - assertEquals(parseCacheControl("no-store"), { noStore: true }); - assertEquals(parseCacheControl("no-cache"), { noCache: true }); - assertEquals(parseCacheControl("public"), { public: true }); - assertEquals(parseCacheControl("immutable"), { immutable: true }); -}); - -Deno.test("parseCacheControl() parses single valued directive", () => { - assertEquals(parseCacheControl("max-age=3600"), { maxAge: 3600 }); - assertEquals(parseCacheControl("s-maxage=0"), { sMaxage: 0 }); - assertEquals(parseCacheControl("stale-while-revalidate=60"), { - staleWhileRevalidate: 60, - }); -}); - -Deno.test("parseCacheControl() parses multiple directives", () => { - assertEquals( - parseCacheControl("max-age=300, private, must-revalidate"), - { maxAge: 300, private: true, mustRevalidate: true }, - ); -}); - -Deno.test("parseCacheControl() parses no-cache with field names", () => { - assertEquals(parseCacheControl('no-cache="foo"'), { noCache: ["foo"] }); - assertEquals(parseCacheControl('no-cache="foo, bar"'), { - noCache: ["foo", "bar"], - }); -}); - -Deno.test("parseCacheControl() parses private with field names", () => { - assertEquals(parseCacheControl('private="x-custom"'), { - private: ["x-custom"], - }); -}); - -Deno.test("parseCacheControl() parses max-stale with and without value", () => { - assertEquals(parseCacheControl("max-stale"), { maxStale: true }); - assertEquals(parseCacheControl("max-stale=120"), { maxStale: 120 }); -}); - -Deno.test("parseCacheControl() is case insensitive", () => { - assertEquals(parseCacheControl("NO-STORE"), { noStore: true }); - assertEquals(parseCacheControl("Max-Age=100"), { maxAge: 100 }); -}); - -Deno.test("parseCacheControl() ignores unknown directives", () => { - assertEquals(parseCacheControl("no-store, unknown=1, no-cache"), { - noStore: true, - noCache: true, - }); -}); - -Deno.test("parseCacheControl() throws on malformed numeric value", () => { - assertThrows( - () => parseCacheControl("max-age=abc"), - SyntaxError, - "invalid value", - ); - assertThrows( - () => parseCacheControl("max-age=-1"), - SyntaxError, - "invalid value", - ); - assertThrows( - () => parseCacheControl("s-maxage=1.5"), - SyntaxError, - "invalid value", - ); -}); - -Deno.test("parseCacheControl() throws when valued directive has no value", () => { - assertThrows( - () => parseCacheControl("max-age"), - SyntaxError, - "requires an integer value", - ); - assertThrows( - () => parseCacheControl("stale-while-revalidate"), - SyntaxError, - "requires an integer value", - ); -}); - -Deno.test("formatCacheControl() returns empty string for empty object", () => { - assertEquals(formatCacheControl({}), ""); -}); - -Deno.test("formatCacheControl() serializes boolean directives", () => { - assertEquals(formatCacheControl({ noStore: true }), "no-store"); - assertEquals( - formatCacheControl({ noStore: true, noTransform: true }), - "no-store, no-transform", - ); -}); - -Deno.test("formatCacheControl() serializes valued directives", () => { - assertEquals(formatCacheControl({ maxAge: 3600 }), "max-age=3600"); - assertEquals( - formatCacheControl({ maxAge: 0, sMaxage: 100 }), - "max-age=0, s-maxage=100", - ); -}); - -Deno.test("formatCacheControl() serializes no-cache and private with field names", () => { - assertEquals( - formatCacheControl({ noCache: ["foo", "bar"] }), - 'no-cache="foo, bar"', - ); - assertEquals( - formatCacheControl({ private: ["x-custom"] }), - 'private="x-custom"', - ); -}); - -Deno.test("formatCacheControl() round-trip", () => { - const value = - "max-age=300, private, must-revalidate, stale-while-revalidate=60"; - const parsed = parseCacheControl(value); - const formatted = formatCacheControl(parsed); - const reparsed = parseCacheControl(formatted); - assertEquals(parsed, reparsed); - assertEquals( - formatted, - "max-age=300, private, must-revalidate, stale-while-revalidate=60", - ); -}); - -Deno.test("formatCacheControl() throws on negative number", () => { - assertThrows( - () => formatCacheControl({ maxAge: -1 }), - RangeError, - "non-negative integer", - ); -}); - -Deno.test("formatCacheControl() throws on NaN", () => { - assertThrows( - () => formatCacheControl({ maxAge: NaN }), - RangeError, - "non-negative integer", - ); -}); - -Deno.test("parseCacheControl() parses min-fresh", () => { - assertEquals(parseCacheControl("min-fresh=30"), { minFresh: 30 }); -}); - -Deno.test("parseCacheControl() throws when min-fresh has no value", () => { - assertThrows( - () => parseCacheControl("min-fresh"), - SyntaxError, - "requires an integer value", - ); -}); - -Deno.test("parseCacheControl() parses stale-if-error", () => { - assertEquals(parseCacheControl("stale-if-error=300"), { - staleIfError: 300, - }); -}); - -Deno.test("parseCacheControl() throws when stale-if-error has no value", () => { - assertThrows( - () => parseCacheControl("stale-if-error"), - SyntaxError, - "requires an integer value", - ); -}); - -Deno.test("parseCacheControl() throws when s-maxage has no value", () => { - assertThrows( - () => parseCacheControl("s-maxage"), - SyntaxError, - "requires an integer value", - ); -}); - -Deno.test("parseCacheControl() parses no-transform", () => { - assertEquals(parseCacheControl("no-transform"), { noTransform: true }); -}); - -Deno.test("parseCacheControl() parses only-if-cached", () => { - assertEquals(parseCacheControl("only-if-cached"), { onlyIfCached: true }); -}); - -Deno.test("parseCacheControl() parses must-understand", () => { - assertEquals(parseCacheControl("must-understand"), { - mustUnderstand: true, - }); -}); - -Deno.test("parseCacheControl() parses proxy-revalidate", () => { - assertEquals(parseCacheControl("proxy-revalidate"), { - proxyRevalidate: true, - }); -}); - -Deno.test("parseCacheControl() uses first occurrence for duplicate directives", () => { - assertEquals(parseCacheControl("max-age=100, max-age=200"), { maxAge: 100 }); -}); - -Deno.test("parseCacheControl() clamps values above 2^31 to 2147483648", () => { - assertEquals(parseCacheControl("max-age=9999999999"), { - maxAge: 2_147_483_648, - }); -}); - -Deno.test("formatCacheControl() clamps values above 2^31 to 2147483648", () => { - assertEquals( - formatCacheControl({ maxAge: 9_999_999_999 }), - "max-age=2147483648", - ); -}); - -Deno.test("formatCacheControl() serializes empty array as bare directive", () => { - assertEquals(formatCacheControl({ noCache: [] }), "no-cache"); - assertEquals(formatCacheControl({ private: [] }), "private"); -}); - -Deno.test("formatCacheControl() serializes max-stale boolean", () => { - assertEquals(formatCacheControl({ maxStale: true }), "max-stale"); -}); - -Deno.test("formatCacheControl() serializes max-stale with value", () => { - assertEquals(formatCacheControl({ maxStale: 120 }), "max-stale=120"); -}); - -Deno.test("formatCacheControl() serializes all response directives", () => { - assertEquals( - formatCacheControl({ - maxAge: 300, - noCache: true, - noStore: true, - noTransform: true, - sMaxage: 600, - private: true, - public: true, - mustRevalidate: true, - proxyRevalidate: true, - mustUnderstand: true, - immutable: true, - staleWhileRevalidate: 60, - staleIfError: 300, - }), - "max-age=300, no-cache, no-store, no-transform, s-maxage=600, private, public, must-revalidate, proxy-revalidate, must-understand, immutable, stale-while-revalidate=60, stale-if-error=300", - ); -}); - -Deno.test("parseCacheControl() splits correctly when quotes contain commas", () => { - assertEquals( - parseCacheControl('no-cache="foo, bar", max-age=60'), - { noCache: ["foo", "bar"], maxAge: 60 }, - ); -}); - -Deno.test("parseCacheControl() parses private with unquoted value", () => { - assertEquals(parseCacheControl("private=foo"), { private: ["foo"] }); -}); - -Deno.test("parseCacheControl() handles trailing and leading commas", () => { - assertEquals(parseCacheControl(",no-store,"), { noStore: true }); -}); - -Deno.test("parseCacheControl() return type is CacheControl", () => { - const cc = parseCacheControl("no-store"); - assertType>(true); -}); - -Deno.test("formatCacheControl() accepts RequestCacheControl and ResponseCacheControl", () => { - const req: RequestCacheControl = { maxStale: true, noStore: true }; - const res: ResponseCacheControl = { maxAge: 3600, public: true }; - formatCacheControl(req); - formatCacheControl(res); - assertType< - IsExact< - Parameters[0], - RequestCacheControl | ResponseCacheControl - > - >(true); -}); diff --git a/import_map.json b/import_map.json index 7aa140dea113..cd855f6889bf 100644 --- a/import_map.json +++ b/import_map.json @@ -48,4 +48,4 @@ "@std/xml": "jsr:@std/xml@^0.1.0", "@std/yaml": "jsr:@std/yaml@^1.0.12" } -} +} \ No newline at end of file From f4960f5198ac8f48777d987c85611f6654f62d8f Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 27 Mar 2026 08:24:09 +0100 Subject: [PATCH 2/7] feat(http): stabilize `cache_control` --- http/cache_control.ts | 384 +++++++++++++++++++++++++++++++++++++ http/cache_control_test.ts | 301 +++++++++++++++++++++++++++++ 2 files changed, 685 insertions(+) create mode 100644 http/cache_control.ts create mode 100644 http/cache_control_test.ts diff --git a/http/cache_control.ts b/http/cache_control.ts new file mode 100644 index 000000000000..845b55bbf1a5 --- /dev/null +++ b/http/cache_control.ts @@ -0,0 +1,384 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +/** + * Parsing and formatting of the `Cache-Control` HTTP header per + * {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2 | RFC 9111 Section 5.2}. + * + * Provides {@linkcode parseCacheControl} to parse a header value into a typed + * object and {@linkcode formatCacheControl} to serialize back to a header string. + * + * @example Response with Cache-Control and ETag + * ```ts ignore + * import { formatCacheControl } from "@std/http/cache-control"; + * import { eTag } from "@std/http/etag"; + * + * Deno.serve(async (_req) => { + * const body = "hello"; + * const etag = await eTag(body); + * const headers = new Headers(); + * if (etag) headers.set("etag", etag); + * headers.set("cache-control", formatCacheControl({ + * maxAge: 3600, + * private: true, + * mustRevalidate: true, + * })); + * return new Response(body, { headers }); + * }); + * ``` + * + * @example Parse request Cache-Control + * ```ts ignore + * import { parseCacheControl } from "@std/http/cache-control"; + * + * Deno.serve((req) => { + * const cc = parseCacheControl(req.headers.get("cache-control")); + * if (cc.noStore) { + * return new Response(null, { status: 400 }); // client forbids storing + * } + * return new Response("OK"); + * }); + * ``` + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2} + * + * @module + */ + +/** + * Shared Cache-Control directives valid in both request and response. + */ +export interface CacheControlBase { + /** When present, the cache must not store the request or response. */ + noStore?: true; + /** When present, the cache must not transform the payload. */ + noTransform?: true; + /** + * Maximum age in seconds. In a request: accept responses whose age is no + * greater than this. In a response: the response is stale after this many seconds. + */ + maxAge?: number; +} + +/** + * Cache-Control directives for requests (e.g. from a client). + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} + */ +export interface RequestCacheControl extends CacheControlBase { + /** When present, the cache must not use a stored response without revalidation. */ + noCache?: true; + /** + * Accept stale responses. If a number, accept responses that have been stale + * for no more than this many seconds. If `true`, accept any staleness. + */ + maxStale?: number | true; + /** Require the response to be fresh for at least this many seconds. */ + minFresh?: number; + /** Only return a cached response; do not forward the request to the origin. */ + onlyIfCached?: true; +} + +/** + * Cache-Control directives for responses (e.g. from a server). + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2} + */ +export interface ResponseCacheControl extends CacheControlBase { + /** + * When `true`, the response must not be used from cache without revalidation. + * When an array, only the listed response header fields require revalidation; + * the rest of the response may be used without it. + */ + noCache?: true | string[]; + /** + * When `true`, the response is intended for a single user (private cache only). + * When an array, only the listed header field names are private. + */ + private?: true | string[]; + /** The response may be stored by any cache. */ + public?: true; + /** Shared (e.g. CDN) cache maximum age in seconds. Overrides max-age for shared caches. */ + sMaxage?: number; + /** When stale, the cache must revalidate before using the response. */ + mustRevalidate?: true; + /** Same as must-revalidate but applies only to shared caches. */ + proxyRevalidate?: true; + /** The cache must not use the response if it does not understand the directive. */ + mustUnderstand?: true; + /** The response will not change while fresh; caches may reuse it without revalidation. */ + immutable?: true; + /** Allow use of stale response while revalidating in the background (seconds). */ + staleWhileRevalidate?: number; + /** Allow use of stale response if revalidation fails (seconds). */ + staleIfError?: number; +} + +/** + * Parsed Cache-Control value. Union of request and response types when direction + * is unknown (e.g. after parsing a raw header string). + */ +export type CacheControl = RequestCacheControl | ResponseCacheControl; + +/** Union of every directive field across both interfaces, using the widest type + * for fields that appear in both (e.g. `noCache` becomes `true | string[]`). + * Avoids per-field type assertions during parsing and formatting. */ +type AllCacheControlFields = { + [K in keyof RequestCacheControl | keyof ResponseCacheControl]?: K extends + keyof RequestCacheControl + ? K extends keyof ResponseCacheControl + ? RequestCacheControl[K] | ResponseCacheControl[K] + : RequestCacheControl[K] + : K extends keyof ResponseCacheControl ? ResponseCacheControl[K] + : never; +}; + +/** Upper bound for delta-seconds per RFC 9111 §1.2.2. Values above this are + * clamped to this sentinel which represents "infinity" (~68 years). */ +const MAX_DELTA_SECONDS = 2_147_483_648; // 2^31 + +function parseNonNegativeInt(value: string, directive: string): number { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new SyntaxError( + `Cache-Control: invalid value for ${directive}: "${value}"`, + ); + } + const n = Number(trimmed); + return n > MAX_DELTA_SECONDS ? MAX_DELTA_SECONDS : n; +} + +/** Split by comma but not inside double-quoted strings (needed for + * `no-cache` and `private` whose quoted-string arguments may contain commas). */ +function splitDirectives(value: string): string[] { + // Fast path: no quotes means a simple split is safe. + if (!value.includes('"')) return value.split(","); + + const parts: string[] = []; + let start = 0; + let inQuotes = false; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 34 /* " */) { + inQuotes = !inQuotes; + } else if (c === 44 /* , */ && !inQuotes) { + parts.push(value.slice(start, i)); + start = i + 1; + } + } + parts.push(value.slice(start)); + return parts; +} + +/** Parse a comma-separated list of HTTP field names from a directive argument. + * Strips surrounding double quotes if present and unescapes `\"` sequences. + * Returns an array of trimmed, non-empty field names. */ +function parseFieldNames(value: string): string[] { + const t = value.trim(); + const parsed = t.length >= 2 && t.startsWith('"') && t.endsWith('"') + ? t.slice(1, -1).replace(/\\"/g, '"') + : t; + return parsed.split(",").map((s) => s.trim()).filter(Boolean); +} + +/** + * Parses a `Cache-Control` header value into a typed object. Returns an empty + * object for `null` or empty string. Directive names are case-insensitive. + * Unknown directives are ignored per RFC 9111. Throws on malformed values for + * known directives (e.g. `max-age=abc`). + * + * @param value The header value (e.g. `headers.get("cache-control")`). + * @returns Parsed directives as a union of request/response types. + * + * @example Usage + * ```ts + * import { parseCacheControl } from "@std/http/cache-control"; + * import { assertEquals } from "@std/assert"; + * + * const cc = parseCacheControl("max-age=3600, no-store"); + * assertEquals(cc.maxAge, 3600); + * assertEquals(cc.noStore, true); + * ``` + * + * @throws {SyntaxError} If a known directive has a malformed value (e.g. + * `max-age=abc`) or a required value is missing (e.g. bare `max-age`). + */ +export function parseCacheControl(value: string | null): CacheControl { + const result: AllCacheControlFields = {}; + if (value === null || value.trim() === "") { + return result as CacheControl; + } + + const seen = new Set(); + const parts = splitDirectives(value); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed === "") continue; + + const eq = trimmed.indexOf("="); + const name = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim() + .toLowerCase(); + const rawValue = eq === -1 ? undefined : trimmed.slice(eq + 1).trim(); + + // RFC 9111 §4.2.1: when a directive appears more than once, use the first + // occurrence. Track seen directive names to skip subsequent duplicates. + if (seen.has(name)) continue; + seen.add(name); + + switch (name) { + case "max-age": + if (rawValue === undefined) { + throw new SyntaxError( + `Cache-Control: ${name} requires an integer value`, + ); + } + result.maxAge = parseNonNegativeInt(rawValue, name); + break; + case "max-stale": + result.maxStale = rawValue === undefined + ? true + : parseNonNegativeInt(rawValue, name); + break; + case "min-fresh": + if (rawValue === undefined) { + throw new SyntaxError( + `Cache-Control: ${name} requires an integer value`, + ); + } + result.minFresh = parseNonNegativeInt(rawValue, name); + break; + case "no-cache": + result.noCache = rawValue === undefined + ? true + : parseFieldNames(rawValue); + break; + case "no-store": + result.noStore = true; + break; + case "no-transform": + result.noTransform = true; + break; + case "only-if-cached": + result.onlyIfCached = true; + break; + case "must-revalidate": + result.mustRevalidate = true; + break; + case "must-understand": + result.mustUnderstand = true; + break; + case "proxy-revalidate": + result.proxyRevalidate = true; + break; + case "public": + result.public = true; + break; + case "s-maxage": + if (rawValue === undefined) { + throw new SyntaxError( + `Cache-Control: ${name} requires an integer value`, + ); + } + result.sMaxage = parseNonNegativeInt(rawValue, name); + break; + case "private": + result.private = rawValue === undefined + ? true + : parseFieldNames(rawValue); + break; + case "immutable": + result.immutable = true; + break; + case "stale-while-revalidate": + if (rawValue === undefined) { + throw new SyntaxError( + `Cache-Control: ${name} requires an integer value`, + ); + } + result.staleWhileRevalidate = parseNonNegativeInt(rawValue, name); + break; + case "stale-if-error": + if (rawValue === undefined) { + throw new SyntaxError( + `Cache-Control: ${name} requires an integer value`, + ); + } + result.staleIfError = parseNonNegativeInt(rawValue, name); + break; + default: + // Unknown directives are ignored per RFC 9111 §5.2.3. + continue; + } + } + + return result as CacheControl; +} + +function append( + out: string[], + directive: string, + value?: number | true | string[], +): void { + if (value === undefined) return; + if (value === true) { + out.push(directive); + return; + } + if (typeof value === "number") { + if (!Number.isInteger(value) || value < 0) { + throw new RangeError( + `Cache-Control: ${directive} must be a non-negative integer, got ${value}`, + ); + } + // Clamp to MAX_DELTA_SECONDS to match parser behavior (RFC 9111 §1.2.2). + out.push(`${directive}=${Math.min(value, MAX_DELTA_SECONDS)}`); + return; + } + if (value.length === 0) { + out.push(directive); + return; + } + out.push(`${directive}="${value.join(", ")}"`); +} + +/** + * Serializes a Cache-Control object to a header value string. Output is + * lowercase and comma-separated. Empty object produces an empty string. + * + * @param cc The Cache-Control object (request or response). + * @returns The header value string, or empty string if no directives. + * + * @example Usage + * ```ts + * import { formatCacheControl } from "@std/http/cache-control"; + * import { assertEquals } from "@std/assert"; + * + * const value = formatCacheControl({ maxAge: 300, mustRevalidate: true }); + * assertEquals(value, "max-age=300, must-revalidate"); + * ``` + * + * @throws {RangeError} If a numeric directive value is not a non-negative + * integer (e.g. `NaN`, `Infinity`, `-1`, or `3.14`). + */ +export function formatCacheControl(cc: CacheControl): string { + const d: AllCacheControlFields = cc; + const out: string[] = []; + append(out, "max-age", d.maxAge); + append(out, "no-cache", d.noCache); + append(out, "no-store", d.noStore); + append(out, "no-transform", d.noTransform); + append(out, "max-stale", d.maxStale); + append(out, "min-fresh", d.minFresh); + append(out, "only-if-cached", d.onlyIfCached); + append(out, "s-maxage", d.sMaxage); + append(out, "private", d.private); + append(out, "public", d.public); + append(out, "must-revalidate", d.mustRevalidate); + append(out, "proxy-revalidate", d.proxyRevalidate); + append(out, "must-understand", d.mustUnderstand); + append(out, "immutable", d.immutable); + append(out, "stale-while-revalidate", d.staleWhileRevalidate); + append(out, "stale-if-error", d.staleIfError); + + return out.join(", "); +} diff --git a/http/cache_control_test.ts b/http/cache_control_test.ts new file mode 100644 index 000000000000..92e8954aeedd --- /dev/null +++ b/http/cache_control_test.ts @@ -0,0 +1,301 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +import { assertEquals, assertThrows } from "@std/assert"; +import { assertType, type IsExact } from "@std/testing/types"; +import { + type CacheControl, + formatCacheControl, + parseCacheControl, + type RequestCacheControl, + type ResponseCacheControl, +} from "./cache_control.ts"; + +Deno.test("parseCacheControl() returns empty object for null", () => { + assertEquals(parseCacheControl(null), {}); +}); + +Deno.test("parseCacheControl() returns empty object for empty string", () => { + assertEquals(parseCacheControl(""), {}); + assertEquals(parseCacheControl(" "), {}); +}); + +Deno.test("parseCacheControl() parses single boolean directive", () => { + assertEquals(parseCacheControl("no-store"), { noStore: true }); + assertEquals(parseCacheControl("no-cache"), { noCache: true }); + assertEquals(parseCacheControl("public"), { public: true }); + assertEquals(parseCacheControl("immutable"), { immutable: true }); +}); + +Deno.test("parseCacheControl() parses single valued directive", () => { + assertEquals(parseCacheControl("max-age=3600"), { maxAge: 3600 }); + assertEquals(parseCacheControl("s-maxage=0"), { sMaxage: 0 }); + assertEquals(parseCacheControl("stale-while-revalidate=60"), { + staleWhileRevalidate: 60, + }); +}); + +Deno.test("parseCacheControl() parses multiple directives", () => { + assertEquals( + parseCacheControl("max-age=300, private, must-revalidate"), + { maxAge: 300, private: true, mustRevalidate: true }, + ); +}); + +Deno.test("parseCacheControl() parses no-cache with field names", () => { + assertEquals(parseCacheControl('no-cache="foo"'), { noCache: ["foo"] }); + assertEquals(parseCacheControl('no-cache="foo, bar"'), { + noCache: ["foo", "bar"], + }); +}); + +Deno.test("parseCacheControl() parses private with field names", () => { + assertEquals(parseCacheControl('private="x-custom"'), { + private: ["x-custom"], + }); +}); + +Deno.test("parseCacheControl() parses max-stale with and without value", () => { + assertEquals(parseCacheControl("max-stale"), { maxStale: true }); + assertEquals(parseCacheControl("max-stale=120"), { maxStale: 120 }); +}); + +Deno.test("parseCacheControl() is case insensitive", () => { + assertEquals(parseCacheControl("NO-STORE"), { noStore: true }); + assertEquals(parseCacheControl("Max-Age=100"), { maxAge: 100 }); +}); + +Deno.test("parseCacheControl() ignores unknown directives", () => { + assertEquals(parseCacheControl("no-store, unknown=1, no-cache"), { + noStore: true, + noCache: true, + }); +}); + +Deno.test("parseCacheControl() throws on malformed numeric value", () => { + assertThrows( + () => parseCacheControl("max-age=abc"), + SyntaxError, + "invalid value", + ); + assertThrows( + () => parseCacheControl("max-age=-1"), + SyntaxError, + "invalid value", + ); + assertThrows( + () => parseCacheControl("s-maxage=1.5"), + SyntaxError, + "invalid value", + ); +}); + +Deno.test("parseCacheControl() throws when valued directive has no value", () => { + assertThrows( + () => parseCacheControl("max-age"), + SyntaxError, + "requires an integer value", + ); + assertThrows( + () => parseCacheControl("stale-while-revalidate"), + SyntaxError, + "requires an integer value", + ); +}); + +Deno.test("formatCacheControl() returns empty string for empty object", () => { + assertEquals(formatCacheControl({}), ""); +}); + +Deno.test("formatCacheControl() serializes boolean directives", () => { + assertEquals(formatCacheControl({ noStore: true }), "no-store"); + assertEquals( + formatCacheControl({ noStore: true, noTransform: true }), + "no-store, no-transform", + ); +}); + +Deno.test("formatCacheControl() serializes valued directives", () => { + assertEquals(formatCacheControl({ maxAge: 3600 }), "max-age=3600"); + assertEquals( + formatCacheControl({ maxAge: 0, sMaxage: 100 }), + "max-age=0, s-maxage=100", + ); +}); + +Deno.test("formatCacheControl() serializes no-cache and private with field names", () => { + assertEquals( + formatCacheControl({ noCache: ["foo", "bar"] }), + 'no-cache="foo, bar"', + ); + assertEquals( + formatCacheControl({ private: ["x-custom"] }), + 'private="x-custom"', + ); +}); + +Deno.test("formatCacheControl() round-trip", () => { + const value = + "max-age=300, private, must-revalidate, stale-while-revalidate=60"; + const parsed = parseCacheControl(value); + const formatted = formatCacheControl(parsed); + const reparsed = parseCacheControl(formatted); + assertEquals(parsed, reparsed); + assertEquals( + formatted, + "max-age=300, private, must-revalidate, stale-while-revalidate=60", + ); +}); + +Deno.test("formatCacheControl() throws on negative number", () => { + assertThrows( + () => formatCacheControl({ maxAge: -1 }), + RangeError, + "non-negative integer", + ); +}); + +Deno.test("formatCacheControl() throws on NaN", () => { + assertThrows( + () => formatCacheControl({ maxAge: NaN }), + RangeError, + "non-negative integer", + ); +}); + +Deno.test("parseCacheControl() parses min-fresh", () => { + assertEquals(parseCacheControl("min-fresh=30"), { minFresh: 30 }); +}); + +Deno.test("parseCacheControl() throws when min-fresh has no value", () => { + assertThrows( + () => parseCacheControl("min-fresh"), + SyntaxError, + "requires an integer value", + ); +}); + +Deno.test("parseCacheControl() parses stale-if-error", () => { + assertEquals(parseCacheControl("stale-if-error=300"), { + staleIfError: 300, + }); +}); + +Deno.test("parseCacheControl() throws when stale-if-error has no value", () => { + assertThrows( + () => parseCacheControl("stale-if-error"), + SyntaxError, + "requires an integer value", + ); +}); + +Deno.test("parseCacheControl() throws when s-maxage has no value", () => { + assertThrows( + () => parseCacheControl("s-maxage"), + SyntaxError, + "requires an integer value", + ); +}); + +Deno.test("parseCacheControl() parses no-transform", () => { + assertEquals(parseCacheControl("no-transform"), { noTransform: true }); +}); + +Deno.test("parseCacheControl() parses only-if-cached", () => { + assertEquals(parseCacheControl("only-if-cached"), { onlyIfCached: true }); +}); + +Deno.test("parseCacheControl() parses must-understand", () => { + assertEquals(parseCacheControl("must-understand"), { + mustUnderstand: true, + }); +}); + +Deno.test("parseCacheControl() parses proxy-revalidate", () => { + assertEquals(parseCacheControl("proxy-revalidate"), { + proxyRevalidate: true, + }); +}); + +Deno.test("parseCacheControl() uses first occurrence for duplicate directives", () => { + assertEquals(parseCacheControl("max-age=100, max-age=200"), { maxAge: 100 }); +}); + +Deno.test("parseCacheControl() clamps values above 2^31 to 2147483648", () => { + assertEquals(parseCacheControl("max-age=9999999999"), { + maxAge: 2_147_483_648, + }); +}); + +Deno.test("formatCacheControl() clamps values above 2^31 to 2147483648", () => { + assertEquals( + formatCacheControl({ maxAge: 9_999_999_999 }), + "max-age=2147483648", + ); +}); + +Deno.test("formatCacheControl() serializes empty array as bare directive", () => { + assertEquals(formatCacheControl({ noCache: [] }), "no-cache"); + assertEquals(formatCacheControl({ private: [] }), "private"); +}); + +Deno.test("formatCacheControl() serializes max-stale boolean", () => { + assertEquals(formatCacheControl({ maxStale: true }), "max-stale"); +}); + +Deno.test("formatCacheControl() serializes max-stale with value", () => { + assertEquals(formatCacheControl({ maxStale: 120 }), "max-stale=120"); +}); + +Deno.test("formatCacheControl() serializes all response directives", () => { + assertEquals( + formatCacheControl({ + maxAge: 300, + noCache: true, + noStore: true, + noTransform: true, + sMaxage: 600, + private: true, + public: true, + mustRevalidate: true, + proxyRevalidate: true, + mustUnderstand: true, + immutable: true, + staleWhileRevalidate: 60, + staleIfError: 300, + }), + "max-age=300, no-cache, no-store, no-transform, s-maxage=600, private, public, must-revalidate, proxy-revalidate, must-understand, immutable, stale-while-revalidate=60, stale-if-error=300", + ); +}); + +Deno.test("parseCacheControl() splits correctly when quotes contain commas", () => { + assertEquals( + parseCacheControl('no-cache="foo, bar", max-age=60'), + { noCache: ["foo", "bar"], maxAge: 60 }, + ); +}); + +Deno.test("parseCacheControl() parses private with unquoted value", () => { + assertEquals(parseCacheControl("private=foo"), { private: ["foo"] }); +}); + +Deno.test("parseCacheControl() handles trailing and leading commas", () => { + assertEquals(parseCacheControl(",no-store,"), { noStore: true }); +}); + +Deno.test("parseCacheControl() return type is CacheControl", () => { + const cc = parseCacheControl("no-store"); + assertType>(true); +}); + +Deno.test("formatCacheControl() accepts RequestCacheControl and ResponseCacheControl", () => { + const req: RequestCacheControl = { maxStale: true, noStore: true }; + const res: ResponseCacheControl = { maxAge: 3600, public: true }; + formatCacheControl(req); + formatCacheControl(res); + assertType< + IsExact< + Parameters[0], + RequestCacheControl | ResponseCacheControl + > + >(true); +}); From 66b4f60367e2a0a85269ec2772467bb270988fd6 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 27 Mar 2026 08:25:24 +0100 Subject: [PATCH 3/7] feat(http): stabilize `cache_control` --- import_map.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/import_map.json b/import_map.json index cd855f6889bf..7aa140dea113 100644 --- a/import_map.json +++ b/import_map.json @@ -48,4 +48,4 @@ "@std/xml": "jsr:@std/xml@^0.1.0", "@std/yaml": "jsr:@std/yaml@^1.0.12" } -} \ No newline at end of file +} From 2e681dc787c4f49f7664228bec2e4ba68d6a8c07 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 7 Apr 2026 19:37:54 +0200 Subject: [PATCH 4/7] fix edge cases --- http/cache_control.ts | 79 +++++++++++++++++++++++--------------- http/cache_control_test.ts | 26 ++++++++++++- 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/http/cache_control.ts b/http/cache_control.ts index 845b55bbf1a5..79315df44cfd 100644 --- a/http/cache_control.ts +++ b/http/cache_control.ts @@ -45,10 +45,8 @@ * @module */ -/** - * Shared Cache-Control directives valid in both request and response. - */ -export interface CacheControlBase { +/** Shared Cache-Control directives valid in both request and response. */ +interface CacheControlBase { /** When present, the cache must not store the request or response. */ noStore?: true; /** When present, the cache must not transform the payload. */ @@ -58,6 +56,8 @@ export interface CacheControlBase { * greater than this. In a response: the response is stale after this many seconds. */ maxAge?: number; + /** Allow use of stale response if revalidation fails (seconds). */ + staleIfError?: number; } /** @@ -110,20 +110,14 @@ export interface ResponseCacheControl extends CacheControlBase { immutable?: true; /** Allow use of stale response while revalidating in the background (seconds). */ staleWhileRevalidate?: number; - /** Allow use of stale response if revalidation fails (seconds). */ - staleIfError?: number; } /** - * Parsed Cache-Control value. Union of request and response types when direction - * is unknown (e.g. after parsing a raw header string). + * Parsed Cache-Control value. Contains all directives from both request and + * response contexts with the widest applicable types. Returned by + * {@linkcode parseCacheControl} and accepted by {@linkcode formatCacheControl}. */ -export type CacheControl = RequestCacheControl | ResponseCacheControl; - -/** Union of every directive field across both interfaces, using the widest type - * for fields that appear in both (e.g. `noCache` becomes `true | string[]`). - * Avoids per-field type assertions during parsing and formatting. */ -type AllCacheControlFields = { +export type CacheControl = { [K in keyof RequestCacheControl | keyof ResponseCacheControl]?: K extends keyof RequestCacheControl ? K extends keyof ResponseCacheControl @@ -137,9 +131,11 @@ type AllCacheControlFields = { * clamped to this sentinel which represents "infinity" (~68 years). */ const MAX_DELTA_SECONDS = 2_147_483_648; // 2^31 +const DIGITS_REGEXP = /^\d+$/; + function parseNonNegativeInt(value: string, directive: string): number { const trimmed = value.trim(); - if (!/^\d+$/.test(trimmed)) { + if (!DIGITS_REGEXP.test(trimmed)) { throw new SyntaxError( `Cache-Control: invalid value for ${directive}: "${value}"`, ); @@ -159,7 +155,9 @@ function splitDirectives(value: string): string[] { let inQuotes = false; for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); - if (c === 34 /* " */) { + if (c === 92 /* \ */ && inQuotes) { + i++; + } else if (c === 34 /* " */) { inQuotes = !inQuotes; } else if (c === 44 /* , */ && !inQuotes) { parts.push(value.slice(start, i)); @@ -204,12 +202,11 @@ function parseFieldNames(value: string): string[] { * `max-age=abc`) or a required value is missing (e.g. bare `max-age`). */ export function parseCacheControl(value: string | null): CacheControl { - const result: AllCacheControlFields = {}; + const result: CacheControl = {}; if (value === null || value.trim() === "") { return result as CacheControl; } - const seen = new Set(); const parts = splitDirectives(value); for (const part of parts) { const trimmed = part.trim(); @@ -221,12 +218,10 @@ export function parseCacheControl(value: string | null): CacheControl { const rawValue = eq === -1 ? undefined : trimmed.slice(eq + 1).trim(); // RFC 9111 §4.2.1: when a directive appears more than once, use the first - // occurrence. Track seen directive names to skip subsequent duplicates. - if (seen.has(name)) continue; - seen.add(name); - + // occurrence. Skip if the property is already set on result. switch (name) { case "max-age": + if (result.maxAge !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -235,11 +230,13 @@ export function parseCacheControl(value: string | null): CacheControl { result.maxAge = parseNonNegativeInt(rawValue, name); break; case "max-stale": + if (result.maxStale !== undefined) continue; result.maxStale = rawValue === undefined ? true : parseNonNegativeInt(rawValue, name); break; case "min-fresh": + if (result.minFresh !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -247,33 +244,47 @@ export function parseCacheControl(value: string | null): CacheControl { } result.minFresh = parseNonNegativeInt(rawValue, name); break; - case "no-cache": - result.noCache = rawValue === undefined - ? true + case "no-cache": { + if (result.noCache !== undefined) continue; + const noCacheFields = rawValue === undefined + ? undefined : parseFieldNames(rawValue); + result.noCache = + noCacheFields === undefined || noCacheFields.length === 0 + ? true + : noCacheFields; break; + } case "no-store": + if (result.noStore !== undefined) continue; result.noStore = true; break; case "no-transform": + if (result.noTransform !== undefined) continue; result.noTransform = true; break; case "only-if-cached": + if (result.onlyIfCached !== undefined) continue; result.onlyIfCached = true; break; case "must-revalidate": + if (result.mustRevalidate !== undefined) continue; result.mustRevalidate = true; break; case "must-understand": + if (result.mustUnderstand !== undefined) continue; result.mustUnderstand = true; break; case "proxy-revalidate": + if (result.proxyRevalidate !== undefined) continue; result.proxyRevalidate = true; break; case "public": + if (result.public !== undefined) continue; result.public = true; break; case "s-maxage": + if (result.sMaxage !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -281,15 +292,23 @@ export function parseCacheControl(value: string | null): CacheControl { } result.sMaxage = parseNonNegativeInt(rawValue, name); break; - case "private": - result.private = rawValue === undefined - ? true + case "private": { + if (result.private !== undefined) continue; + const privateFields = rawValue === undefined + ? undefined : parseFieldNames(rawValue); + result.private = + privateFields === undefined || privateFields.length === 0 + ? true + : privateFields; break; + } case "immutable": + if (result.immutable !== undefined) continue; result.immutable = true; break; case "stale-while-revalidate": + if (result.staleWhileRevalidate !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -298,6 +317,7 @@ export function parseCacheControl(value: string | null): CacheControl { result.staleWhileRevalidate = parseNonNegativeInt(rawValue, name); break; case "stale-if-error": + if (result.staleIfError !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -306,7 +326,6 @@ export function parseCacheControl(value: string | null): CacheControl { result.staleIfError = parseNonNegativeInt(rawValue, name); break; default: - // Unknown directives are ignored per RFC 9111 §5.2.3. continue; } } @@ -361,7 +380,7 @@ function append( * integer (e.g. `NaN`, `Infinity`, `-1`, or `3.14`). */ export function formatCacheControl(cc: CacheControl): string { - const d: AllCacheControlFields = cc; + const d: CacheControl = cc; const out: string[] = []; append(out, "max-age", d.maxAge); append(out, "no-cache", d.noCache); diff --git a/http/cache_control_test.ts b/http/cache_control_test.ts index 92e8954aeedd..b64b5225e8b4 100644 --- a/http/cache_control_test.ts +++ b/http/cache_control_test.ts @@ -287,6 +287,30 @@ Deno.test("parseCacheControl() return type is CacheControl", () => { assertType>(true); }); +Deno.test("parseCacheControl() splits correctly when quoted values contain escaped quotes", () => { + assertEquals( + parseCacheControl('no-cache="x-\\"header", max-age=60'), + { noCache: ['x-"header'], maxAge: 60 }, + ); +}); + +Deno.test("parseCacheControl() normalizes no-cache with empty value to true", () => { + assertEquals(parseCacheControl("no-cache="), { noCache: true }); + assertEquals(parseCacheControl('no-cache=""'), { noCache: true }); +}); + +Deno.test("parseCacheControl() normalizes private with empty value to true", () => { + assertEquals(parseCacheControl("private="), { private: true }); + assertEquals(parseCacheControl('private=""'), { private: true }); +}); + +Deno.test("parseCacheControl() round-trips no-cache with empty value", () => { + const parsed = parseCacheControl("no-cache="); + const formatted = formatCacheControl(parsed); + const reparsed = parseCacheControl(formatted); + assertEquals(parsed, reparsed); +}); + Deno.test("formatCacheControl() accepts RequestCacheControl and ResponseCacheControl", () => { const req: RequestCacheControl = { maxStale: true, noStore: true }; const res: ResponseCacheControl = { maxAge: 3600, public: true }; @@ -295,7 +319,7 @@ Deno.test("formatCacheControl() accepts RequestCacheControl and ResponseCacheCon assertType< IsExact< Parameters[0], - RequestCacheControl | ResponseCacheControl + CacheControl > >(true); }); From 0315ee5c33101968459642819dd4cb19f29d5f88 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 7 Apr 2026 21:55:05 +0200 Subject: [PATCH 5/7] coverage --- http/cache_control.ts | 29 +++++++++++-------- http/cache_control_test.ts | 58 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/http/cache_control.ts b/http/cache_control.ts index 79315df44cfd..b6045c6b5d61 100644 --- a/http/cache_control.ts +++ b/http/cache_control.ts @@ -45,8 +45,12 @@ * @module */ -/** Shared Cache-Control directives valid in both request and response. */ -interface CacheControlBase { +/** + * Cache-Control directives for requests (e.g. from a client). + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} + */ +export interface RequestCacheControl { /** When present, the cache must not store the request or response. */ noStore?: true; /** When present, the cache must not transform the payload. */ @@ -58,14 +62,6 @@ interface CacheControlBase { maxAge?: number; /** Allow use of stale response if revalidation fails (seconds). */ staleIfError?: number; -} - -/** - * Cache-Control directives for requests (e.g. from a client). - * - * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} - */ -export interface RequestCacheControl extends CacheControlBase { /** When present, the cache must not use a stored response without revalidation. */ noCache?: true; /** @@ -84,7 +80,18 @@ export interface RequestCacheControl extends CacheControlBase { * * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2} */ -export interface ResponseCacheControl extends CacheControlBase { +export interface ResponseCacheControl { + /** When present, the cache must not store the request or response. */ + noStore?: true; + /** When present, the cache must not transform the payload. */ + noTransform?: true; + /** + * Maximum age in seconds. In a request: accept responses whose age is no + * greater than this. In a response: the response is stale after this many seconds. + */ + maxAge?: number; + /** Allow use of stale response if revalidation fails (seconds). */ + staleIfError?: number; /** * When `true`, the response must not be used from cache without revalidation. * When an array, only the listed response header fields require revalidation; diff --git a/http/cache_control_test.ts b/http/cache_control_test.ts index b64b5225e8b4..1ee2daf015be 100644 --- a/http/cache_control_test.ts +++ b/http/cache_control_test.ts @@ -220,6 +220,64 @@ Deno.test("parseCacheControl() uses first occurrence for duplicate directives", assertEquals(parseCacheControl("max-age=100, max-age=200"), { maxAge: 100 }); }); +Deno.test( + "parseCacheControl() uses first occurrence when request-leaning directives repeat", + () => { + assertEquals( + parseCacheControl( + [ + "max-stale, max-stale=9", + "min-fresh=10, min-fresh=20", + 'no-cache="a", no-cache="b"', + "no-store, no-store", + "no-transform, no-transform", + "only-if-cached, only-if-cached", + ].join(", "), + ), + { + maxStale: true, + minFresh: 10, + noCache: ["a"], + noStore: true, + noTransform: true, + onlyIfCached: true, + }, + ); + }, +); + +Deno.test( + "parseCacheControl() uses first occurrence when response-leaning directives repeat", + () => { + assertEquals( + parseCacheControl( + [ + "must-revalidate, must-revalidate", + "must-understand, must-understand", + "proxy-revalidate, proxy-revalidate", + "public, public", + "s-maxage=1, s-maxage=2", + 'private="x", private="y"', + "immutable, immutable", + "stale-while-revalidate=1, stale-while-revalidate=2", + "stale-if-error=1, stale-if-error=2", + ].join(", "), + ), + { + mustRevalidate: true, + mustUnderstand: true, + proxyRevalidate: true, + public: true, + sMaxage: 1, + private: ["x"], + immutable: true, + staleWhileRevalidate: 1, + staleIfError: 1, + }, + ); + }, +); + Deno.test("parseCacheControl() clamps values above 2^31 to 2147483648", () => { assertEquals(parseCacheControl("max-age=9999999999"), { maxAge: 2_147_483_648, From 197ce505c781c01eecabbf8a934e254f16bc9032 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Tue, 7 Apr 2026 22:59:04 +0200 Subject: [PATCH 6/7] revert stabilization --- http/deno.json | 4 ++-- http/mod.ts | 6 ------ ...e_control.ts => unstable_cache_control.ts} | 20 +++++++++++++++---- ...test.ts => unstable_cache_control_test.ts} | 2 +- 4 files changed, 19 insertions(+), 13 deletions(-) rename http/{cache_control.ts => unstable_cache_control.ts} (95%) rename http/{cache_control_test.ts => unstable_cache_control_test.ts} (99%) diff --git a/http/deno.json b/http/deno.json index 0eda351c46f6..254955f54d51 100644 --- a/http/deno.json +++ b/http/deno.json @@ -3,7 +3,6 @@ "version": "1.0.25", "exports": { ".": "./mod.ts", - "./cache-control": "./cache_control.ts", "./cookie": "./cookie.ts", "./etag": "./etag.ts", "./file-server": "./file_server.ts", @@ -20,6 +19,7 @@ "./unstable-signed-cookie": "./unstable_signed_cookie.ts", "./unstable-structured-fields": "./unstable_structured_fields.ts", "./user-agent": "./user_agent.ts", - "./unstable-route": "./unstable_route.ts" + "./unstable-route": "./unstable_route.ts", + "./unstable-cache-control": "./unstable_cache_control.ts" } } diff --git a/http/mod.ts b/http/mod.ts index c4a3b90ecf63..67203f6e4d59 100644 --- a/http/mod.ts +++ b/http/mod.ts @@ -40,11 +40,6 @@ * > {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Charset | clients omit and servers should ignore} * > therefore is not provided. * - * ## Cache-Control - * - * {@linkcode parseCacheControl} and {@linkcode formatCacheControl} parse and serialize the - * `Cache-Control` header per RFC 9111 §5.2. - * * ## User agent handling * * The {@linkcode UserAgent} class provides user agent string parsing, allowing @@ -102,7 +97,6 @@ * @module */ -export * from "./cache_control.ts"; export * from "./cookie.ts"; export * from "./etag.ts"; export * from "./status.ts"; diff --git a/http/cache_control.ts b/http/unstable_cache_control.ts similarity index 95% rename from http/cache_control.ts rename to http/unstable_cache_control.ts index b6045c6b5d61..b73b2f8f3cd5 100644 --- a/http/cache_control.ts +++ b/http/unstable_cache_control.ts @@ -10,7 +10,7 @@ * * @example Response with Cache-Control and ETag * ```ts ignore - * import { formatCacheControl } from "@std/http/cache-control"; + * import { formatCacheControl } from "@std/http/unstable-cache-control"; * import { eTag } from "@std/http/etag"; * * Deno.serve(async (_req) => { @@ -29,7 +29,7 @@ * * @example Parse request Cache-Control * ```ts ignore - * import { parseCacheControl } from "@std/http/cache-control"; + * import { parseCacheControl } from "@std/http/unstable-cache-control"; * * Deno.serve((req) => { * const cc = parseCacheControl(req.headers.get("cache-control")); @@ -40,6 +40,8 @@ * }); * ``` * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2} * * @module @@ -48,6 +50,8 @@ /** * Cache-Control directives for requests (e.g. from a client). * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} */ export interface RequestCacheControl { @@ -78,6 +82,8 @@ export interface RequestCacheControl { /** * Cache-Control directives for responses (e.g. from a server). * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2} */ export interface ResponseCacheControl { @@ -123,6 +129,8 @@ export interface ResponseCacheControl { * Parsed Cache-Control value. Contains all directives from both request and * response contexts with the widest applicable types. Returned by * {@linkcode parseCacheControl} and accepted by {@linkcode formatCacheControl}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. */ export type CacheControl = { [K in keyof RequestCacheControl | keyof ResponseCacheControl]?: K extends @@ -192,12 +200,14 @@ function parseFieldNames(value: string): string[] { * Unknown directives are ignored per RFC 9111. Throws on malformed values for * known directives (e.g. `max-age=abc`). * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @param value The header value (e.g. `headers.get("cache-control")`). * @returns Parsed directives as a union of request/response types. * * @example Usage * ```ts - * import { parseCacheControl } from "@std/http/cache-control"; + * import { parseCacheControl } from "@std/http/unstable-cache-control"; * import { assertEquals } from "@std/assert"; * * const cc = parseCacheControl("max-age=3600, no-store"); @@ -371,12 +381,14 @@ function append( * Serializes a Cache-Control object to a header value string. Output is * lowercase and comma-separated. Empty object produces an empty string. * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * * @param cc The Cache-Control object (request or response). * @returns The header value string, or empty string if no directives. * * @example Usage * ```ts - * import { formatCacheControl } from "@std/http/cache-control"; + * import { formatCacheControl } from "@std/http/unstable-cache-control"; * import { assertEquals } from "@std/assert"; * * const value = formatCacheControl({ maxAge: 300, mustRevalidate: true }); diff --git a/http/cache_control_test.ts b/http/unstable_cache_control_test.ts similarity index 99% rename from http/cache_control_test.ts rename to http/unstable_cache_control_test.ts index 1ee2daf015be..dc6ce1b7472c 100644 --- a/http/cache_control_test.ts +++ b/http/unstable_cache_control_test.ts @@ -8,7 +8,7 @@ import { parseCacheControl, type RequestCacheControl, type ResponseCacheControl, -} from "./cache_control.ts"; +} from "./unstable_cache_control.ts"; Deno.test("parseCacheControl() returns empty object for null", () => { assertEquals(parseCacheControl(null), {}); From b2bdefe2d09afbac7916da0817cdb741bb5db302 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Fri, 10 Apr 2026 21:26:33 +0200 Subject: [PATCH 7/7] fix --- http/unstable_cache_control.ts | 52 +++++++++++++--------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/http/unstable_cache_control.ts b/http/unstable_cache_control.ts index b73b2f8f3cd5..c43b643fc47a 100644 --- a/http/unstable_cache_control.ts +++ b/http/unstable_cache_control.ts @@ -48,13 +48,13 @@ */ /** - * Cache-Control directives for requests (e.g. from a client). + * Directives shared by both request and response Cache-Control headers. * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} + * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2} */ -export interface RequestCacheControl { +export interface CacheControlBase { /** When present, the cache must not store the request or response. */ noStore?: true; /** When present, the cache must not transform the payload. */ @@ -66,6 +66,16 @@ export interface RequestCacheControl { maxAge?: number; /** Allow use of stale response if revalidation fails (seconds). */ staleIfError?: number; +} + +/** + * Cache-Control directives for requests (e.g. from a client). + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.1} + */ +export interface RequestCacheControl extends CacheControlBase { /** When present, the cache must not use a stored response without revalidation. */ noCache?: true; /** @@ -86,18 +96,7 @@ export interface RequestCacheControl { * * @see {@link https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2} */ -export interface ResponseCacheControl { - /** When present, the cache must not store the request or response. */ - noStore?: true; - /** When present, the cache must not transform the payload. */ - noTransform?: true; - /** - * Maximum age in seconds. In a request: accept responses whose age is no - * greater than this. In a response: the response is stale after this many seconds. - */ - maxAge?: number; - /** Allow use of stale response if revalidation fails (seconds). */ - staleIfError?: number; +export interface ResponseCacheControl extends CacheControlBase { /** * When `true`, the response must not be used from cache without revalidation. * When an array, only the listed response header fields require revalidation; @@ -224,6 +223,7 @@ export function parseCacheControl(value: string | null): CacheControl { return result as CacheControl; } + const seen = new Set(); const parts = splitDirectives(value); for (const part of parts) { const trimmed = part.trim(); @@ -235,10 +235,12 @@ export function parseCacheControl(value: string | null): CacheControl { const rawValue = eq === -1 ? undefined : trimmed.slice(eq + 1).trim(); // RFC 9111 §4.2.1: when a directive appears more than once, use the first - // occurrence. Skip if the property is already set on result. + // occurrence. Track seen directive names to skip subsequent duplicates. + if (seen.has(name)) continue; + seen.add(name); + switch (name) { case "max-age": - if (result.maxAge !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -247,13 +249,11 @@ export function parseCacheControl(value: string | null): CacheControl { result.maxAge = parseNonNegativeInt(rawValue, name); break; case "max-stale": - if (result.maxStale !== undefined) continue; result.maxStale = rawValue === undefined ? true : parseNonNegativeInt(rawValue, name); break; case "min-fresh": - if (result.minFresh !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -262,7 +262,6 @@ export function parseCacheControl(value: string | null): CacheControl { result.minFresh = parseNonNegativeInt(rawValue, name); break; case "no-cache": { - if (result.noCache !== undefined) continue; const noCacheFields = rawValue === undefined ? undefined : parseFieldNames(rawValue); @@ -273,35 +272,27 @@ export function parseCacheControl(value: string | null): CacheControl { break; } case "no-store": - if (result.noStore !== undefined) continue; result.noStore = true; break; case "no-transform": - if (result.noTransform !== undefined) continue; result.noTransform = true; break; case "only-if-cached": - if (result.onlyIfCached !== undefined) continue; result.onlyIfCached = true; break; case "must-revalidate": - if (result.mustRevalidate !== undefined) continue; result.mustRevalidate = true; break; case "must-understand": - if (result.mustUnderstand !== undefined) continue; result.mustUnderstand = true; break; case "proxy-revalidate": - if (result.proxyRevalidate !== undefined) continue; result.proxyRevalidate = true; break; case "public": - if (result.public !== undefined) continue; result.public = true; break; case "s-maxage": - if (result.sMaxage !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -310,7 +301,6 @@ export function parseCacheControl(value: string | null): CacheControl { result.sMaxage = parseNonNegativeInt(rawValue, name); break; case "private": { - if (result.private !== undefined) continue; const privateFields = rawValue === undefined ? undefined : parseFieldNames(rawValue); @@ -321,11 +311,9 @@ export function parseCacheControl(value: string | null): CacheControl { break; } case "immutable": - if (result.immutable !== undefined) continue; result.immutable = true; break; case "stale-while-revalidate": - if (result.staleWhileRevalidate !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -334,7 +322,6 @@ export function parseCacheControl(value: string | null): CacheControl { result.staleWhileRevalidate = parseNonNegativeInt(rawValue, name); break; case "stale-if-error": - if (result.staleIfError !== undefined) continue; if (rawValue === undefined) { throw new SyntaxError( `Cache-Control: ${name} requires an integer value`, @@ -343,6 +330,7 @@ export function parseCacheControl(value: string | null): CacheControl { result.staleIfError = parseNonNegativeInt(rawValue, name); break; default: + // Unknown directives are ignored per RFC 9111 §5.2.3. continue; } }