@sdxc/http 2026.10.24 → 2026.10.26
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/README.md +45 -3
- package/dist/cache/index.d.ts +2 -0
- package/dist/cache/index.js +2 -0
- package/dist/cache/targeted.d.ts +32 -0
- package/dist/cache/targeted.js +112 -0
- package/dist/deprecation.d.ts +54 -0
- package/dist/deprecation.js +98 -0
- package/dist/negotiate.js +4 -4
- package/dist/response.d.ts +3 -2
- package/dist/response.js +5 -3
- package/dist/retry-after.d.ts +24 -0
- package/dist/retry-after.js +99 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
Response builders, content negotiation, and HTTP caching for the Fetch API.
|
|
4
4
|
|
|
5
5
|
Each concern is its own entry point — status codes, content types, response builders,
|
|
6
|
-
`Accept` negotiation, `Cache-Control` and validators,
|
|
7
|
-
imports only the surface it uses.
|
|
6
|
+
`Accept` negotiation, `Cache-Control` and validators, deprecation notices, `Retry-After`,
|
|
7
|
+
and `HEAD` middleware — so a handler imports only the surface it uses.
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
@@ -16,6 +16,8 @@ It installs alongside [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/resul
|
|
|
16
16
|
`Result` that `etag` and `precondition` answer),
|
|
17
17
|
[`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration) (cache ages),
|
|
18
18
|
[`@sdxc/crypto`](https://www.npmjs.com/package/@sdxc/crypto),
|
|
19
|
+
[`@sdxc/structured-fields`](https://www.npmjs.com/package/@sdxc/structured-fields),
|
|
20
|
+
[`@sdxc/link-header`](https://www.npmjs.com/package/@sdxc/link-header) (deprecation notices),
|
|
19
21
|
[`@sdxc/location`](https://www.npmjs.com/package/@sdxc/location) and
|
|
20
22
|
[`remix`](https://www.npmjs.com/package/remix) (typed headers and the router).
|
|
21
23
|
|
|
@@ -172,7 +174,8 @@ Writes `application/pdf`.
|
|
|
172
174
|
|
|
173
175
|
#### `file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response`
|
|
174
176
|
|
|
175
|
-
A download
|
|
177
|
+
A download with `Content-Disposition: attachment(filename)`, typed by the `Content-Type` in
|
|
178
|
+
`init` or `application/octet-stream` when it names none.
|
|
176
179
|
|
|
177
180
|
#### `attachment(filename: string): string`
|
|
178
181
|
|
|
@@ -242,6 +245,38 @@ Calls the handler for the most preferred type, then `default`, then answers
|
|
|
242
245
|
`406 Not Acceptable`. `respond.Handlers` holds an optional `() => Response` per shorthand
|
|
243
246
|
plus `default`.
|
|
244
247
|
|
|
248
|
+
### `@sdxc/http/deprecation`
|
|
249
|
+
|
|
250
|
+
#### `setDeprecation(headers: Headers, notice: DeprecationNotice): Result<void, DeprecationNoticeError>`
|
|
251
|
+
|
|
252
|
+
Writes the RFC 9745 `Deprecation` and RFC 8594 `Sunset` fields and their `deprecation` and
|
|
253
|
+
`sunset` links, keeping the response's other links. Fails for a sunset earlier than the
|
|
254
|
+
deprecation.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
setDeprecation(response.headers, {
|
|
258
|
+
deprecation: new Date("2026-12-01T00:00:00Z"),
|
|
259
|
+
sunset: new Date("2027-06-01T00:00:00Z"),
|
|
260
|
+
deprecationLink: "https://example.com/changelog",
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
#### `getDeprecation(headers: Headers): DeprecationNotice | null`
|
|
265
|
+
|
|
266
|
+
Reads the notice back on the client, leaving out any field that does not parse.
|
|
267
|
+
|
|
268
|
+
### `@sdxc/http/retry-after`
|
|
269
|
+
|
|
270
|
+
#### `parseRetryAfter(value: string | null, now?: Date): number | null`
|
|
271
|
+
|
|
272
|
+
Reads a [`Retry-After`](https://www.rfc-editor.org/rfc/rfc9110#section-10.2.3) as
|
|
273
|
+
milliseconds to wait, from delay-seconds or an HTTP-date measured from `now` (a past date
|
|
274
|
+
is `0`). Anything else is `null`.
|
|
275
|
+
|
|
276
|
+
```typescript
|
|
277
|
+
let wait = parseRetryAfter(response.headers.get("Retry-After")) ?? backoff(attempt);
|
|
278
|
+
```
|
|
279
|
+
|
|
245
280
|
### `@sdxc/http/cache`
|
|
246
281
|
|
|
247
282
|
Built on the typed `CacheControl` header from `remix/headers`. Every age is a duration
|
|
@@ -260,6 +295,13 @@ policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
|
|
|
260
295
|
// "public, max-age=3600, s-maxage=86400"
|
|
261
296
|
```
|
|
262
297
|
|
|
298
|
+
#### `targetedPolicy(options?: PolicyOptions): string` and `parseTargetedPolicy(value: string | null): CacheControl | null`
|
|
299
|
+
|
|
300
|
+
Write and read [RFC 9213](https://www.rfc-editor.org/rfc/rfc9213) targeted fields such as
|
|
301
|
+
`CDN-Cache-Control`, as Structured Field Dictionaries, to give an edge its own lifetime
|
|
302
|
+
(`targetedPolicy({ maxAge: "1 day" })` is `"max-age=86400"`). The parser answers `null` for
|
|
303
|
+
a field a cache ignores: absent, empty or invalid.
|
|
304
|
+
|
|
263
305
|
#### `Policies`
|
|
264
306
|
|
|
265
307
|
The recurring policies, each returning a `CacheControl`:
|
package/dist/cache/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Public surface of the cache subpath: policies that decide who may store a
|
|
3
3
|
* response and for how long, validators that identify its content, and the
|
|
4
4
|
* conditional-request handling that turns a current client copy into a `304`.
|
|
5
|
+
* Targeted fields give an edge cache its own lifetime apart from the browser's.
|
|
5
6
|
*
|
|
6
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
8
|
* @copyright Sergio Xalambrí 2026
|
|
@@ -17,4 +18,5 @@ export { ifModifiedSince, isModifiedSince } from "./if-modified-since.js";
|
|
|
17
18
|
export { Policies } from "./policies.js";
|
|
18
19
|
export { policy } from "./policy.js";
|
|
19
20
|
export { precondition, PreconditionFailedError } from "./precondition.js";
|
|
21
|
+
export { parseTargetedPolicy, targetedPolicy } from "./targeted.js";
|
|
20
22
|
export { vary } from "./vary.js";
|
package/dist/cache/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Public surface of the cache subpath: policies that decide who may store a
|
|
3
3
|
* response and for how long, validators that identify its content, and the
|
|
4
4
|
* conditional-request handling that turns a current client copy into a `304`.
|
|
5
|
+
* Targeted fields give an edge cache its own lifetime apart from the browser's.
|
|
5
6
|
*
|
|
6
7
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
8
|
* @copyright Sergio Xalambrí 2026
|
|
@@ -13,4 +14,5 @@ export { ifModifiedSince, isModifiedSince } from "./if-modified-since.js";
|
|
|
13
14
|
export { Policies } from "./policies.js";
|
|
14
15
|
export { policy } from "./policy.js";
|
|
15
16
|
export { precondition, PreconditionFailedError } from "./precondition.js";
|
|
17
|
+
export { parseTargetedPolicy, targetedPolicy } from "./targeted.js";
|
|
16
18
|
export { vary } from "./vary.js";
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes and reads RFC 9213 targeted cache fields such as `CDN-Cache-Control`, which give one
|
|
3
|
+
* class of cache (a CDN, or one vendor's edge) its own lifetime apart from `Cache-Control`.
|
|
4
|
+
* They are Structured Field Dictionaries, so both directions go through that grammar.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { CacheControl } from "remix/headers";
|
|
10
|
+
import type { PolicyOptions } from "./policy.js";
|
|
11
|
+
/**
|
|
12
|
+
* Builds a targeted cache field value from the same description `policy()` takes, so an
|
|
13
|
+
* edge-only lifetime reads like any other. An empty description yields `""`, which caches
|
|
14
|
+
* ignore, leaving `Cache-Control` in charge.
|
|
15
|
+
*
|
|
16
|
+
* @param options - Visibility, ages, and directives to emit; all optional.
|
|
17
|
+
* @returns The Structured Field Dictionary text for `CDN-Cache-Control` and its kin.
|
|
18
|
+
* @example
|
|
19
|
+
* headers.set("CDN-Cache-Control", targetedPolicy({ maxAge: "1 day" })); // "max-age=86400"
|
|
20
|
+
*/
|
|
21
|
+
export declare function targetedPolicy(options?: PolicyOptions): string;
|
|
22
|
+
/**
|
|
23
|
+
* Reads a targeted cache field. `null` means a cache ignores the field (absent, empty, or not a
|
|
24
|
+
* valid Dictionary) and falls back to the next one; any other result governs, even with no
|
|
25
|
+
* known directive. Parameters, unknown directives and ill-typed values are skipped.
|
|
26
|
+
*
|
|
27
|
+
* @param value - The field value, as `Headers#get` returns it.
|
|
28
|
+
* @returns The directives the field sets, or `null` when the field is ignored.
|
|
29
|
+
* @example
|
|
30
|
+
* parseTargetedPolicy(response.headers.get("CDN-Cache-Control"))?.maxAge; // 86400
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseTargetedPolicy(value: string | null | undefined): CacheControl | null;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes and reads RFC 9213 targeted cache fields such as `CDN-Cache-Control`, which give one
|
|
3
|
+
* class of cache (a CDN, or one vendor's edge) its own lifetime apart from `Cache-Control`.
|
|
4
|
+
* They are Structured Field Dictionaries, so both directions go through that grammar.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { isFailure } from "@sdxc/result";
|
|
10
|
+
import { parse, stringify } from "@sdxc/structured-fields";
|
|
11
|
+
import { CacheControl } from "remix/headers";
|
|
12
|
+
import { policy } from "./policy.js";
|
|
13
|
+
/** Directives whose value is an Integer number of seconds, in the order they are written. */
|
|
14
|
+
const AGE_DIRECTIVES = [
|
|
15
|
+
["max-age", "maxAge"],
|
|
16
|
+
["s-maxage", "sMaxage"],
|
|
17
|
+
["stale-while-revalidate", "staleWhileRevalidate"],
|
|
18
|
+
["stale-if-error", "staleIfError"],
|
|
19
|
+
];
|
|
20
|
+
/** Directives written as a bare key, in the order they are written. */
|
|
21
|
+
const FLAG_DIRECTIVES = [
|
|
22
|
+
["public", "public"],
|
|
23
|
+
["private", "private"],
|
|
24
|
+
["no-cache", "noCache"],
|
|
25
|
+
["no-store", "noStore"],
|
|
26
|
+
["no-transform", "noTransform"],
|
|
27
|
+
["must-revalidate", "mustRevalidate"],
|
|
28
|
+
["proxy-revalidate", "proxyRevalidate"],
|
|
29
|
+
["must-understand", "mustUnderstand"],
|
|
30
|
+
["immutable", "immutable"],
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Directives that may instead carry a String listing field names. A cache reading one takes
|
|
34
|
+
* the unqualified directive, which withholds the whole response rather than some fields.
|
|
35
|
+
*/
|
|
36
|
+
const QUALIFIABLE_DIRECTIVES = new Set(["private", "no-cache"]);
|
|
37
|
+
/** The largest Integer a Structured Field can carry. */
|
|
38
|
+
const MAX_INTEGER = 999_999_999_999_999;
|
|
39
|
+
/**
|
|
40
|
+
* Builds a targeted cache field value from the same description `policy()` takes, so an
|
|
41
|
+
* edge-only lifetime reads like any other. An empty description yields `""`, which caches
|
|
42
|
+
* ignore, leaving `Cache-Control` in charge.
|
|
43
|
+
*
|
|
44
|
+
* @param options - Visibility, ages, and directives to emit; all optional.
|
|
45
|
+
* @returns The Structured Field Dictionary text for `CDN-Cache-Control` and its kin.
|
|
46
|
+
* @example
|
|
47
|
+
* headers.set("CDN-Cache-Control", targetedPolicy({ maxAge: "1 day" })); // "max-age=86400"
|
|
48
|
+
*/
|
|
49
|
+
export function targetedPolicy(options = {}) {
|
|
50
|
+
let control = policy(options);
|
|
51
|
+
let dictionary = {};
|
|
52
|
+
for (let [name, key] of FLAG_DIRECTIVES)
|
|
53
|
+
if (control[key])
|
|
54
|
+
dictionary[name] = true;
|
|
55
|
+
for (let [name, key] of AGE_DIRECTIVES) {
|
|
56
|
+
let seconds = control[key];
|
|
57
|
+
if (seconds !== undefined)
|
|
58
|
+
dictionary[name] = Math.min(Math.max(seconds, 0), MAX_INTEGER);
|
|
59
|
+
}
|
|
60
|
+
let text = stringify(dictionary, "dictionary");
|
|
61
|
+
return isFailure(text) ? "" : text.data;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Reads a targeted cache field. `null` means a cache ignores the field (absent, empty, or not a
|
|
65
|
+
* valid Dictionary) and falls back to the next one; any other result governs, even with no
|
|
66
|
+
* known directive. Parameters, unknown directives and ill-typed values are skipped.
|
|
67
|
+
*
|
|
68
|
+
* @param value - The field value, as `Headers#get` returns it.
|
|
69
|
+
* @returns The directives the field sets, or `null` when the field is ignored.
|
|
70
|
+
* @example
|
|
71
|
+
* parseTargetedPolicy(response.headers.get("CDN-Cache-Control"))?.maxAge; // 86400
|
|
72
|
+
*/
|
|
73
|
+
export function parseTargetedPolicy(value) {
|
|
74
|
+
if (value === null || value === undefined)
|
|
75
|
+
return null;
|
|
76
|
+
let parsed = parse(value, "dictionary");
|
|
77
|
+
if (isFailure(parsed))
|
|
78
|
+
return null;
|
|
79
|
+
let members = Object.entries(parsed.data);
|
|
80
|
+
if (members.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
let init = {};
|
|
83
|
+
for (let [name, member] of members) {
|
|
84
|
+
if ("items" in member)
|
|
85
|
+
continue;
|
|
86
|
+
readDirective(init, name, member.value);
|
|
87
|
+
}
|
|
88
|
+
return new CacheControl(init);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Copies one directive into the init when its value has the type the directive allows: a
|
|
92
|
+
* non-negative Integer for an age, Boolean true for a flag, or a String for `private` and
|
|
93
|
+
* `no-cache`. Anything else is skipped, since coercing it would diverge from other caches.
|
|
94
|
+
*
|
|
95
|
+
* @param init - The directives read so far.
|
|
96
|
+
* @param name - The directive's key in the Dictionary.
|
|
97
|
+
* @param value - The member's bare value.
|
|
98
|
+
*/
|
|
99
|
+
function readDirective(init, name, value) {
|
|
100
|
+
let age = AGE_DIRECTIVES.find(([directive]) => directive === name);
|
|
101
|
+
if (age) {
|
|
102
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= 0)
|
|
103
|
+
init[age[1]] = value;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let flag = FLAG_DIRECTIVES.find(([directive]) => directive === name);
|
|
107
|
+
if (!flag)
|
|
108
|
+
return;
|
|
109
|
+
if (value === true || (QUALIFIABLE_DIRECTIVES.has(name) && typeof value === "string")) {
|
|
110
|
+
init[flag[1]] = true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deprecation signalling on the Fetch API: the RFC 9745 `Deprecation` field, the RFC 8594
|
|
3
|
+
* `Sunset` field and their `deprecation` and `sunset` link relations, written by a server
|
|
4
|
+
* announcing a resource's retirement and read back by a client watching for one.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
/**
|
|
11
|
+
* What a response announces about its resource's retirement. Every member is optional:
|
|
12
|
+
* a resource may publish its deprecation policy link long before it names a date.
|
|
13
|
+
*/
|
|
14
|
+
export interface DeprecationNotice {
|
|
15
|
+
/** When the resource was deprecated, or will be when the date is in the future. */
|
|
16
|
+
deprecation?: Date;
|
|
17
|
+
/** When the resource is expected to stop answering; never earlier than `deprecation`. */
|
|
18
|
+
sunset?: Date;
|
|
19
|
+
/** Documentation of the deprecation and how to migrate, linked with `rel="deprecation"`. */
|
|
20
|
+
deprecationLink?: string;
|
|
21
|
+
/** Documentation of the retirement policy, linked with `rel="sunset"`. */
|
|
22
|
+
sunsetLink?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A notice the fields cannot carry: an invalid `Date`, or a sunset earlier than the
|
|
26
|
+
* deprecation, which RFC 9745 forbids a server to send.
|
|
27
|
+
*/
|
|
28
|
+
export declare class DeprecationNoticeError extends Error {
|
|
29
|
+
name: "DeprecationNoticeError";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Writes a notice onto response headers, replacing any `Deprecation`, `Sunset` and
|
|
33
|
+
* `deprecation`/`sunset` links already there and keeping every other link. Dates are
|
|
34
|
+
* truncated to whole seconds, the precision both fields carry.
|
|
35
|
+
*
|
|
36
|
+
* @param headers - The response headers to write.
|
|
37
|
+
* @param notice - What to announce.
|
|
38
|
+
* @returns Success once written, or why the notice is invalid, leaving `headers` untouched.
|
|
39
|
+
* @example setDeprecation(response.headers, { deprecation: new Date("2026-12-01T00:00:00Z"), sunset: new Date("2027-06-01T00:00:00Z"), deprecationLink: "https://example.com/changelog" });
|
|
40
|
+
* @example setDeprecation(response.headers, { deprecationLink: "https://example.com/deprecation-policy" });
|
|
41
|
+
*/
|
|
42
|
+
export declare function setDeprecation(headers: Headers, notice: DeprecationNotice): Result<void, DeprecationNoticeError>;
|
|
43
|
+
/**
|
|
44
|
+
* Reads the notice a response carries. Both fields are hints a client must work without,
|
|
45
|
+
* so an unreadable one is left out of the notice: a `Sunset` reads in any spelling
|
|
46
|
+
* `Date.parse` accepts, including the `UTC` zone RFC 9745's own example uses.
|
|
47
|
+
*
|
|
48
|
+
* @param headers - The response headers to read.
|
|
49
|
+
* @returns The notice, or `null` when the response announces nothing.
|
|
50
|
+
* @example
|
|
51
|
+
* let notice = getDeprecation(response.headers);
|
|
52
|
+
* if (notice?.deprecation) log.warn("Deprecated endpoint", { deprecation: notice.deprecation });
|
|
53
|
+
*/
|
|
54
|
+
export declare function getDeprecation(headers: Headers): DeprecationNotice | null;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { parse as parseLinks, serializeLink, stringify as stringifyLinks } from "@sdxc/link-header";
|
|
2
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
3
|
+
import { getField, setField } from "@sdxc/structured-fields";
|
|
4
|
+
/**
|
|
5
|
+
* A notice the fields cannot carry: an invalid `Date`, or a sunset earlier than the
|
|
6
|
+
* deprecation, which RFC 9745 forbids a server to send.
|
|
7
|
+
*/
|
|
8
|
+
export class DeprecationNoticeError extends Error {
|
|
9
|
+
name = "DeprecationNoticeError";
|
|
10
|
+
}
|
|
11
|
+
/** The relation types this module owns inside a `Link` field. */
|
|
12
|
+
const RELATIONS = ["deprecation", "sunset"];
|
|
13
|
+
/**
|
|
14
|
+
* Writes a notice onto response headers, replacing any `Deprecation`, `Sunset` and
|
|
15
|
+
* `deprecation`/`sunset` links already there and keeping every other link. Dates are
|
|
16
|
+
* truncated to whole seconds, the precision both fields carry.
|
|
17
|
+
*
|
|
18
|
+
* @param headers - The response headers to write.
|
|
19
|
+
* @param notice - What to announce.
|
|
20
|
+
* @returns Success once written, or why the notice is invalid, leaving `headers` untouched.
|
|
21
|
+
* @example setDeprecation(response.headers, { deprecation: new Date("2026-12-01T00:00:00Z"), sunset: new Date("2027-06-01T00:00:00Z"), deprecationLink: "https://example.com/changelog" });
|
|
22
|
+
* @example setDeprecation(response.headers, { deprecationLink: "https://example.com/deprecation-policy" });
|
|
23
|
+
*/
|
|
24
|
+
export function setDeprecation(headers, notice) {
|
|
25
|
+
let deprecation = notice.deprecation === undefined ? undefined : wholeSeconds(notice.deprecation);
|
|
26
|
+
let sunset = notice.sunset === undefined ? undefined : wholeSeconds(notice.sunset);
|
|
27
|
+
if (deprecation !== undefined && Number.isNaN(deprecation.getTime())) {
|
|
28
|
+
return failure(new DeprecationNoticeError("The deprecation date is invalid"));
|
|
29
|
+
}
|
|
30
|
+
if (sunset !== undefined && Number.isNaN(sunset.getTime())) {
|
|
31
|
+
return failure(new DeprecationNoticeError("The sunset date is invalid"));
|
|
32
|
+
}
|
|
33
|
+
if (deprecation !== undefined && sunset !== undefined && sunset < deprecation) {
|
|
34
|
+
return failure(new DeprecationNoticeError("The sunset date is earlier than the deprecation"));
|
|
35
|
+
}
|
|
36
|
+
headers.delete("Deprecation");
|
|
37
|
+
if (deprecation !== undefined) {
|
|
38
|
+
let written = setField(headers, "Deprecation", deprecation, "item");
|
|
39
|
+
if (isFailure(written))
|
|
40
|
+
return failure(new DeprecationNoticeError(written.error.message));
|
|
41
|
+
}
|
|
42
|
+
if (sunset === undefined)
|
|
43
|
+
headers.delete("Sunset");
|
|
44
|
+
else
|
|
45
|
+
headers.set("Sunset", sunset.toUTCString());
|
|
46
|
+
let links = [];
|
|
47
|
+
for (let link of parseLinks(headers.get("Link"))) {
|
|
48
|
+
if (!link.rels.some((rel) => RELATIONS.includes(rel))) {
|
|
49
|
+
links.push(link.raw);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (notice.deprecationLink !== undefined) {
|
|
53
|
+
links.push(serializeLink(notice.deprecationLink, "deprecation"));
|
|
54
|
+
}
|
|
55
|
+
if (notice.sunsetLink !== undefined)
|
|
56
|
+
links.push(serializeLink(notice.sunsetLink, "sunset"));
|
|
57
|
+
let link = stringifyLinks(links);
|
|
58
|
+
if (link === null)
|
|
59
|
+
headers.delete("Link");
|
|
60
|
+
else
|
|
61
|
+
headers.set("Link", link);
|
|
62
|
+
return success(undefined);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Reads the notice a response carries. Both fields are hints a client must work without,
|
|
66
|
+
* so an unreadable one is left out of the notice: a `Sunset` reads in any spelling
|
|
67
|
+
* `Date.parse` accepts, including the `UTC` zone RFC 9745's own example uses.
|
|
68
|
+
*
|
|
69
|
+
* @param headers - The response headers to read.
|
|
70
|
+
* @returns The notice, or `null` when the response announces nothing.
|
|
71
|
+
* @example
|
|
72
|
+
* let notice = getDeprecation(response.headers);
|
|
73
|
+
* if (notice?.deprecation) log.warn("Deprecated endpoint", { deprecation: notice.deprecation });
|
|
74
|
+
*/
|
|
75
|
+
export function getDeprecation(headers) {
|
|
76
|
+
let notice = {};
|
|
77
|
+
let deprecation = getField(headers, "Deprecation", "item");
|
|
78
|
+
if (!isFailure(deprecation) && deprecation.data?.value instanceof Date) {
|
|
79
|
+
notice.deprecation = deprecation.data.value;
|
|
80
|
+
}
|
|
81
|
+
let sunset = headers.get("Sunset");
|
|
82
|
+
let sunsetTime = sunset === null ? Number.NaN : Date.parse(sunset);
|
|
83
|
+
if (!Number.isNaN(sunsetTime))
|
|
84
|
+
notice.sunset = new Date(sunsetTime);
|
|
85
|
+
for (let link of parseLinks(headers.get("Link"))) {
|
|
86
|
+
if (notice.deprecationLink === undefined && link.rels.includes("deprecation")) {
|
|
87
|
+
notice.deprecationLink = link.target;
|
|
88
|
+
}
|
|
89
|
+
if (notice.sunsetLink === undefined && link.rels.includes("sunset")) {
|
|
90
|
+
notice.sunsetLink = link.target;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return Object.keys(notice).length === 0 ? null : notice;
|
|
94
|
+
}
|
|
95
|
+
/** Drops the milliseconds an HTTP-date and an sf-date cannot represent. */
|
|
96
|
+
function wholeSeconds(date) {
|
|
97
|
+
return new Date(Math.floor(date.getTime() / 1000) * 1000);
|
|
98
|
+
}
|
package/dist/negotiate.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
6
|
* @copyright Sergio Xalambrí 2026
|
|
7
7
|
*/
|
|
8
|
-
const
|
|
8
|
+
const SHORT_TO_MIME = new Map([
|
|
9
9
|
["json", ["application/json"]],
|
|
10
10
|
["html", ["text/html"]],
|
|
11
11
|
["xml", ["application/xml", "text/xml"]],
|
|
@@ -16,7 +16,7 @@ const shortToMime = new Map([
|
|
|
16
16
|
["csv", ["text/csv"]],
|
|
17
17
|
["pdf", ["application/pdf"]],
|
|
18
18
|
]);
|
|
19
|
-
const
|
|
19
|
+
const MIME_TO_SHORT = new Map([
|
|
20
20
|
["application/json", "json"],
|
|
21
21
|
["text/html", "html"],
|
|
22
22
|
["application/xml", "xml"],
|
|
@@ -86,7 +86,7 @@ export class AcceptList {
|
|
|
86
86
|
* @returns The shorthand type or null if not recognized
|
|
87
87
|
*/
|
|
88
88
|
toShortType(mimeType) {
|
|
89
|
-
return
|
|
89
|
+
return MIME_TO_SHORT.get(mimeType) ?? null;
|
|
90
90
|
}
|
|
91
91
|
parseAcceptHeader(header) {
|
|
92
92
|
return header
|
|
@@ -105,7 +105,7 @@ export class AcceptList {
|
|
|
105
105
|
.map(({ type }) => type);
|
|
106
106
|
}
|
|
107
107
|
resolveType(type) {
|
|
108
|
-
return
|
|
108
|
+
return SHORT_TO_MIME.get(type) ?? [type];
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
/**
|
package/dist/response.d.ts
CHANGED
|
@@ -123,7 +123,8 @@ export declare function pdf(body: Blob | ArrayBuffer | ReadableStream, init?: Re
|
|
|
123
123
|
export declare function attachment(filename: string): string;
|
|
124
124
|
/**
|
|
125
125
|
* Creates a Response for a file download, with the name written by `attachment` so a quote
|
|
126
|
-
* or a non-ASCII character in it reaches the client intact.
|
|
126
|
+
* or a non-ASCII character in it reaches the client intact. A `Content-Type` in `init` names
|
|
127
|
+
* the file's type; without one the download is `application/octet-stream`.
|
|
127
128
|
* @param body - The file content as Blob, ArrayBuffer, or ReadableStream
|
|
128
129
|
* @param filename - The filename to suggest for the download
|
|
129
130
|
* @param init - Optional response options (status, headers, etc.)
|
|
@@ -131,7 +132,7 @@ export declare function attachment(filename: string): string;
|
|
|
131
132
|
* @example
|
|
132
133
|
* return file(zipBuffer, "archive.zip");
|
|
133
134
|
* @example
|
|
134
|
-
* return file(
|
|
135
|
+
* return file(zipBuffer, "archive.zip", { headers: { "Content-Type": ContentType.ZIP } });
|
|
135
136
|
*/
|
|
136
137
|
export declare function file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response;
|
|
137
138
|
/**
|
package/dist/response.js
CHANGED
|
@@ -187,7 +187,8 @@ export function attachment(filename) {
|
|
|
187
187
|
}
|
|
188
188
|
/**
|
|
189
189
|
* Creates a Response for a file download, with the name written by `attachment` so a quote
|
|
190
|
-
* or a non-ASCII character in it reaches the client intact.
|
|
190
|
+
* or a non-ASCII character in it reaches the client intact. A `Content-Type` in `init` names
|
|
191
|
+
* the file's type; without one the download is `application/octet-stream`.
|
|
191
192
|
* @param body - The file content as Blob, ArrayBuffer, or ReadableStream
|
|
192
193
|
* @param filename - The filename to suggest for the download
|
|
193
194
|
* @param init - Optional response options (status, headers, etc.)
|
|
@@ -195,11 +196,12 @@ export function attachment(filename) {
|
|
|
195
196
|
* @example
|
|
196
197
|
* return file(zipBuffer, "archive.zip");
|
|
197
198
|
* @example
|
|
198
|
-
* return file(
|
|
199
|
+
* return file(zipBuffer, "archive.zip", { headers: { "Content-Type": ContentType.ZIP } });
|
|
199
200
|
*/
|
|
200
201
|
export function file(body, filename, init) {
|
|
201
202
|
let headers = new Headers(init?.headers);
|
|
202
|
-
headers.
|
|
203
|
+
if (!headers.has("Content-Type"))
|
|
204
|
+
headers.set("Content-Type", ContentType.OctetStream);
|
|
203
205
|
headers.set("Content-Disposition", attachment(filename));
|
|
204
206
|
return new Response(body, { ...init, headers });
|
|
205
207
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RFC 9110 `Retry-After` field read as a wait in milliseconds, from either of its
|
|
3
|
+
* forms: delay-seconds or an HTTP-date, the date in any of the three spellings a
|
|
4
|
+
* recipient must accept, so every client backs off by the same reading of the header.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Reads how long a `Retry-After` asks the client to wait. Delay-seconds count from the
|
|
11
|
+
* answer; a date counts from `now`, and one already past reads as `0`. A value in neither
|
|
12
|
+
* form, a negative or fractional number included, reads as `null`, so the caller falls
|
|
13
|
+
* back to its own backoff.
|
|
14
|
+
*
|
|
15
|
+
* @param value - The header's value, or `null` when the response carried none.
|
|
16
|
+
* @param now - The time the answer arrived, which a date is measured from.
|
|
17
|
+
* @default now new Date()
|
|
18
|
+
* @returns Milliseconds to wait, or `null` when the header names no usable delay.
|
|
19
|
+
* @example
|
|
20
|
+
* parseRetryAfter(response.headers.get("Retry-After")); // 120000 for "120"
|
|
21
|
+
* @example
|
|
22
|
+
* parseRetryAfter("Wed, 21 Oct 2015 07:28:30 GMT", new Date("2015-10-21T07:28:00Z")); // 30000
|
|
23
|
+
*/
|
|
24
|
+
export declare function parseRetryAfter(value: string | null, now?: Date): number | null;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RFC 9110 `Retry-After` field read as a wait in milliseconds, from either of its
|
|
3
|
+
* forms: delay-seconds or an HTTP-date, the date in any of the three spellings a
|
|
4
|
+
* recipient must accept, so every client backs off by the same reading of the header.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** RFC 9110 `delay-seconds`: one or more digits, nothing else. */
|
|
10
|
+
const DELAY_SECONDS = /^\d+$/;
|
|
11
|
+
/** The preferred spelling: `Sun, 06 Nov 1994 08:49:37 GMT`. */
|
|
12
|
+
const IMF_FIXDATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) ([A-Z][a-z]{2}) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
13
|
+
/** The obsolete RFC 850 spelling, with a two-digit year: `Sunday, 06-Nov-94 08:49:37 GMT`. */
|
|
14
|
+
const RFC_850_DATE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-([A-Z][a-z]{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
15
|
+
/** The obsolete asctime spelling, always UTC: `Sun Nov 6 08:49:37 1994`. */
|
|
16
|
+
const ASCTIME_DATE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) ([A-Z][a-z]{2}) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
|
17
|
+
/** Month names in the order `Date.UTC` numbers them from zero. */
|
|
18
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
19
|
+
/**
|
|
20
|
+
* Reads how long a `Retry-After` asks the client to wait. Delay-seconds count from the
|
|
21
|
+
* answer; a date counts from `now`, and one already past reads as `0`. A value in neither
|
|
22
|
+
* form, a negative or fractional number included, reads as `null`, so the caller falls
|
|
23
|
+
* back to its own backoff.
|
|
24
|
+
*
|
|
25
|
+
* @param value - The header's value, or `null` when the response carried none.
|
|
26
|
+
* @param now - The time the answer arrived, which a date is measured from.
|
|
27
|
+
* @default now new Date()
|
|
28
|
+
* @returns Milliseconds to wait, or `null` when the header names no usable delay.
|
|
29
|
+
* @example
|
|
30
|
+
* parseRetryAfter(response.headers.get("Retry-After")); // 120000 for "120"
|
|
31
|
+
* @example
|
|
32
|
+
* parseRetryAfter("Wed, 21 Oct 2015 07:28:30 GMT", new Date("2015-10-21T07:28:00Z")); // 30000
|
|
33
|
+
*/
|
|
34
|
+
export function parseRetryAfter(value, now = new Date()) {
|
|
35
|
+
if (value === null)
|
|
36
|
+
return null;
|
|
37
|
+
let text = value.trim();
|
|
38
|
+
if (DELAY_SECONDS.test(text))
|
|
39
|
+
return Number(text) * 1000;
|
|
40
|
+
let date = parseHttpDate(text, now);
|
|
41
|
+
if (date === null)
|
|
42
|
+
return null;
|
|
43
|
+
return Math.max(0, date - now.getTime());
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Parses an RFC 9110 HTTP-date in any of its three spellings as epoch milliseconds. A
|
|
47
|
+
* two-digit RFC 850 year more than 50 years past `now` belongs to the previous century.
|
|
48
|
+
*
|
|
49
|
+
* @param text - The trimmed header value.
|
|
50
|
+
* @param now - The clock a two-digit year is placed against.
|
|
51
|
+
* @returns The instant, or `null` for any other text or an impossible date.
|
|
52
|
+
*/
|
|
53
|
+
function parseHttpDate(text, now) {
|
|
54
|
+
let imf = IMF_FIXDATE.exec(text);
|
|
55
|
+
if (imf) {
|
|
56
|
+
let [, day = "", month = "", year = "", hour = "", minute = "", second = ""] = imf;
|
|
57
|
+
return toTime({ year: Number(year), month, day, hour, minute, second });
|
|
58
|
+
}
|
|
59
|
+
let rfc850 = RFC_850_DATE.exec(text);
|
|
60
|
+
if (rfc850) {
|
|
61
|
+
let [, day = "", month = "", year = "", hour = "", minute = "", second = ""] = rfc850;
|
|
62
|
+
let current = now.getUTCFullYear();
|
|
63
|
+
let full = current - (current % 100) + Number(year);
|
|
64
|
+
if (full > current + 50)
|
|
65
|
+
full -= 100;
|
|
66
|
+
return toTime({ year: full, month, day, hour, minute, second });
|
|
67
|
+
}
|
|
68
|
+
let asctime = ASCTIME_DATE.exec(text);
|
|
69
|
+
if (asctime) {
|
|
70
|
+
let [, month = "", day = "", hour = "", minute = "", second = "", year = ""] = asctime;
|
|
71
|
+
return toTime({ year: Number(year), month, day: day.trim(), hour, minute, second });
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Turns date parts into epoch milliseconds in UTC, refusing a date `Date.UTC` would roll
|
|
77
|
+
* over (31 February, hour 24), so a malformed header never becomes a wrong wait.
|
|
78
|
+
*
|
|
79
|
+
* @param parts - The parts one spelling captured.
|
|
80
|
+
* @returns The instant, or `null` when the parts name no real date.
|
|
81
|
+
*/
|
|
82
|
+
function toTime(parts) {
|
|
83
|
+
let month = MONTHS.indexOf(parts.month);
|
|
84
|
+
if (month === -1)
|
|
85
|
+
return null;
|
|
86
|
+
let day = Number(parts.day);
|
|
87
|
+
let hour = Number(parts.hour);
|
|
88
|
+
let minute = Number(parts.minute);
|
|
89
|
+
let second = Number(parts.second);
|
|
90
|
+
let time = Date.UTC(parts.year, month, day, hour, minute, second);
|
|
91
|
+
let date = new Date(time);
|
|
92
|
+
let exact = date.getUTCFullYear() === parts.year &&
|
|
93
|
+
date.getUTCMonth() === month &&
|
|
94
|
+
date.getUTCDate() === day &&
|
|
95
|
+
date.getUTCHours() === hour &&
|
|
96
|
+
date.getUTCMinutes() === minute &&
|
|
97
|
+
date.getUTCSeconds() === second;
|
|
98
|
+
return exact ? time : null;
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/http",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.26",
|
|
4
4
|
"description": "Response builders, content negotiation, and HTTP caching for the Fetch API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -11,19 +11,23 @@
|
|
|
11
11
|
"./response/json": "./dist/response/json.js",
|
|
12
12
|
"./response/html": "./dist/response/html.js",
|
|
13
13
|
"./negotiate": "./dist/negotiate.js",
|
|
14
|
+
"./deprecation": "./dist/deprecation.js",
|
|
15
|
+
"./retry-after": "./dist/retry-after.js",
|
|
14
16
|
"./cache": "./dist/cache/index.js",
|
|
15
17
|
"./middleware/head-requests": "./dist/middleware/head-requests.js"
|
|
16
18
|
},
|
|
17
19
|
"dependencies": {
|
|
18
20
|
"@sdxc/crypto": "2026.10.23",
|
|
19
21
|
"@sdxc/duration": "2026.10.13",
|
|
22
|
+
"@sdxc/link-header": "2026.10.25",
|
|
20
23
|
"@sdxc/location": "2026.10.13",
|
|
21
|
-
"@sdxc/result": "2026.10.13"
|
|
24
|
+
"@sdxc/result": "2026.10.13",
|
|
25
|
+
"@sdxc/structured-fields": "2026.10.25"
|
|
22
26
|
},
|
|
23
27
|
"peerDependencies": {
|
|
24
28
|
"remix": "3.1.0"
|
|
25
29
|
},
|
|
26
|
-
"gitHead": "
|
|
30
|
+
"gitHead": "ff18988bd00de6200766dbc77367257277979566",
|
|
27
31
|
"publishConfig": {
|
|
28
32
|
"access": "public"
|
|
29
33
|
},
|