@sdxc/http 2026.10.24 → 2026.10.25

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 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, and `HEAD` middleware — so a handler
7
- imports only the surface it uses.
6
+ `Accept` negotiation, `Cache-Control` and validators, deprecation notices, and `HEAD`
7
+ 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
 
@@ -242,6 +244,26 @@ Calls the handler for the most preferred type, then `default`, then answers
242
244
  `406 Not Acceptable`. `respond.Handlers` holds an optional `() => Response` per shorthand
243
245
  plus `default`.
244
246
 
247
+ ### `@sdxc/http/deprecation`
248
+
249
+ #### `setDeprecation(headers: Headers, notice: DeprecationNotice): Result<void, DeprecationNoticeError>`
250
+
251
+ Writes the RFC 9745 `Deprecation` and RFC 8594 `Sunset` fields and their `deprecation` and
252
+ `sunset` links, keeping the response's other links. Fails for a sunset earlier than the
253
+ deprecation.
254
+
255
+ ```typescript
256
+ setDeprecation(response.headers, {
257
+ deprecation: new Date("2026-12-01T00:00:00Z"),
258
+ sunset: new Date("2027-06-01T00:00:00Z"),
259
+ deprecationLink: "https://example.com/changelog",
260
+ });
261
+ ```
262
+
263
+ #### `getDeprecation(headers: Headers): DeprecationNotice | null`
264
+
265
+ Reads the notice back on the client, leaving out any field that does not parse.
266
+
245
267
  ### `@sdxc/http/cache`
246
268
 
247
269
  Built on the typed `CacheControl` header from `remix/headers`. Every age is a duration
@@ -260,6 +282,13 @@ policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
260
282
  // "public, max-age=3600, s-maxage=86400"
261
283
  ```
262
284
 
285
+ #### `targetedPolicy(options?: PolicyOptions): string` and `parseTargetedPolicy(value: string | null): CacheControl | null`
286
+
287
+ Write and read [RFC 9213](https://www.rfc-editor.org/rfc/rfc9213) targeted fields such as
288
+ `CDN-Cache-Control`, as Structured Field Dictionaries, to give an edge its own lifetime
289
+ (`targetedPolicy({ maxAge: "1 day" })` is `"max-age=86400"`). The parser answers `null` for
290
+ a field a cache ignores: absent, empty or invalid.
291
+
263
292
  #### `Policies`
264
293
 
265
294
  The recurring policies, each returning a `CacheControl`:
@@ -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";
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/http",
3
- "version": "2026.10.24",
3
+ "version": "2026.10.25",
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,22 @@
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",
14
15
  "./cache": "./dist/cache/index.js",
15
16
  "./middleware/head-requests": "./dist/middleware/head-requests.js"
16
17
  },
17
18
  "dependencies": {
18
19
  "@sdxc/crypto": "2026.10.23",
19
20
  "@sdxc/duration": "2026.10.13",
21
+ "@sdxc/link-header": "2026.10.25",
20
22
  "@sdxc/location": "2026.10.13",
21
- "@sdxc/result": "2026.10.13"
23
+ "@sdxc/result": "2026.10.13",
24
+ "@sdxc/structured-fields": "2026.10.25"
22
25
  },
23
26
  "peerDependencies": {
24
27
  "remix": "3.1.0"
25
28
  },
26
- "gitHead": "f9db0f8f1f51a256e74b94b0f4b1db4be9880370",
29
+ "gitHead": "1440b969c0a3e3808dfcee3f17d2ef43915f32a8",
27
30
  "publishConfig": {
28
31
  "access": "public"
29
32
  },