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
54 changes: 33 additions & 21 deletions http/unstable_cache_control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@
*/

/**
* Shared Cache-Control directives valid in both request and response.
* 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}
*/
export interface CacheControlBase {
/** When present, the cache must not store the request or response. */
Expand All @@ -62,6 +64,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;
Comment thread
tomas-zijdemans marked this conversation as resolved.
}

/**
Expand Down Expand Up @@ -118,22 +122,16 @@ 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}.
*
* @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 = {
export type CacheControl = {
Comment thread
tomas-zijdemans marked this conversation as resolved.
[K in keyof RequestCacheControl | keyof ResponseCacheControl]?: K extends
keyof RequestCacheControl
? K extends keyof ResponseCacheControl
Expand All @@ -147,9 +145,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}"`,
);
Expand All @@ -169,7 +169,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) {
Comment thread
tomas-zijdemans marked this conversation as resolved.
i++;
} else if (c === 34 /* " */) {
inQuotes = !inQuotes;
} else if (c === 44 /* , */ && !inQuotes) {
parts.push(value.slice(start, i));
Expand Down Expand Up @@ -216,7 +218,7 @@ 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;
}
Expand Down Expand Up @@ -259,11 +261,16 @@ export function parseCacheControl(value: string | null): CacheControl {
}
result.minFresh = parseNonNegativeInt(rawValue, name);
break;
case "no-cache":
result.noCache = rawValue === undefined
? true
case "no-cache": {
const noCacheFields = rawValue === undefined
? undefined
: parseFieldNames(rawValue);
result.noCache =
Comment thread
tomas-zijdemans marked this conversation as resolved.
noCacheFields === undefined || noCacheFields.length === 0
? true
: noCacheFields;
break;
}
case "no-store":
result.noStore = true;
break;
Expand Down Expand Up @@ -293,11 +300,16 @@ export function parseCacheControl(value: string | null): CacheControl {
}
result.sMaxage = parseNonNegativeInt(rawValue, name);
break;
case "private":
result.private = rawValue === undefined
? true
case "private": {
const privateFields = rawValue === undefined
? undefined
: parseFieldNames(rawValue);
result.private =
privateFields === undefined || privateFields.length === 0
? true
: privateFields;
break;
}
case "immutable":
result.immutable = true;
break;
Expand Down Expand Up @@ -375,7 +387,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);
Expand Down
84 changes: 83 additions & 1 deletion http/unstable_cache_control_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -287,6 +345,30 @@ Deno.test("parseCacheControl() return type is CacheControl", () => {
assertType<IsExact<typeof cc, CacheControl>>(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 };
Expand All @@ -295,7 +377,7 @@ Deno.test("formatCacheControl() accepts RequestCacheControl and ResponseCacheCon
assertType<
IsExact<
Parameters<typeof formatCacheControl>[0],
RequestCacheControl | ResponseCacheControl
CacheControl
>
>(true);
});
Loading