@sdxc/http 0.0.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +549 -0
- package/dist/cache/conditional.d.ts +24 -0
- package/dist/cache/conditional.js +108 -0
- package/dist/cache/etag.d.ts +37 -0
- package/dist/cache/etag.js +33 -0
- package/dist/cache/http-date.d.ts +38 -0
- package/dist/cache/http-date.js +56 -0
- package/dist/cache/if-modified-since.d.ts +36 -0
- package/dist/cache/if-modified-since.js +53 -0
- package/dist/cache/index.d.ts +20 -0
- package/dist/cache/index.js +16 -0
- package/dist/cache/policies.d.ts +73 -0
- package/dist/cache/policies.js +78 -0
- package/dist/cache/policy.d.ts +63 -0
- package/dist/cache/policy.js +54 -0
- package/dist/cache/precondition.d.ts +53 -0
- package/dist/cache/precondition.js +53 -0
- package/dist/cache/vary.d.ts +24 -0
- package/dist/cache/vary.js +33 -0
- package/dist/content-type.d.ts +312 -0
- package/dist/content-type.js +312 -0
- package/dist/middleware/head-requests.d.ts +19 -0
- package/dist/middleware/head-requests.js +30 -0
- package/dist/negotiate.d.ts +97 -0
- package/dist/negotiate.js +156 -0
- package/dist/response/html.d.ts +272 -0
- package/dist/response/html.js +316 -0
- package/dist/response/json.d.ts +272 -0
- package/dist/response/json.js +316 -0
- package/dist/response.d.ts +171 -0
- package/dist/response.js +225 -0
- package/dist/status-code.d.ts +620 -0
- package/dist/status-code.js +486 -0
- package/package.json +33 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answering conditional requests: when the validators a client sends back still
|
|
3
|
+
* describe the response about to be sent, the body is dropped and a `304` is
|
|
4
|
+
* returned instead, carrying only the headers a `304` is allowed to carry.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { IfNoneMatch } from "remix/headers";
|
|
10
|
+
import * as StatusCode from "../status-code.js";
|
|
11
|
+
import { parseHttpDate } from "./http-date.js";
|
|
12
|
+
import { ifModifiedSince, isModifiedSince } from "./if-modified-since.js";
|
|
13
|
+
/**
|
|
14
|
+
* Methods a `304` may answer. A conditional header on any other method is a
|
|
15
|
+
* precondition for a write, which `precondition()` handles instead.
|
|
16
|
+
*/
|
|
17
|
+
const CONDITIONAL_METHODS = new Set(["GET", "HEAD"]);
|
|
18
|
+
/**
|
|
19
|
+
* The only headers a `304` carries, per the caching specification. `Vary` stays
|
|
20
|
+
* on the list because a shared cache without it can no longer tell which
|
|
21
|
+
* negotiated variant was validated, and may serve the wrong one.
|
|
22
|
+
*/
|
|
23
|
+
const NOT_MODIFIED_HEADERS = [
|
|
24
|
+
"Cache-Control",
|
|
25
|
+
"Content-Location",
|
|
26
|
+
"Date",
|
|
27
|
+
"ETag",
|
|
28
|
+
"Expires",
|
|
29
|
+
"Vary",
|
|
30
|
+
];
|
|
31
|
+
/**
|
|
32
|
+
* The same entity tag written with the other strength, so weak comparison
|
|
33
|
+
* matches a tag against its counterpart while the framework's own quoting and
|
|
34
|
+
* wildcard handling stays in charge of the match.
|
|
35
|
+
*/
|
|
36
|
+
function counterpart(tag) {
|
|
37
|
+
return tag.startsWith("W/") ? tag.slice(2) : `W/${tag}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Whether the client's `If-None-Match` covers the response's own `ETag`, under
|
|
41
|
+
* the weak comparison the specification defines for this header. A `*` matches
|
|
42
|
+
* because a representation exists at all; a response with no `ETag` can't match.
|
|
43
|
+
*/
|
|
44
|
+
function matchesEtag(header, tag) {
|
|
45
|
+
let ifNoneMatch = IfNoneMatch.from(header);
|
|
46
|
+
if (ifNoneMatch.matches("*"))
|
|
47
|
+
return true;
|
|
48
|
+
if (tag === null)
|
|
49
|
+
return false;
|
|
50
|
+
return ifNoneMatch.matches(tag) || ifNoneMatch.matches(counterpart(tag));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether the copy the client already holds is still current. `If-None-Match`
|
|
54
|
+
* decides whenever present; `If-Modified-Since` is consulted only in its
|
|
55
|
+
* absence, per the precedence the specification sets between the two validators.
|
|
56
|
+
*/
|
|
57
|
+
function isCurrent(request, response) {
|
|
58
|
+
let ifNoneMatch = request.headers.get("If-None-Match");
|
|
59
|
+
if (ifNoneMatch !== null)
|
|
60
|
+
return matchesEtag(ifNoneMatch, response.headers.get("ETag"));
|
|
61
|
+
let since = ifModifiedSince(request.headers);
|
|
62
|
+
if (since === null)
|
|
63
|
+
return false;
|
|
64
|
+
let modifiedAt = parseHttpDate(response.headers.get("Last-Modified"));
|
|
65
|
+
if (modifiedAt === null)
|
|
66
|
+
return false;
|
|
67
|
+
return !isModifiedSince(modifiedAt, since);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Builds the `304` for a response whose body the client already has. Cancelling
|
|
71
|
+
* the original body lets a response piped from an upstream request release its
|
|
72
|
+
* connection immediately.
|
|
73
|
+
*/
|
|
74
|
+
async function notModified(response) {
|
|
75
|
+
let headers = new Headers();
|
|
76
|
+
for (let name of NOT_MODIFIED_HEADERS) {
|
|
77
|
+
let value = response.headers.get(name);
|
|
78
|
+
if (value !== null)
|
|
79
|
+
headers.set(name, value);
|
|
80
|
+
}
|
|
81
|
+
if (response.body !== null && !response.body.locked)
|
|
82
|
+
await response.body.cancel();
|
|
83
|
+
return new Response(null, { ...StatusCode.NotModified, headers });
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Downgrades a response to a `304` when the request's validators still hold.
|
|
87
|
+
* Only a `GET` or `HEAD` answered with `200` is eligible, so this runs safely
|
|
88
|
+
* at the end of any handler, using `ETag` or `Last-Modified` set on the response.
|
|
89
|
+
*
|
|
90
|
+
* @param request - The incoming request, carrying the client's validators.
|
|
91
|
+
* @param response - The response the handler produced.
|
|
92
|
+
* @returns A `304` with no body, or the original response unchanged.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* return await conditional(request, html(body, { headers }));
|
|
96
|
+
* @example
|
|
97
|
+
* let response = await conditional(request, json(payload, { headers }));
|
|
98
|
+
* response.status; // 304 when the client's ETag still matches
|
|
99
|
+
*/
|
|
100
|
+
export async function conditional(request, response) {
|
|
101
|
+
if (!CONDITIONAL_METHODS.has(request.method.toUpperCase()))
|
|
102
|
+
return response;
|
|
103
|
+
if (response.status !== StatusCode.Ok.status)
|
|
104
|
+
return response;
|
|
105
|
+
if (!isCurrent(request, response))
|
|
106
|
+
return response;
|
|
107
|
+
return await notModified(response);
|
|
108
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity tag generation: a validator derived from the bytes of a response, so a
|
|
3
|
+
* client holding a current copy is answered with a `304` instead of the body.
|
|
4
|
+
* The digest comes from `@sdxc/crypto`, keeping the hash implementation shared.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { BinaryLike, CryptoError } from "@sdxc/crypto";
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
/**
|
|
12
|
+
* How strictly the tag identifies the bytes it was built from.
|
|
13
|
+
*/
|
|
14
|
+
export interface EtagOptions {
|
|
15
|
+
/**
|
|
16
|
+
* Mark the tag weak (`W/"…"`), meaning it identifies content that is
|
|
17
|
+
* semantically equivalent, tolerating minor representation differences.
|
|
18
|
+
*/
|
|
19
|
+
weak?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Derives an entity tag from a payload by hashing it with SHA-256. The tag is
|
|
23
|
+
* base64url of the digest, quoted, so it changes whenever the bytes do — the
|
|
24
|
+
* property that makes an `ETag` a safe cache validator.
|
|
25
|
+
*
|
|
26
|
+
* @param body - The bytes the tag must identify; text is read as UTF-8.
|
|
27
|
+
* @param options - Set `weak: true` for content that varies insignificantly
|
|
28
|
+
* between renders, such as server-rendered HTML carrying a timestamp.
|
|
29
|
+
* @returns The quoted entity tag, or the digest failure from the runtime.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* let tag = await etag(body);
|
|
33
|
+
* if (isSuccess(tag)) headers.set("ETag", tag.data);
|
|
34
|
+
* @example
|
|
35
|
+
* let tag = await etag(html, { weak: true }); // W/"47DEQpj8HBSa…"
|
|
36
|
+
*/
|
|
37
|
+
export declare function etag(body: BinaryLike, options?: EtagOptions): Promise<Result<string, CryptoError>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity tag generation: a validator derived from the bytes of a response, so a
|
|
3
|
+
* client holding a current copy is answered with a `304` instead of the body.
|
|
4
|
+
* The digest comes from `@sdxc/crypto`, keeping the hash implementation shared.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { Base64Url, sha256 } from "@sdxc/crypto";
|
|
10
|
+
import { isFailure, success } from "@sdxc/result";
|
|
11
|
+
/**
|
|
12
|
+
* Derives an entity tag from a payload by hashing it with SHA-256. The tag is
|
|
13
|
+
* base64url of the digest, quoted, so it changes whenever the bytes do — the
|
|
14
|
+
* property that makes an `ETag` a safe cache validator.
|
|
15
|
+
*
|
|
16
|
+
* @param body - The bytes the tag must identify; text is read as UTF-8.
|
|
17
|
+
* @param options - Set `weak: true` for content that varies insignificantly
|
|
18
|
+
* between renders, such as server-rendered HTML carrying a timestamp.
|
|
19
|
+
* @returns The quoted entity tag, or the digest failure from the runtime.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* let tag = await etag(body);
|
|
23
|
+
* if (isSuccess(tag)) headers.set("ETag", tag.data);
|
|
24
|
+
* @example
|
|
25
|
+
* let tag = await etag(html, { weak: true }); // W/"47DEQpj8HBSa…"
|
|
26
|
+
*/
|
|
27
|
+
export async function etag(body, options = {}) {
|
|
28
|
+
let digest = await sha256(body);
|
|
29
|
+
if (isFailure(digest))
|
|
30
|
+
return digest;
|
|
31
|
+
let tag = `"${Base64Url.encode(digest.data)}"`;
|
|
32
|
+
return success(options.weak ? `W/${tag}` : tag);
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP-date format on both directions of the wire: producing the value a
|
|
3
|
+
* `Last-Modified` validator carries, and reading back the dates clients send in
|
|
4
|
+
* conditional requests. Only the fixed IMF-fixdate spelling is accepted.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Formats a modification time as the `Last-Modified` validator for a response.
|
|
11
|
+
*
|
|
12
|
+
* HTTP dates carry whole seconds, so sub-second writes within the same second
|
|
13
|
+
* share a validator; a content-derived `ETag` is the better one when available.
|
|
14
|
+
*
|
|
15
|
+
* @param date - Modification time, as a `Date` or epoch milliseconds.
|
|
16
|
+
* @returns The time as an HTTP-date.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* headers.set("Last-Modified", lastModified(article.updatedAt));
|
|
20
|
+
* @example
|
|
21
|
+
* lastModified(new Date("2015-10-21T07:28:00Z")); // "Wed, 21 Oct 2015 07:28:00 GMT"
|
|
22
|
+
*/
|
|
23
|
+
export declare function lastModified(date: Date | number): string;
|
|
24
|
+
/**
|
|
25
|
+
* Parses an HTTP-date header value, such as the one `If-Modified-Since` carries.
|
|
26
|
+
*
|
|
27
|
+
* A missing header and an unparsable one both return `null`, so callers fall
|
|
28
|
+
* back to serving the full body instead of asserting freshness they cannot prove.
|
|
29
|
+
*
|
|
30
|
+
* @param value - Raw header value, or `null` when the header is absent.
|
|
31
|
+
* @returns The parsed date, or `null` when there is nothing valid to compare.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* parseHttpDate(request.headers.get("If-Modified-Since"));
|
|
35
|
+
* @example
|
|
36
|
+
* parseHttpDate("yesterday"); // null
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseHttpDate(value: string | null): Date | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP-date format on both directions of the wire: producing the value a
|
|
3
|
+
* `Last-Modified` validator carries, and reading back the dates clients send in
|
|
4
|
+
* conditional requests. Only the fixed IMF-fixdate spelling is accepted.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The one date spelling this package reads: `"Wed, 21 Oct 2015 07:28:00 GMT"`.
|
|
11
|
+
*
|
|
12
|
+
* Obsolete RFC 850 and asctime forms fail the match, because a misread date
|
|
13
|
+
* silently turns into a `304` for content that changed.
|
|
14
|
+
*/
|
|
15
|
+
const HTTP_DATE_PATTERN = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT$/;
|
|
16
|
+
/**
|
|
17
|
+
* Formats a modification time as the `Last-Modified` validator for a response.
|
|
18
|
+
*
|
|
19
|
+
* HTTP dates carry whole seconds, so sub-second writes within the same second
|
|
20
|
+
* share a validator; a content-derived `ETag` is the better one when available.
|
|
21
|
+
*
|
|
22
|
+
* @param date - Modification time, as a `Date` or epoch milliseconds.
|
|
23
|
+
* @returns The time as an HTTP-date.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* headers.set("Last-Modified", lastModified(article.updatedAt));
|
|
27
|
+
* @example
|
|
28
|
+
* lastModified(new Date("2015-10-21T07:28:00Z")); // "Wed, 21 Oct 2015 07:28:00 GMT"
|
|
29
|
+
*/
|
|
30
|
+
export function lastModified(date) {
|
|
31
|
+
return new Date(date).toUTCString();
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parses an HTTP-date header value, such as the one `If-Modified-Since` carries.
|
|
35
|
+
*
|
|
36
|
+
* A missing header and an unparsable one both return `null`, so callers fall
|
|
37
|
+
* back to serving the full body instead of asserting freshness they cannot prove.
|
|
38
|
+
*
|
|
39
|
+
* @param value - Raw header value, or `null` when the header is absent.
|
|
40
|
+
* @returns The parsed date, or `null` when there is nothing valid to compare.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* parseHttpDate(request.headers.get("If-Modified-Since"));
|
|
44
|
+
* @example
|
|
45
|
+
* parseHttpDate("yesterday"); // null
|
|
46
|
+
*/
|
|
47
|
+
export function parseHttpDate(value) {
|
|
48
|
+
if (value === null)
|
|
49
|
+
return null;
|
|
50
|
+
if (!HTTP_DATE_PATTERN.test(value))
|
|
51
|
+
return null;
|
|
52
|
+
let timestamp = Date.parse(value);
|
|
53
|
+
if (Number.isNaN(timestamp))
|
|
54
|
+
return null;
|
|
55
|
+
return new Date(timestamp);
|
|
56
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `If-Modified-Since` side of conditional requests: reading the date a
|
|
3
|
+
* client claims its copy was made, and comparing it against a modification time
|
|
4
|
+
* at the second precision the header can express.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Reads the `If-Modified-Since` date from a request's headers.
|
|
11
|
+
*
|
|
12
|
+
* @param headers - Headers of the incoming request.
|
|
13
|
+
* @returns The date the client stored its copy, or `null` when the header is
|
|
14
|
+
* absent or not a valid HTTP-date.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* let since = ifModifiedSince(request.headers);
|
|
18
|
+
* if (since !== null && !isModifiedSince(article.updatedAt, since)) return notModified();
|
|
19
|
+
*/
|
|
20
|
+
export declare function ifModifiedSince(headers: Headers): Date | null;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a resource changed after the copy a client already holds.
|
|
23
|
+
*
|
|
24
|
+
* Both times are compared as whole seconds, so a modification in the same
|
|
25
|
+
* second as the client's copy counts as unmodified, keeping repeat requests cheap.
|
|
26
|
+
*
|
|
27
|
+
* @param modifiedAt - When the resource last changed.
|
|
28
|
+
* @param since - The date the client sent in `If-Modified-Since`.
|
|
29
|
+
* @returns `true` when the resource is newer and the full body must be sent.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* isModifiedSince(new Date("2015-10-21T07:28:01Z"), new Date("2015-10-21T07:28:00Z")); // true
|
|
33
|
+
* @example
|
|
34
|
+
* isModifiedSince(new Date("2015-10-21T07:28:00Z"), new Date("2015-10-21T07:28:00Z")); // false
|
|
35
|
+
*/
|
|
36
|
+
export declare function isModifiedSince(modifiedAt: Date | number, since: Date | number): boolean;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `If-Modified-Since` side of conditional requests: reading the date a
|
|
3
|
+
* client claims its copy was made, and comparing it against a modification time
|
|
4
|
+
* at the second precision the header can express.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { parseHttpDate } from "./http-date.js";
|
|
10
|
+
/** Milliseconds in the one second an HTTP-date can express. */
|
|
11
|
+
const SECOND_MS = 1000;
|
|
12
|
+
/**
|
|
13
|
+
* Truncates a time to whole seconds, the only precision an HTTP-date carries.
|
|
14
|
+
*
|
|
15
|
+
* Comparing raw milliseconds would flag a same-second write as modified,
|
|
16
|
+
* forcing a full body for a copy that is already byte-identical.
|
|
17
|
+
*/
|
|
18
|
+
function toWholeSeconds(value) {
|
|
19
|
+
let time = value instanceof Date ? value.getTime() : value;
|
|
20
|
+
return Math.floor(time / SECOND_MS);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reads the `If-Modified-Since` date from a request's headers.
|
|
24
|
+
*
|
|
25
|
+
* @param headers - Headers of the incoming request.
|
|
26
|
+
* @returns The date the client stored its copy, or `null` when the header is
|
|
27
|
+
* absent or not a valid HTTP-date.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* let since = ifModifiedSince(request.headers);
|
|
31
|
+
* if (since !== null && !isModifiedSince(article.updatedAt, since)) return notModified();
|
|
32
|
+
*/
|
|
33
|
+
export function ifModifiedSince(headers) {
|
|
34
|
+
return parseHttpDate(headers.get("If-Modified-Since"));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether a resource changed after the copy a client already holds.
|
|
38
|
+
*
|
|
39
|
+
* Both times are compared as whole seconds, so a modification in the same
|
|
40
|
+
* second as the client's copy counts as unmodified, keeping repeat requests cheap.
|
|
41
|
+
*
|
|
42
|
+
* @param modifiedAt - When the resource last changed.
|
|
43
|
+
* @param since - The date the client sent in `If-Modified-Since`.
|
|
44
|
+
* @returns `true` when the resource is newer and the full body must be sent.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* isModifiedSince(new Date("2015-10-21T07:28:01Z"), new Date("2015-10-21T07:28:00Z")); // true
|
|
48
|
+
* @example
|
|
49
|
+
* isModifiedSince(new Date("2015-10-21T07:28:00Z"), new Date("2015-10-21T07:28:00Z")); // false
|
|
50
|
+
*/
|
|
51
|
+
export function isModifiedSince(modifiedAt, since) {
|
|
52
|
+
return toWholeSeconds(modifiedAt) > toWholeSeconds(since);
|
|
53
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the cache subpath: policies that decide who may store a
|
|
3
|
+
* response and for how long, validators that identify its content, and the
|
|
4
|
+
* conditional-request handling that turns a current client copy into a `304`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export type { EtagOptions } from "./etag.js";
|
|
10
|
+
export type { PrivatePolicyOptions } from "./policies.js";
|
|
11
|
+
export type { CacheVisibility, PolicyOptions } from "./policy.js";
|
|
12
|
+
export type { PreconditionOptions } from "./precondition.js";
|
|
13
|
+
export { conditional } from "./conditional.js";
|
|
14
|
+
export { etag } from "./etag.js";
|
|
15
|
+
export { lastModified } from "./http-date.js";
|
|
16
|
+
export { ifModifiedSince, isModifiedSince } from "./if-modified-since.js";
|
|
17
|
+
export { Policies } from "./policies.js";
|
|
18
|
+
export { policy } from "./policy.js";
|
|
19
|
+
export { precondition, PreconditionFailedError } from "./precondition.js";
|
|
20
|
+
export { vary } from "./vary.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public surface of the cache subpath: policies that decide who may store a
|
|
3
|
+
* response and for how long, validators that identify its content, and the
|
|
4
|
+
* conditional-request handling that turns a current client copy into a `304`.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export { conditional } from "./conditional.js";
|
|
10
|
+
export { etag } from "./etag.js";
|
|
11
|
+
export { lastModified } from "./http-date.js";
|
|
12
|
+
export { ifModifiedSince, isModifiedSince } from "./if-modified-since.js";
|
|
13
|
+
export { Policies } from "./policies.js";
|
|
14
|
+
export { policy } from "./policy.js";
|
|
15
|
+
export { precondition, PreconditionFailedError } from "./precondition.js";
|
|
16
|
+
export { vary } from "./vary.js";
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named policies that cover the recurring cases, so the safe answer is also
|
|
3
|
+
* the short one to write. Each one is a reviewable decision with a name rather
|
|
4
|
+
* than a set of directives assembled again in every route that serves content.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
10
|
+
import type { CacheControl } from "remix/headers";
|
|
11
|
+
/**
|
|
12
|
+
* Ages the `private` policy needs, kept as an object so the age is named at the
|
|
13
|
+
* call site instead of appearing as a bare positional argument.
|
|
14
|
+
*/
|
|
15
|
+
export interface PrivatePolicyOptions {
|
|
16
|
+
/** How long the client's own cache may reuse the response. */
|
|
17
|
+
maxAge: DurationInput;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The recurring cache policies, each named after the outcome it produces.
|
|
21
|
+
*
|
|
22
|
+
* Only the exceptional case, a response a shared cache may store, is written out
|
|
23
|
+
* with `policy({ visibility: "public", … })`; everything routine has a name here.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* headers.set("Cache-Control", Policies.immutable());
|
|
27
|
+
*/
|
|
28
|
+
export declare class Policies {
|
|
29
|
+
/**
|
|
30
|
+
* Nothing stores the response, anywhere.
|
|
31
|
+
*
|
|
32
|
+
* The right answer for one-time payloads and anything a cache holding a copy
|
|
33
|
+
* would turn into a security problem, such as a token exchange response.
|
|
34
|
+
*
|
|
35
|
+
* @returns `no-store`.
|
|
36
|
+
* @example
|
|
37
|
+
* Policies.noStore().toString(); // "no-store"
|
|
38
|
+
*/
|
|
39
|
+
static noStore(): CacheControl;
|
|
40
|
+
/**
|
|
41
|
+
* Only the client that made the request may store the response.
|
|
42
|
+
*
|
|
43
|
+
* The age is required so a browser can't fall back to its own heuristic
|
|
44
|
+
* freshness for user-specific content.
|
|
45
|
+
*
|
|
46
|
+
* @param options - The client-side freshness lifetime.
|
|
47
|
+
* @returns `private, max-age=N`.
|
|
48
|
+
* @example
|
|
49
|
+
* Policies.private({ maxAge: "5 minutes" }).toString(); // "private, max-age=300"
|
|
50
|
+
*/
|
|
51
|
+
static private(options: PrivatePolicyOptions): CacheControl;
|
|
52
|
+
/**
|
|
53
|
+
* Any cache may keep the response for a year without ever revalidating it.
|
|
54
|
+
*
|
|
55
|
+
* Correct only for URLs whose content never changes — meaning fingerprinted
|
|
56
|
+
* asset names — since any other URL risks caches serving a stale copy forever.
|
|
57
|
+
*
|
|
58
|
+
* @returns `public, max-age=31536000, immutable`.
|
|
59
|
+
* @example
|
|
60
|
+
* Policies.immutable().toString(); // "public, max-age=31536000, immutable"
|
|
61
|
+
*/
|
|
62
|
+
static immutable(): CacheControl;
|
|
63
|
+
/**
|
|
64
|
+
* The response may be stored only by its own client, checked with the origin
|
|
65
|
+
* before every reuse, which is what makes validators worth generating.
|
|
66
|
+
* Pairing `private` with `no-cache` keeps a shared cache from storing it too.
|
|
67
|
+
*
|
|
68
|
+
* @returns `private, no-cache`.
|
|
69
|
+
* @example
|
|
70
|
+
* Policies.revalidate().toString(); // "private, no-cache"
|
|
71
|
+
*/
|
|
72
|
+
static revalidate(): CacheControl;
|
|
73
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The named policies that cover the recurring cases, so the safe answer is also
|
|
3
|
+
* the short one to write. Each one is a reviewable decision with a name rather
|
|
4
|
+
* than a set of directives assembled again in every route that serves content.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { policy } from "./policy.js";
|
|
10
|
+
/**
|
|
11
|
+
* Freshness lifetime for fingerprinted assets: one year, the longest value the
|
|
12
|
+
* caching specification asks caches to honor. Expressed in days because a year
|
|
13
|
+
* has no fixed length and durations only count fixed spans.
|
|
14
|
+
*/
|
|
15
|
+
const IMMUTABLE_MAX_AGE = "365 days";
|
|
16
|
+
/**
|
|
17
|
+
* The recurring cache policies, each named after the outcome it produces.
|
|
18
|
+
*
|
|
19
|
+
* Only the exceptional case, a response a shared cache may store, is written out
|
|
20
|
+
* with `policy({ visibility: "public", … })`; everything routine has a name here.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* headers.set("Cache-Control", Policies.immutable());
|
|
24
|
+
*/
|
|
25
|
+
export class Policies {
|
|
26
|
+
/**
|
|
27
|
+
* Nothing stores the response, anywhere.
|
|
28
|
+
*
|
|
29
|
+
* The right answer for one-time payloads and anything a cache holding a copy
|
|
30
|
+
* would turn into a security problem, such as a token exchange response.
|
|
31
|
+
*
|
|
32
|
+
* @returns `no-store`.
|
|
33
|
+
* @example
|
|
34
|
+
* Policies.noStore().toString(); // "no-store"
|
|
35
|
+
*/
|
|
36
|
+
static noStore() {
|
|
37
|
+
return policy({ noStore: true });
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Only the client that made the request may store the response.
|
|
41
|
+
*
|
|
42
|
+
* The age is required so a browser can't fall back to its own heuristic
|
|
43
|
+
* freshness for user-specific content.
|
|
44
|
+
*
|
|
45
|
+
* @param options - The client-side freshness lifetime.
|
|
46
|
+
* @returns `private, max-age=N`.
|
|
47
|
+
* @example
|
|
48
|
+
* Policies.private({ maxAge: "5 minutes" }).toString(); // "private, max-age=300"
|
|
49
|
+
*/
|
|
50
|
+
static private(options) {
|
|
51
|
+
return policy({ visibility: "private", maxAge: options.maxAge });
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Any cache may keep the response for a year without ever revalidating it.
|
|
55
|
+
*
|
|
56
|
+
* Correct only for URLs whose content never changes — meaning fingerprinted
|
|
57
|
+
* asset names — since any other URL risks caches serving a stale copy forever.
|
|
58
|
+
*
|
|
59
|
+
* @returns `public, max-age=31536000, immutable`.
|
|
60
|
+
* @example
|
|
61
|
+
* Policies.immutable().toString(); // "public, max-age=31536000, immutable"
|
|
62
|
+
*/
|
|
63
|
+
static immutable() {
|
|
64
|
+
return policy({ visibility: "public", maxAge: IMMUTABLE_MAX_AGE, immutable: true });
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The response may be stored only by its own client, checked with the origin
|
|
68
|
+
* before every reuse, which is what makes validators worth generating.
|
|
69
|
+
* Pairing `private` with `no-cache` keeps a shared cache from storing it too.
|
|
70
|
+
*
|
|
71
|
+
* @returns `private, no-cache`.
|
|
72
|
+
* @example
|
|
73
|
+
* Policies.revalidate().toString(); // "private, no-cache"
|
|
74
|
+
*/
|
|
75
|
+
static revalidate() {
|
|
76
|
+
return policy({ visibility: "private", noCache: true });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds `Cache-Control` values from a description of intent: who may store the
|
|
3
|
+
* response, and for how long, with every age written as a duration instead of a
|
|
4
|
+
* bare number of seconds. Serialization is left to the framework header class.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
10
|
+
import { CacheControl } from "remix/headers";
|
|
11
|
+
/**
|
|
12
|
+
* Who is allowed to store the response.
|
|
13
|
+
*
|
|
14
|
+
* An edge cache sitting in front of the origin can serve one client's body to
|
|
15
|
+
* another once `public` is set, so the call site always states it explicitly.
|
|
16
|
+
*/
|
|
17
|
+
export type CacheVisibility = "public" | "private";
|
|
18
|
+
/**
|
|
19
|
+
* The policy description a response declares.
|
|
20
|
+
*
|
|
21
|
+
* Every age is a duration, so `"1 hour"` reads as an hour at the call site and
|
|
22
|
+
* converts once; a zero value emits its directive, omission emits none.
|
|
23
|
+
*/
|
|
24
|
+
export interface PolicyOptions {
|
|
25
|
+
/** Who may store the response; omitted means neither directive is emitted. */
|
|
26
|
+
visibility?: CacheVisibility;
|
|
27
|
+
/** How long any cache may reuse the response without revalidating. */
|
|
28
|
+
maxAge?: DurationInput;
|
|
29
|
+
/** How long a shared cache may reuse it, overriding `maxAge` for that cache. */
|
|
30
|
+
sMaxAge?: DurationInput;
|
|
31
|
+
/** How long a stale response may be served while it is revalidated in the background. */
|
|
32
|
+
staleWhileRevalidate?: DurationInput;
|
|
33
|
+
/** How long a stale response may be served after the origin errors. */
|
|
34
|
+
staleIfError?: DurationInput;
|
|
35
|
+
/** Store the response, but revalidate it with the origin before every reuse. */
|
|
36
|
+
noCache?: boolean;
|
|
37
|
+
/** Do not store the response anywhere, in any cache. */
|
|
38
|
+
noStore?: boolean;
|
|
39
|
+
/** Forbid intermediaries from transforming the payload (recompressing images, for example). */
|
|
40
|
+
noTransform?: boolean;
|
|
41
|
+
/** Once stale, the response must be revalidated before it can be reused again. */
|
|
42
|
+
mustRevalidate?: boolean;
|
|
43
|
+
/** Same as `mustRevalidate`, but binding on shared caches only. */
|
|
44
|
+
proxyRevalidate?: boolean;
|
|
45
|
+
/** The body will never change while fresh, so revalidation can be skipped entirely. */
|
|
46
|
+
immutable?: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Turns a policy description into a `Cache-Control` header value.
|
|
50
|
+
*
|
|
51
|
+
* The result is the framework's own type, so it can be handed to anything
|
|
52
|
+
* that accepts it, read back field by field, or serialized via `toString()`.
|
|
53
|
+
*
|
|
54
|
+
* @param options - Visibility, ages, and directives to emit; all optional.
|
|
55
|
+
* @returns A `CacheControl` carrying only the directives that were asked for.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* headers.set("Cache-Control", policy({ visibility: "public", maxAge: "1 hour" }));
|
|
59
|
+
* @example
|
|
60
|
+
* policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
|
|
61
|
+
* // "public, max-age=3600, s-maxage=86400"
|
|
62
|
+
*/
|
|
63
|
+
export declare function policy(options?: PolicyOptions): CacheControl;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds `Cache-Control` values from a description of intent: who may store the
|
|
3
|
+
* response, and for how long, with every age written as a duration instead of a
|
|
4
|
+
* bare number of seconds. Serialization is left to the framework header class.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { toSeconds } from "@sdxc/duration";
|
|
10
|
+
import { CacheControl } from "remix/headers";
|
|
11
|
+
/**
|
|
12
|
+
* Turns a policy description into a `Cache-Control` header value.
|
|
13
|
+
*
|
|
14
|
+
* The result is the framework's own type, so it can be handed to anything
|
|
15
|
+
* that accepts it, read back field by field, or serialized via `toString()`.
|
|
16
|
+
*
|
|
17
|
+
* @param options - Visibility, ages, and directives to emit; all optional.
|
|
18
|
+
* @returns A `CacheControl` carrying only the directives that were asked for.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* headers.set("Cache-Control", policy({ visibility: "public", maxAge: "1 hour" }));
|
|
22
|
+
* @example
|
|
23
|
+
* policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
|
|
24
|
+
* // "public, max-age=3600, s-maxage=86400"
|
|
25
|
+
*/
|
|
26
|
+
export function policy(options = {}) {
|
|
27
|
+
let init = {};
|
|
28
|
+
if (options.visibility === "public")
|
|
29
|
+
init.public = true;
|
|
30
|
+
if (options.visibility === "private")
|
|
31
|
+
init.private = true;
|
|
32
|
+
if (options.maxAge !== undefined)
|
|
33
|
+
init.maxAge = toSeconds(options.maxAge);
|
|
34
|
+
if (options.sMaxAge !== undefined)
|
|
35
|
+
init.sMaxage = toSeconds(options.sMaxAge);
|
|
36
|
+
if (options.staleWhileRevalidate !== undefined) {
|
|
37
|
+
init.staleWhileRevalidate = toSeconds(options.staleWhileRevalidate);
|
|
38
|
+
}
|
|
39
|
+
if (options.staleIfError !== undefined)
|
|
40
|
+
init.staleIfError = toSeconds(options.staleIfError);
|
|
41
|
+
if (options.noCache)
|
|
42
|
+
init.noCache = true;
|
|
43
|
+
if (options.noStore)
|
|
44
|
+
init.noStore = true;
|
|
45
|
+
if (options.noTransform)
|
|
46
|
+
init.noTransform = true;
|
|
47
|
+
if (options.mustRevalidate)
|
|
48
|
+
init.mustRevalidate = true;
|
|
49
|
+
if (options.proxyRevalidate)
|
|
50
|
+
init.proxyRevalidate = true;
|
|
51
|
+
if (options.immutable)
|
|
52
|
+
init.immutable = true;
|
|
53
|
+
return new CacheControl(init);
|
|
54
|
+
}
|