Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 47 additions & 49 deletions http/unstable_cache_control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -217,8 +217,16 @@ 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. 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.
*
Expand All @@ -234,9 +242,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 = {};
Expand All @@ -261,27 +266,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
Expand Down Expand Up @@ -313,14 +316,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
Expand All @@ -334,22 +334,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;
Expand Down Expand Up @@ -397,6 +391,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).
Expand Down
76 changes: 26 additions & 50 deletions http/unstable_cache_control_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}), "");
});
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading