From fac615023a64712b4028dbd648fe1f03fe86396d Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 23 Jul 2026 22:34:18 +0200 Subject: [PATCH 1/2] fix(http/unstable): make `parseCacheControl()` lenient instead of throwing --- http/unstable_cache_control.ts | 93 ++++++++++++++--------------- http/unstable_cache_control_test.ts | 76 ++++++++--------------- 2 files changed, 70 insertions(+), 99 deletions(-) diff --git a/http/unstable_cache_control.ts b/http/unstable_cache_control.ts index ee55717b8a6b..c38a64a63566 100644 --- a/http/unstable_cache_control.ts +++ b/http/unstable_cache_control.ts @@ -163,13 +163,13 @@ function unquoteArgument(value: string): string { return t; } -function parseNonNegativeInt(value: string, directive: string): number { +/** Parses a delta-seconds argument. Returns `undefined` when the argument is + * missing or malformed so the caller can ignore the directive (RFC 9111 + * §4.2.1). */ +function parseNonNegativeInt(value: string | undefined): number | undefined { + if (value === undefined) return undefined; const trimmed = unquoteArgument(value); - if (!DIGITS_REGEXP.test(trimmed)) { - throw new SyntaxError( - `Cache-Control: invalid value for ${directive}: "${value}"`, - ); - } + if (!DIGITS_REGEXP.test(trimmed)) return undefined; const n = Number(trimmed); return n > MAX_DELTA_SECONDS ? MAX_DELTA_SECONDS : n; } @@ -217,8 +217,13 @@ function parseFieldNames(value: string): string[] { /** * 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`). + * + * Parsing is lenient and never throws: unknown directives are ignored per + * RFC 9111 §5.2.3, and known directives with a malformed or missing value + * (e.g. `max-age=abc` or bare `max-age`) are ignored as well. Callers + * implementing a cache may want to treat a response whose freshness + * directives do not survive parsing as stale, as encouraged by RFC 9111 + * §4.2.1. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -234,9 +239,6 @@ function parseFieldNames(value: string): string[] { * 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: CacheControl = {}; @@ -261,27 +263,25 @@ export function parseCacheControl(value: string | null): CacheControl { 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); + case "max-age": { + const n = parseNonNegativeInt(rawValue); + if (n !== undefined) result.maxAge = n; break; - case "max-stale": - result.maxStale = rawValue === undefined - ? true - : parseNonNegativeInt(rawValue, name); - break; - case "min-fresh": + } + case "max-stale": { if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); + result.maxStale = true; + break; } - result.minFresh = parseNonNegativeInt(rawValue, name); + const n = parseNonNegativeInt(rawValue); + if (n !== undefined) result.maxStale = n; break; + } + case "min-fresh": { + const n = parseNonNegativeInt(rawValue); + if (n !== undefined) result.minFresh = n; + break; + } case "no-cache": { const noCacheFields = rawValue === undefined ? undefined @@ -313,14 +313,11 @@ export function parseCacheControl(value: string | null): CacheControl { 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); + case "s-maxage": { + const n = parseNonNegativeInt(rawValue); + if (n !== undefined) result.sMaxage = n; break; + } case "private": { const privateFields = rawValue === undefined ? undefined @@ -334,22 +331,16 @@ export function parseCacheControl(value: string | null): CacheControl { 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); + case "stale-while-revalidate": { + const n = parseNonNegativeInt(rawValue); + if (n !== undefined) result.staleWhileRevalidate = n; break; - case "stale-if-error": - if (rawValue === undefined) { - throw new SyntaxError( - `Cache-Control: ${name} requires an integer value`, - ); - } - result.staleIfError = parseNonNegativeInt(rawValue, name); + } + case "stale-if-error": { + const n = parseNonNegativeInt(rawValue); + if (n !== undefined) result.staleIfError = n; break; + } default: // Unknown directives are ignored per RFC 9111 §5.2.3. continue; @@ -397,6 +388,10 @@ function append( * Serializes a Cache-Control object to a header value string. Output is * lowercase and comma-separated. Empty object produces an empty string. * + * An empty array for `noCache` or `private` is serialized as the bare + * directive (e.g. `no-cache`), which {@linkcode parseCacheControl} parses + * back as `true` rather than `[]`. + * * @experimental **UNSTABLE**: New API, yet to be vetted. * * @param cc The Cache-Control object (request or response). diff --git a/http/unstable_cache_control_test.ts b/http/unstable_cache_control_test.ts index 56d8ce1b4d56..fcaf0ce01c3f 100644 --- a/http/unstable_cache_control_test.ts +++ b/http/unstable_cache_control_test.ts @@ -71,37 +71,29 @@ Deno.test("parseCacheControl() ignores unknown directives", () => { }); }); -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() ignores malformed numeric values", () => { + assertEquals(parseCacheControl("max-age=abc"), {}); + assertEquals(parseCacheControl("max-age=-1"), {}); + assertEquals(parseCacheControl("s-maxage=1.5"), {}); + assertEquals(parseCacheControl("max-stale=abc"), {}); }); -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("parseCacheControl() ignores valued directives without a value", () => { + assertEquals(parseCacheControl("max-age"), {}); + assertEquals(parseCacheControl("stale-while-revalidate"), {}); +}); + +Deno.test("parseCacheControl() keeps well-formed directives when others are malformed", () => { + assertEquals( + parseCacheControl("max-age=abc, no-store, s-maxage=600"), + { noStore: true, sMaxage: 600 }, ); }); +Deno.test("parseCacheControl() ignores duplicates after a malformed first occurrence", () => { + assertEquals(parseCacheControl("max-age=abc, max-age=100"), {}); +}); + Deno.test("formatCacheControl() returns empty string for empty object", () => { assertEquals(formatCacheControl({}), ""); }); @@ -166,12 +158,8 @@ 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() ignores min-fresh without a value", () => { + assertEquals(parseCacheControl("min-fresh"), {}); }); Deno.test("parseCacheControl() parses stale-if-error", () => { @@ -180,20 +168,12 @@ Deno.test("parseCacheControl() parses stale-if-error", () => { }); }); -Deno.test("parseCacheControl() throws when stale-if-error has no value", () => { - assertThrows( - () => parseCacheControl("stale-if-error"), - SyntaxError, - "requires an integer value", - ); +Deno.test("parseCacheControl() ignores stale-if-error without a value", () => { + assertEquals(parseCacheControl("stale-if-error"), {}); }); -Deno.test("parseCacheControl() throws when s-maxage has no value", () => { - assertThrows( - () => parseCacheControl("s-maxage"), - SyntaxError, - "requires an integer value", - ); +Deno.test("parseCacheControl() ignores s-maxage without a value", () => { + assertEquals(parseCacheControl("s-maxage"), {}); }); Deno.test("parseCacheControl() parses no-transform", () => { @@ -391,12 +371,8 @@ Deno.test("parseCacheControl() accepts quoted-string form for numeric arguments" assertEquals(parseCacheControl('max-stale="120"'), { maxStale: 120 }); }); -Deno.test("parseCacheControl() rejects quoted-string with non-digit content", () => { - assertThrows( - () => parseCacheControl('max-age="abc"'), - SyntaxError, - "invalid value", - ); +Deno.test("parseCacheControl() ignores quoted-string with non-digit content", () => { + assertEquals(parseCacheControl('max-age="abc"'), {}); }); Deno.test("parseCacheControl() unescapes backslash quoted-pairs in field names", () => { From bbaecdc4110f3413b56dabb46c4e0d98889f476f Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Thu, 30 Jul 2026 20:21:09 +0200 Subject: [PATCH 2/2] feedback --- http/unstable_cache_control.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/http/unstable_cache_control.ts b/http/unstable_cache_control.ts index c38a64a63566..e8a609f19993 100644 --- a/http/unstable_cache_control.ts +++ b/http/unstable_cache_control.ts @@ -220,10 +220,13 @@ function parseFieldNames(value: string): string[] { * * Parsing is lenient and never throws: unknown directives are ignored per * RFC 9111 §5.2.3, and known directives with a malformed or missing value - * (e.g. `max-age=abc` or bare `max-age`) are ignored as well. Callers - * implementing a cache may want to treat a response whose freshness - * directives do not survive parsing as stale, as encouraged by RFC 9111 - * §4.2.1. + * (e.g. `max-age=abc` or bare `max-age`) are ignored as well. The one + * exception is bare `max-stale`, which is valid per RFC 9111 §5.2.1.2 and + * parses as `true`. When a directive appears more than once, only the first + * occurrence counts, even if it is malformed (`max-age=abc, max-age=100` + * yields no `maxAge`). Callers implementing a cache may want to treat a + * response whose freshness directives do not survive parsing as stale, as + * encouraged by RFC 9111 §4.2.1. * * @experimental **UNSTABLE**: New API, yet to be vetted. *