@sdxc/http 2026.10.25 → 2026.11.3

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, deprecation notices, and `HEAD`
7
- middleware — so a handler 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
 
@@ -174,7 +174,8 @@ Writes `application/pdf`.
174
174
 
175
175
  #### `file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response`
176
176
 
177
- A download of opaque bytes: `application/octet-stream` plus `attachment(filename)`.
177
+ A download with `Content-Disposition: attachment(filename)`, typed by the `Content-Type` in
178
+ `init` or `application/octet-stream` when it names none.
178
179
 
179
180
  #### `attachment(filename: string): string`
180
181
 
@@ -264,6 +265,18 @@ setDeprecation(response.headers, {
264
265
 
265
266
  Reads the notice back on the client, leaving out any field that does not parse.
266
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
+
267
280
  ### `@sdxc/http/cache`
268
281
 
269
282
  Built on the typed `CacheControl` header from `remix/headers`. Every age is a duration
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 shortToMime = new Map([
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 mimeToShort = new Map([
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 mimeToShort.get(mimeType) ?? null;
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 shortToMime.get(type) ?? [type];
108
+ return SHORT_TO_MIME.get(type) ?? [type];
109
109
  }
110
110
  }
111
111
  /**
@@ -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(imageBlob, "photo.png", { headers: { "Cache-Control": "no-store" } });
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(imageBlob, "photo.png", { headers: { "Cache-Control": "no-store" } });
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.set("Content-Type", ContentType.OctetStream);
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.25",
3
+ "version": "2026.11.3",
4
4
  "description": "Response builders, content negotiation, and HTTP caching for the Fetch API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,11 +12,12 @@
12
12
  "./response/html": "./dist/response/html.js",
13
13
  "./negotiate": "./dist/negotiate.js",
14
14
  "./deprecation": "./dist/deprecation.js",
15
+ "./retry-after": "./dist/retry-after.js",
15
16
  "./cache": "./dist/cache/index.js",
16
17
  "./middleware/head-requests": "./dist/middleware/head-requests.js"
17
18
  },
18
19
  "dependencies": {
19
- "@sdxc/crypto": "2026.10.23",
20
+ "@sdxc/crypto": "2026.11.3",
20
21
  "@sdxc/duration": "2026.10.13",
21
22
  "@sdxc/link-header": "2026.10.25",
22
23
  "@sdxc/location": "2026.10.13",
@@ -26,7 +27,7 @@
26
27
  "peerDependencies": {
27
28
  "remix": "3.1.0"
28
29
  },
29
- "gitHead": "1440b969c0a3e3808dfcee3f17d2ef43915f32a8",
30
+ "gitHead": "d1dd4030f8df68b8a170940a3f790ea8654d3676",
30
31
  "publishConfig": {
31
32
  "access": "public"
32
33
  },