@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 +16 -3
- 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 +4 -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, deprecation notices,
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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": "
|
|
30
|
+
"gitHead": "d1dd4030f8df68b8a170940a3f790ea8654d3676",
|
|
30
31
|
"publishConfig": {
|
|
31
32
|
"access": "public"
|
|
32
33
|
},
|