@sdxc/http 2026.10.10 → 2026.10.16
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 +241 -311
- package/package.json +9 -7
package/README.md
CHANGED
|
@@ -2,43 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
Response builders, content negotiation, and HTTP caching for the Fetch API.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
negotiation,
|
|
7
|
-
|
|
8
|
-
layer out of the bundle.
|
|
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.
|
|
9
8
|
|
|
10
9
|
## Installation
|
|
11
10
|
|
|
12
|
-
```
|
|
11
|
+
```sh
|
|
13
12
|
npm add @sdxc/http
|
|
14
13
|
```
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`
|
|
19
|
-
[`@sdxc/
|
|
20
|
-
[`@sdxc/
|
|
21
|
-
[
|
|
22
|
-
subpaths build on the typed headers and router of
|
|
23
|
-
[`remix`](https://www.npmjs.com/package/remix). All install alongside this package.
|
|
15
|
+
It installs alongside [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result) (the
|
|
16
|
+
`Result` that `etag` and `precondition` answer),
|
|
17
|
+
[`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration) (cache ages),
|
|
18
|
+
[`@sdxc/crypto`](https://www.npmjs.com/package/@sdxc/crypto),
|
|
19
|
+
[`@sdxc/location`](https://www.npmjs.com/package/@sdxc/location) and
|
|
20
|
+
[`remix`](https://www.npmjs.com/package/remix) (typed headers and the router).
|
|
24
21
|
|
|
25
22
|
## Usage
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- `@sdxc/http/content-type` — MIME type constants for
|
|
30
|
-
[`Content-Type`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Type)
|
|
31
|
-
values.
|
|
32
|
-
- `@sdxc/http/status-code` — status and status text pairs, shaped as a `ResponseInit`.
|
|
33
|
-
- `@sdxc/http/response` — one builder per content kind.
|
|
34
|
-
- `@sdxc/http/response/json` — a JSON body with a named status.
|
|
35
|
-
- `@sdxc/http/response/html` — an HTML body with a named status.
|
|
36
|
-
- `@sdxc/http/negotiate` — [`Accept`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept)
|
|
37
|
-
header parsing and dispatch.
|
|
38
|
-
- `@sdxc/http/cache` — `Cache-Control` policies, validators, and conditional requests.
|
|
39
|
-
- `@sdxc/http/middleware/head-requests` — router middleware that answers `HEAD` from `GET`.
|
|
40
|
-
|
|
41
|
-
### Answer With JSON
|
|
24
|
+
### Answer with JSON
|
|
42
25
|
|
|
43
26
|
```typescript
|
|
44
27
|
import { badRequest, notFound, ok } from "@sdxc/http/response/json";
|
|
@@ -54,44 +37,56 @@ export async function handler(request: Request): Promise<Response> {
|
|
|
54
37
|
}
|
|
55
38
|
```
|
|
56
39
|
|
|
57
|
-
Each function writes the status, the status text
|
|
58
|
-
|
|
40
|
+
Each function writes the status, the status text and `Content-Type: application/json`.
|
|
41
|
+
`@sdxc/http/response/html` exports the same names for HTML strings.
|
|
42
|
+
|
|
43
|
+
### Use named statuses and content types
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
import { Json } from "@sdxc/http/content-type";
|
|
47
|
+
import { Accepted } from "@sdxc/http/status-code";
|
|
48
|
+
|
|
49
|
+
let headers = new Headers({ "Content-Type": Json }); // "application/json"
|
|
50
|
+
return new Response(JSON.stringify({ queued: true }), { ...Accepted, headers }); // 202 Accepted
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A status constant is a `{ status, statusText }` pair, the shape `ResponseInit` takes.
|
|
59
54
|
|
|
60
|
-
### Serve
|
|
55
|
+
### Serve the format the client asked for
|
|
61
56
|
|
|
62
57
|
```typescript
|
|
58
|
+
import { vary } from "@sdxc/http/cache";
|
|
63
59
|
import { respond } from "@sdxc/http/negotiate";
|
|
64
60
|
import { csv, html, json } from "@sdxc/http/response";
|
|
65
61
|
|
|
66
62
|
export async function handler(request: Request): Promise<Response> {
|
|
67
63
|
let report = await buildReport();
|
|
68
64
|
|
|
69
|
-
|
|
65
|
+
let response = respond(request, {
|
|
70
66
|
json: () => json(report),
|
|
71
67
|
html: () => html(renderReport(report)),
|
|
72
68
|
csv: () => csv(toCsv(report)),
|
|
73
69
|
default: () => json(report),
|
|
74
70
|
});
|
|
71
|
+
|
|
72
|
+
vary(response.headers, "Accept");
|
|
73
|
+
return response;
|
|
75
74
|
}
|
|
76
75
|
```
|
|
77
76
|
|
|
78
|
-
Handlers are tried in the order the
|
|
79
|
-
|
|
80
|
-
answered with a `406 Not Acceptable`. A negotiated response wants `vary(headers, ["Accept"])`
|
|
81
|
-
alongside it, so a shared cache keeps the variants apart.
|
|
77
|
+
Handlers are tried in the order the `Accept` header prefers; with no `default`, a request
|
|
78
|
+
matching none gets `406 Not Acceptable`.
|
|
82
79
|
|
|
83
|
-
### Cache
|
|
80
|
+
### Cache a page and answer its revalidations
|
|
84
81
|
|
|
85
82
|
```typescript
|
|
86
|
-
import { conditional, etag, Policies
|
|
83
|
+
import { conditional, etag, Policies } from "@sdxc/http/cache";
|
|
87
84
|
import { html } from "@sdxc/http/response";
|
|
88
85
|
import { isSuccess } from "@sdxc/result";
|
|
89
86
|
|
|
90
87
|
export async function handler(request: Request): Promise<Response> {
|
|
91
88
|
let body = await renderPage();
|
|
92
|
-
|
|
93
89
|
let headers = new Headers({ "Cache-Control": Policies.revalidate().toString() });
|
|
94
|
-
vary(headers, ["Accept-Language"]);
|
|
95
90
|
|
|
96
91
|
let tag = await etag(body, { weak: true });
|
|
97
92
|
if (isSuccess(tag)) headers.set("ETag", tag.data);
|
|
@@ -100,11 +95,10 @@ export async function handler(request: Request): Promise<Response> {
|
|
|
100
95
|
}
|
|
101
96
|
```
|
|
102
97
|
|
|
103
|
-
The client
|
|
104
|
-
|
|
105
|
-
when it changed.
|
|
98
|
+
The client checks back before every reuse, and `conditional` answers with a `304` while the
|
|
99
|
+
`ETag` still matches.
|
|
106
100
|
|
|
107
|
-
### Answer `HEAD`
|
|
101
|
+
### Answer `HEAD` like `GET`
|
|
108
102
|
|
|
109
103
|
```typescript
|
|
110
104
|
import { headRequests } from "@sdxc/http/middleware/head-requests";
|
|
@@ -116,382 +110,220 @@ let router = createRouter({ middleware: [headRequests()] });
|
|
|
116
110
|
router.get("/page", () => html(renderPage()));
|
|
117
111
|
```
|
|
118
112
|
|
|
119
|
-
A `HEAD` to `/page`
|
|
120
|
-
|
|
113
|
+
A `HEAD` to `/page` gets the `GET`'s status and headers with no body, through the same
|
|
114
|
+
middleware chain.
|
|
121
115
|
|
|
122
116
|
## API
|
|
123
117
|
|
|
124
118
|
### `@sdxc/http/content-type`
|
|
125
119
|
|
|
126
|
-
MIME type constants, each the bare type with no `charset
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
```typescript
|
|
130
|
-
import { HTML, Json } from "@sdxc/http/content-type";
|
|
131
|
-
|
|
132
|
-
headers.set("Content-Type", Json); // "application/json"
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
**Text:** `Text`, `HTML`, `CSS`, `JavaScript`, `CSV`, `XML`, `Markdown`.
|
|
136
|
-
|
|
137
|
-
**Application:** `Json`, `JSONLines`, `ApplicationXML`, `PDF`, `ZIP`, `GZip`,
|
|
138
|
-
`FormURLEncoded`, `OctetStream`, `FormData`.
|
|
139
|
-
|
|
140
|
-
**Image:** `PNG`, `JPEG`, `GIF`, `WebP`, `SVG`, `ICO`, `AVIF`.
|
|
141
|
-
|
|
142
|
-
**Audio and video:** `MP3`, `WAV`, `OGG`, `WebMAudio`, `MP4`, `WebMVideo`.
|
|
143
|
-
|
|
144
|
-
**Font:** `WOFF`, `WOFF2`, `TTF`, `OTF`.
|
|
120
|
+
MIME type constants, each the bare type with no `charset`, so one value both compares
|
|
121
|
+
against a parsed header and writes a new one.
|
|
145
122
|
|
|
146
|
-
**
|
|
123
|
+
- **Text:** `Text`, `HTML`, `CSS`, `JavaScript`, `CSV`, `XML`, `Markdown`
|
|
124
|
+
- **Application:** `Json`, `JSONLines`, `ApplicationXML`, `PDF`, `ZIP`, `GZip`,
|
|
125
|
+
`FormURLEncoded`, `OctetStream`, `FormData`
|
|
126
|
+
- **Image:** `PNG`, `JPEG`, `GIF`, `WebP`, `SVG`, `ICO`, `AVIF`
|
|
127
|
+
- **Audio and video:** `MP3`, `WAV`, `OGG`, `WebMAudio`, `MP4`, `WebMVideo`
|
|
128
|
+
- **Font:** `WOFF`, `WOFF2`, `TTF`, `OTF`
|
|
129
|
+
- **Streaming:** `EventStream`, `NDJson`
|
|
147
130
|
|
|
148
|
-
`Json` is spelled
|
|
131
|
+
`Json` is spelled so it leaves the global `JSON` unshadowed.
|
|
149
132
|
|
|
150
133
|
### `@sdxc/http/status-code`
|
|
151
134
|
|
|
152
|
-
Each constant is a `{ status, statusText }` object
|
|
153
|
-
`ResponseInit` wants, so it drops straight into a response.
|
|
135
|
+
Each constant is a `{ status, statusText }` object that spreads into a `ResponseInit`.
|
|
154
136
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
**
|
|
166
|
-
`
|
|
167
|
-
|
|
168
|
-
**4xx:** `BadRequest`, `Unauthorized`, `PaymentRequired`, `Forbidden`, `NotFound`,
|
|
169
|
-
`MethodNotAllowed`, `NotAcceptable`, `ProxyAuthRequired`, `RequestTimeout`, `Conflict`,
|
|
170
|
-
`Gone`, `LengthRequired`, `PreconditionFailed`, `PayloadTooLarge`, `URITooLong`,
|
|
171
|
-
`UnsupportedMediaType`, `RangeNotSatisfiable`, `ExpectationFailed`, `ImATeapot`,
|
|
172
|
-
`UnprocessableEntity`, `TooEarly`, `UpgradeRequired`, `PreconditionRequired`,
|
|
173
|
-
`TooManyRequests`, `RequestHeaderFieldsTooLarge`, `UnavailableForLegalReasons`.
|
|
174
|
-
|
|
175
|
-
**5xx:** `InternalServerError`, `NotImplemented`, `BadGateway`, `ServiceUnavailable`,
|
|
176
|
-
`GatewayTimeout`, `HTTPVersionNotSupported`.
|
|
177
|
-
|
|
178
|
-
#### `StatusCode`
|
|
137
|
+
- **1xx:** `Continue`, `SwitchingProtocols`
|
|
138
|
+
- **2xx:** `Ok`, `Created`, `Accepted`, `NoContent`, `ResetContent`, `PartialContent`
|
|
139
|
+
- **3xx:** `MultipleChoices`, `MovedPermanently`, `Found`, `SeeOther`, `NotModified`,
|
|
140
|
+
`TemporaryRedirect`, `PermanentRedirect`
|
|
141
|
+
- **4xx:** `BadRequest`, `Unauthorized`, `PaymentRequired`, `Forbidden`, `NotFound`,
|
|
142
|
+
`MethodNotAllowed`, `NotAcceptable`, `ProxyAuthRequired`, `RequestTimeout`, `Conflict`,
|
|
143
|
+
`Gone`, `LengthRequired`, `PreconditionFailed`, `PayloadTooLarge`, `URITooLong`,
|
|
144
|
+
`UnsupportedMediaType`, `RangeNotSatisfiable`, `ExpectationFailed`, `ImATeapot`,
|
|
145
|
+
`UnprocessableEntity`, `TooEarly`, `UpgradeRequired`, `PreconditionRequired`,
|
|
146
|
+
`TooManyRequests`, `RequestHeaderFieldsTooLarge`, `UnavailableForLegalReasons`
|
|
147
|
+
- **5xx:** `InternalServerError`, `NotImplemented`, `BadGateway`, `ServiceUnavailable`,
|
|
148
|
+
`GatewayTimeout`, `HTTPVersionNotSupported`
|
|
179
149
|
|
|
180
|
-
|
|
150
|
+
`StatusCode` is the type every constant satisfies.
|
|
181
151
|
|
|
182
152
|
### `@sdxc/http/response`
|
|
183
153
|
|
|
184
|
-
Each builder takes a body plus an optional `ResponseInit`, sets the
|
|
185
|
-
|
|
186
|
-
[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
|
|
154
|
+
Each builder takes a body plus an optional `ResponseInit`, sets the `Content-Type`, and
|
|
155
|
+
returns a plain [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
|
|
187
156
|
|
|
188
|
-
|
|
189
|
-
import { csv, markdown, xml } from "@sdxc/http/response";
|
|
157
|
+
#### `json<T>(body: T, init?: ResponseInit): Response`
|
|
190
158
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
markdown(
|
|
194
|
-
```
|
|
159
|
+
Serializes any value through `Response.json`.
|
|
160
|
+
|
|
161
|
+
#### `text`, `html`, `css`, `javascript`, `xml`, `markdown` `(body: string, init?: ResponseInit): Response`
|
|
195
162
|
|
|
196
|
-
|
|
197
|
-
`javascript`, `xml` and `markdown` each take a string and write their own type.
|
|
198
|
-
`pdf(body, init?)` writes `application/pdf` and takes a `Blob`, `ArrayBuffer` or
|
|
199
|
-
`ReadableStream`.
|
|
163
|
+
Write their own type with `charset=utf-8`.
|
|
200
164
|
|
|
201
165
|
#### `csv(body: string | ReadableStream<Uint8Array>, init?: ResponseInit): Response`
|
|
202
166
|
|
|
203
|
-
Writes `text/csv; charset=utf-8
|
|
204
|
-
The body is a whole document or a stream of its UTF-8 bytes, which sends each row as it is
|
|
205
|
-
written instead of holding the file in memory:
|
|
167
|
+
Writes `text/csv; charset=utf-8`; a stream body sends each row as it is written.
|
|
206
168
|
|
|
207
|
-
|
|
208
|
-
import { csv } from "@sdxc/http/response";
|
|
169
|
+
#### `pdf(body: Blob | ArrayBuffer | ReadableStream, init?: ResponseInit): Response`
|
|
209
170
|
|
|
210
|
-
|
|
171
|
+
Writes `application/pdf`.
|
|
211
172
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
controller.enqueue(encoder.encode("name,city\r\n"));
|
|
216
|
-
for await (let row of readRows()) {
|
|
217
|
-
controller.enqueue(encoder.encode(`${row.name},${row.city}\r\n`));
|
|
218
|
-
}
|
|
219
|
-
controller.close();
|
|
220
|
-
},
|
|
221
|
-
}),
|
|
222
|
-
);
|
|
223
|
-
```
|
|
173
|
+
#### `file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response`
|
|
174
|
+
|
|
175
|
+
A download of opaque bytes: `application/octet-stream` plus `attachment(filename)`.
|
|
224
176
|
|
|
225
177
|
#### `attachment(filename: string): string`
|
|
226
178
|
|
|
227
179
|
A [`Content-Disposition`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition)
|
|
228
|
-
value that
|
|
229
|
-
|
|
230
|
-
character outside printable ASCII shown as `_`. A name that needed such a replacement also
|
|
231
|
-
gets an RFC 8187 `filename*`, which browsers prefer, carrying the exact name in UTF-8.
|
|
180
|
+
value that saves the response as `filename`. A non-ASCII name gets an ASCII fallback plus an
|
|
181
|
+
exact UTF-8 `filename*`.
|
|
232
182
|
|
|
233
183
|
```typescript
|
|
234
|
-
import { attachment } from "@sdxc/http/response";
|
|
235
|
-
|
|
236
|
-
attachment("report.csv");
|
|
237
|
-
// 'attachment; filename="report.csv"'
|
|
238
184
|
attachment("Café report.csv");
|
|
239
185
|
// "attachment; filename=\"Caf_ report.csv\"; filename*=UTF-8''Caf%C3%A9%20report.csv"
|
|
240
|
-
attachment("報告.csv");
|
|
241
|
-
// "attachment; filename=\"__.csv\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A.csv"
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
Pass it in the `headers` of any builder, so a CSV or a PDF download keeps its own type:
|
|
245
|
-
|
|
246
|
-
```typescript
|
|
247
|
-
return csv(report, { headers: { "Content-Disposition": attachment("report-2026-08.csv") } });
|
|
248
186
|
```
|
|
249
187
|
|
|
250
|
-
#### `file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response`
|
|
251
|
-
|
|
252
|
-
A download of opaque bytes: `application/octet-stream` plus the `Content-Disposition` that
|
|
253
|
-
`attachment(filename)` writes.
|
|
254
|
-
|
|
255
188
|
#### `stream(body: ReadableStream, init?: ResponseInit): Response`
|
|
256
189
|
|
|
257
|
-
A Server-Sent Events
|
|
190
|
+
A Server-Sent Events response: `text/event-stream`, `Cache-Control: no-cache` and
|
|
258
191
|
`Connection: keep-alive`.
|
|
259
192
|
|
|
260
|
-
#### `noContent(init
|
|
193
|
+
#### `noContent(init?: Omit<ResponseInit, "status" | "statusText">): Response`
|
|
261
194
|
|
|
262
|
-
A `204 No Content` with no body.
|
|
195
|
+
A `204 No Content` with no body.
|
|
263
196
|
|
|
264
197
|
#### `redirect(target: URL | Location | string, init?: redirect.Init): Response`
|
|
265
198
|
|
|
266
|
-
Writes `Location`
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
`redirect.Status` names the three worth choosing between: `SeeOther` (303) turns a `POST`
|
|
270
|
-
into a `GET`, while `Temporary` (307) and `Permanent` (308) keep the method.
|
|
199
|
+
Writes `Location` with status `307` by default, and throws on a target that does not parse.
|
|
200
|
+
`redirect.Status` names `SeeOther` (303, turns a `POST` into a `GET`), `Temporary` (307) and
|
|
201
|
+
`Permanent` (308); `redirect.Init` is a `ResponseInit` whose `status` is one of them.
|
|
271
202
|
|
|
272
203
|
### `@sdxc/http/response/json`
|
|
273
204
|
|
|
274
|
-
One function per status, each taking a value plus optional headers and writing the JSON body,
|
|
275
|
-
the status, and the status text together.
|
|
276
|
-
|
|
277
205
|
`ok`, `created`, `accepted`, `badRequest`, `unauthorized`, `paymentRequired`, `forbidden`,
|
|
278
206
|
`notFound`, `methodNotAllowed`, `notAcceptable`, `conflict`, `gone`, `preconditionFailed`,
|
|
279
207
|
`payloadTooLarge`, `unsupportedMediaType`, `unprocessableEntity`, `tooManyRequests`,
|
|
280
208
|
`internalServerError`, `notImplemented`, `badGateway`, `serviceUnavailable`,
|
|
281
|
-
`gatewayTimeout
|
|
282
|
-
|
|
283
|
-
```typescript
|
|
284
|
-
import { created } from "@sdxc/http/response/json";
|
|
285
|
-
|
|
286
|
-
return created({ id: user.id }); // 201 Created, application/json
|
|
287
|
-
```
|
|
209
|
+
`gatewayTimeout` — each `(body: T, init?)`, writing a JSON body with its status; `init`
|
|
210
|
+
carries headers.
|
|
288
211
|
|
|
289
212
|
### `@sdxc/http/response/html`
|
|
290
213
|
|
|
291
|
-
The same names, taking an HTML string and writing `text/html`.
|
|
292
|
-
|
|
293
|
-
```typescript
|
|
294
|
-
import { notFound } from "@sdxc/http/response/html";
|
|
295
|
-
|
|
296
|
-
return notFound("<h1>Page Not Found</h1>"); // 404 Not Found, text/html
|
|
297
|
-
```
|
|
214
|
+
The same names, each taking an HTML string and writing `text/html`.
|
|
298
215
|
|
|
299
216
|
### `@sdxc/http/negotiate`
|
|
300
217
|
|
|
301
218
|
#### `accepts(request: Request): AcceptList`
|
|
302
219
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
#### `new AcceptList(header: string)`
|
|
306
|
-
|
|
307
|
-
A parsed `Accept` header, sorted by quality, so preference is queried without re-parsing.
|
|
308
|
-
|
|
309
|
-
#### `list.includes(type: string): boolean`
|
|
310
|
-
|
|
311
|
-
Whether a type is accepted. Takes a full MIME type or a shorthand — `json`, `html`, `xml`,
|
|
312
|
-
`text`, `markdown`, `css`, `javascript`, `csv`, `pdf` — and a wildcard header matches
|
|
313
|
-
everything.
|
|
220
|
+
Parses the request's `Accept` header, reading an absent one as `*/*`.
|
|
314
221
|
|
|
315
|
-
#### `
|
|
222
|
+
#### `AcceptList`
|
|
316
223
|
|
|
317
|
-
|
|
224
|
+
A parsed `Accept` header sorted by quality. Types are full MIME types or the shorthands
|
|
225
|
+
`json`, `html`, `xml`, `text`, `markdown`, `css`, `javascript`, `csv`, `pdf`.
|
|
318
226
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
The shorthand for a MIME type, or `null` when it has none.
|
|
227
|
+
- `new AcceptList(header: string)`
|
|
228
|
+
- `includes(type: string): boolean` — whether the type is accepted.
|
|
229
|
+
- `all(): string[]` — accepted types, highest quality first.
|
|
230
|
+
- `preferred(...types: string[]): string | null` — the candidate the client prefers most; a
|
|
231
|
+
wildcard picks the first candidate.
|
|
232
|
+
- `toShortType(mimeType: string): string | null` — the shorthand for a MIME type.
|
|
327
233
|
|
|
328
234
|
```typescript
|
|
329
235
|
let list = new AcceptList("text/html, application/json;q=0.9");
|
|
330
|
-
|
|
331
|
-
list.includes("json"); // true
|
|
332
|
-
list.all(); // ["text/html", "application/json"]
|
|
333
236
|
list.preferred("application/json", "text/html"); // "text/html"
|
|
334
|
-
list.toShortType("application/json"); // "json"
|
|
335
237
|
```
|
|
336
238
|
|
|
337
239
|
#### `respond(request: Request, handlers: respond.Handlers): Response`
|
|
338
240
|
|
|
339
|
-
Calls the handler for the
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
#### `respond.Handlers`
|
|
343
|
-
|
|
344
|
-
The handlers object: an optional `() => Response` under each shorthand, plus `default`.
|
|
241
|
+
Calls the handler for the most preferred type, then `default`, then answers
|
|
242
|
+
`406 Not Acceptable`. `respond.Handlers` holds an optional `() => Response` per shorthand
|
|
243
|
+
plus `default`.
|
|
345
244
|
|
|
346
245
|
### `@sdxc/http/cache`
|
|
347
246
|
|
|
348
|
-
`
|
|
349
|
-
`
|
|
350
|
-
is a duration, so `"1 hour"` reads as an hour at the call site and converts to whole seconds
|
|
351
|
-
internally.
|
|
247
|
+
Built on the typed `CacheControl` header from `remix/headers`. Every age is a duration
|
|
248
|
+
(`"1 hour"`, `"5 minutes"`), written as whole seconds.
|
|
352
249
|
|
|
353
250
|
#### `policy(options?: PolicyOptions): CacheControl`
|
|
354
251
|
|
|
355
252
|
Builds a
|
|
356
253
|
[`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control)
|
|
357
|
-
|
|
358
|
-
|
|
254
|
+
header emitting only the options passed: `visibility` (`"public" | "private"`), the ages
|
|
255
|
+
`maxAge`, `sMaxAge`, `staleWhileRevalidate` and `staleIfError`, and the flags `noCache`,
|
|
256
|
+
`noStore`, `noTransform`, `mustRevalidate`, `proxyRevalidate` and `immutable`.
|
|
359
257
|
|
|
360
258
|
```typescript
|
|
361
|
-
import { policy } from "@sdxc/http/cache";
|
|
362
|
-
|
|
363
259
|
policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
|
|
364
260
|
// "public, max-age=3600, s-maxage=86400"
|
|
365
261
|
```
|
|
366
262
|
|
|
367
|
-
`PolicyOptions` carries `visibility` (`"public" | "private"`), the ages `maxAge`, `sMaxAge`,
|
|
368
|
-
`staleWhileRevalidate` and `staleIfError`, and the flags `noCache`, `noStore`, `noTransform`,
|
|
369
|
-
`mustRevalidate`, `proxyRevalidate` and `immutable`. Only what you pass is emitted.
|
|
370
|
-
`visibility` has no default: where an edge cache sits in front of the origin, `public` is
|
|
371
|
-
what allows one client's body to be served to another, so it is always written out.
|
|
372
|
-
|
|
373
263
|
#### `Policies`
|
|
374
264
|
|
|
375
|
-
The recurring policies,
|
|
376
|
-
short one to write.
|
|
265
|
+
The recurring policies, each returning a `CacheControl`:
|
|
377
266
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
Policies.
|
|
382
|
-
Policies.private({ maxAge: "5 minutes" }); // "private, max-age=300"
|
|
383
|
-
Policies.immutable(); // "public, max-age=31536000, immutable"
|
|
384
|
-
Policies.revalidate(); // "private, no-cache"
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
`noStore()` suits one-time payloads and anything a stored copy would turn into a security
|
|
388
|
-
problem. `private({ maxAge })` requires its age, because a browser given none applies its own
|
|
389
|
-
heuristic freshness. `immutable()` fits URLs whose bytes never change, meaning fingerprinted
|
|
390
|
-
asset names. `revalidate()` is the policy for authenticated HTML, and pairs `private` with
|
|
391
|
-
`no-cache` because that first directive is what keeps a shared cache out.
|
|
267
|
+
- `Policies.noStore()` — `no-store`, for one-time and sensitive payloads.
|
|
268
|
+
- `Policies.private({ maxAge })` — `private, max-age=…`, a per-user copy fresh for `maxAge`.
|
|
269
|
+
- `Policies.immutable()` — `public, max-age=31536000, immutable`, for fingerprinted URLs.
|
|
270
|
+
- `Policies.revalidate()` — `private, no-cache`, for authenticated HTML.
|
|
392
271
|
|
|
393
272
|
#### `etag(body: BinaryLike, options?: EtagOptions): Promise<Result<string, CryptoError>>`
|
|
394
273
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
server-rendered HTML carrying a timestamp.
|
|
398
|
-
|
|
399
|
-
```typescript
|
|
400
|
-
await etag(body); // '"uU0nuZNNPgilLlLX2n2r-sSE7-N6U4DukIj3rOLvzek"'
|
|
401
|
-
await etag(body, { weak: true }); // 'W/"uU0nuZNNPgilLlLX2n2r-…"'
|
|
402
|
-
```
|
|
403
|
-
|
|
404
|
-
Hashing costs CPU proportional to the payload, which suits HTML and JSON responses rather
|
|
405
|
-
than large bodies that are never revalidated.
|
|
274
|
+
A quoted SHA-256 base64url validator of the bytes; `{ weak: true }` writes a `W/` tag for
|
|
275
|
+
content that varies insignificantly between renders.
|
|
406
276
|
|
|
407
277
|
#### `lastModified(date: Date | number): string`
|
|
408
278
|
|
|
409
|
-
Formats a
|
|
410
|
-
HTTP dates hold whole seconds, so two writes in the same second share a validator; a
|
|
411
|
-
content-derived `ETag` is the stronger choice where one is available.
|
|
412
|
-
|
|
413
|
-
```typescript
|
|
414
|
-
lastModified(new Date("2015-10-21T07:28:00Z")); // "Wed, 21 Oct 2015 07:28:00 GMT"
|
|
415
|
-
```
|
|
279
|
+
Formats a date as the HTTP-date a `Last-Modified` header carries, to whole seconds.
|
|
416
280
|
|
|
417
281
|
#### `ifModifiedSince(headers: Headers): Date | null`
|
|
418
282
|
|
|
419
|
-
|
|
420
|
-
`null`, so callers send the full body rather than assert freshness they cannot prove.
|
|
283
|
+
The request's `If-Modified-Since` date, or `null` when absent or unparsable.
|
|
421
284
|
|
|
422
285
|
#### `isModifiedSince(modifiedAt: Date | number, since: Date | number): boolean`
|
|
423
286
|
|
|
424
|
-
Whether
|
|
425
|
-
seconds, so a change in the same second as the client's copy counts as unmodified.
|
|
287
|
+
Whether the resource changed after `since`, compared to the second.
|
|
426
288
|
|
|
427
289
|
#### `conditional(request: Request, response: Response): Promise<Response>`
|
|
428
290
|
|
|
429
|
-
|
|
430
|
-
`
|
|
431
|
-
`
|
|
432
|
-
|
|
433
|
-
Only a `GET` or `HEAD` answered with `200` is eligible, and every other method and status
|
|
434
|
-
passes through untouched, so this is safe at the end of any handler. The `304` keeps only
|
|
435
|
-
`Cache-Control`, `Content-Location`, `Date`, `ETag`, `Expires`, and `Vary`. Repeating `Vary`
|
|
436
|
-
matters: a shared cache without it can no longer tell which negotiated variant was validated.
|
|
291
|
+
Turns a `200` answer to a `GET` or `HEAD` into a `304` when the request's `If-None-Match`
|
|
292
|
+
matches the response's `ETag` (or, absent that header, `If-Modified-Since` covers its
|
|
293
|
+
`Last-Modified`), keeping only `Cache-Control`,
|
|
294
|
+
`Content-Location`, `Date`, `ETag`, `Expires` and `Vary`. Any other response passes through.
|
|
437
295
|
|
|
438
296
|
#### `precondition(request: Request, options: PreconditionOptions): Result<string, PreconditionFailedError>`
|
|
439
297
|
|
|
440
|
-
Checks a write
|
|
441
|
-
|
|
442
|
-
other value is compared strongly, so a weak tag fails. The failure is returned rather than
|
|
443
|
-
thrown, which keeps answering with a `412` your decision.
|
|
444
|
-
|
|
445
|
-
```typescript
|
|
446
|
-
import { precondition } from "@sdxc/http/cache";
|
|
447
|
-
import { preconditionFailed } from "@sdxc/http/response/html";
|
|
448
|
-
import { isFailure } from "@sdxc/result";
|
|
449
|
-
|
|
450
|
-
let checked = precondition(request, { etag: current });
|
|
451
|
-
if (isFailure(checked)) return preconditionFailed("<h1>Precondition Failed</h1>");
|
|
452
|
-
```
|
|
298
|
+
Checks a write's `If-Match` against `options.etag`, the resource's current tag. An absent
|
|
299
|
+
`If-Match` or `*` passes; any other value is compared strongly.
|
|
453
300
|
|
|
454
301
|
#### `PreconditionFailedError`
|
|
455
302
|
|
|
456
|
-
The
|
|
457
|
-
diagnostics.
|
|
303
|
+
The failure `precondition` returns, carrying the resource's current `etag`.
|
|
458
304
|
|
|
459
305
|
#### `vary(headers: Headers, names: string | string[]): Headers`
|
|
460
306
|
|
|
461
|
-
|
|
462
|
-
[`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary),
|
|
463
|
-
|
|
464
|
-
names are normalized to lowercase.
|
|
465
|
-
|
|
466
|
-
```typescript
|
|
467
|
-
let headers = new Headers({ Vary: "Accept-Encoding" });
|
|
468
|
-
vary(headers, ["Accept-Language", "Cookie"]);
|
|
469
|
-
headers.get("Vary"); // "accept-encoding, accept-language, cookie"
|
|
470
|
-
```
|
|
471
|
-
|
|
472
|
-
Each listed header multiplies the variants a shared cache stores for the URL, so the list is
|
|
473
|
-
a cost rather than documentation. Varying on `Cookie` leaves shared caching off for any
|
|
474
|
-
request that carries one; a response that genuinely differs per user wants
|
|
475
|
-
`Policies.private()`.
|
|
307
|
+
Merges lowercase names into the
|
|
308
|
+
[`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary) header,
|
|
309
|
+
mutating and returning `headers`. Each name multiplies the variants a shared cache stores.
|
|
476
310
|
|
|
477
311
|
#### Types
|
|
478
312
|
|
|
479
|
-
`CacheVisibility`, `PolicyOptions`, `PrivatePolicyOptions`, `EtagOptions
|
|
480
|
-
`PreconditionOptions
|
|
313
|
+
`CacheVisibility`, `PolicyOptions`, `PrivatePolicyOptions`, `EtagOptions` and
|
|
314
|
+
`PreconditionOptions`.
|
|
481
315
|
|
|
482
316
|
### `@sdxc/http/middleware/head-requests`
|
|
483
317
|
|
|
484
318
|
#### `headRequests(): Middleware`
|
|
485
319
|
|
|
486
|
-
Router middleware that dispatches a `HEAD` as a `GET
|
|
487
|
-
|
|
488
|
-
through the same auth and rate limiting as `GET`.
|
|
320
|
+
Router middleware that dispatches a `HEAD` as a `GET` and drops the body, keeping status and
|
|
321
|
+
headers. Place it first in the global chain.
|
|
489
322
|
|
|
490
323
|
## Pattern: A Validated API Endpoint
|
|
491
324
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
spelled as the status it answers with:
|
|
325
|
+
Pair [`@sdxc/validate`](https://www.npmjs.com/package/@sdxc/validate) with the status-named
|
|
326
|
+
JSON helpers so each outcome reads as the status it answers with.
|
|
495
327
|
|
|
496
328
|
```typescript
|
|
497
329
|
import { badRequest, created } from "@sdxc/http/response/json";
|
|
@@ -514,18 +346,14 @@ export async function handler(request: Request): Promise<Response> {
|
|
|
514
346
|
}
|
|
515
347
|
```
|
|
516
348
|
|
|
517
|
-
`validate` reads the
|
|
518
|
-
[`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) body according to its
|
|
519
|
-
`Content-Type`, so the same endpoint accepts JSON and a form submission.
|
|
520
|
-
|
|
521
349
|
## Pattern: A Conditional Write
|
|
522
350
|
|
|
523
|
-
|
|
524
|
-
|
|
351
|
+
The read hands out an `ETag`; the write requires it back, so a client cannot overwrite a
|
|
352
|
+
change it never saw.
|
|
525
353
|
|
|
526
354
|
```typescript
|
|
527
355
|
import { etag, precondition } from "@sdxc/http/cache";
|
|
528
|
-
import {
|
|
356
|
+
import { ok, preconditionFailed } from "@sdxc/http/response/json";
|
|
529
357
|
import { isFailure, unwrap } from "@sdxc/result";
|
|
530
358
|
|
|
531
359
|
export async function show(id: string): Promise<Response> {
|
|
@@ -543,16 +371,43 @@ export async function update(request: Request, id: string): Promise<Response> {
|
|
|
543
371
|
if (isFailure(checked)) return preconditionFailed({ etag: checked.error.etag });
|
|
544
372
|
|
|
545
373
|
let saved = await writeDocument(id, await request.json());
|
|
546
|
-
if (!saved) return conflict({ error: "Document changed" });
|
|
547
|
-
|
|
548
374
|
return ok(saved);
|
|
549
375
|
}
|
|
550
376
|
```
|
|
551
377
|
|
|
378
|
+
## Pattern: Revalidate by Modification Time
|
|
379
|
+
|
|
380
|
+
When a record stores its update time, `Last-Modified` validates it, and checking
|
|
381
|
+
`If-Modified-Since` first skips loading the body for a client whose copy is current.
|
|
382
|
+
|
|
383
|
+
```typescript
|
|
384
|
+
import { ifModifiedSince, isModifiedSince, lastModified, Policies } from "@sdxc/http/cache";
|
|
385
|
+
import { json } from "@sdxc/http/response";
|
|
386
|
+
import { NotModified } from "@sdxc/http/status-code";
|
|
387
|
+
|
|
388
|
+
export async function handler(request: Request, id: string): Promise<Response> {
|
|
389
|
+
let updatedAt = await readArticleUpdatedAt(id);
|
|
390
|
+
let headers = new Headers({
|
|
391
|
+
"Cache-Control": Policies.private({ maxAge: "1 minute" }).toString(),
|
|
392
|
+
"Last-Modified": lastModified(updatedAt),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
let since = ifModifiedSince(request.headers);
|
|
396
|
+
if (since && !isModifiedSince(updatedAt, since)) {
|
|
397
|
+
return new Response(null, { ...NotModified, headers });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return json(await readArticle(id), { headers });
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`conditional` reaches the same `304` from the response's own `Last-Modified` when the body
|
|
405
|
+
is already built.
|
|
406
|
+
|
|
552
407
|
## Pattern: A Cached Asset Route
|
|
553
408
|
|
|
554
|
-
Fingerprinted asset URLs
|
|
555
|
-
revalidation
|
|
409
|
+
Fingerprinted asset URLs change with their bytes, so the response is cached for a year with
|
|
410
|
+
no revalidation.
|
|
556
411
|
|
|
557
412
|
```typescript
|
|
558
413
|
import { Policies } from "@sdxc/http/cache";
|
|
@@ -567,6 +422,81 @@ export async function handler(request: Request): Promise<Response> {
|
|
|
567
422
|
}
|
|
568
423
|
```
|
|
569
424
|
|
|
425
|
+
## Pattern: A Streamed CSV Download
|
|
426
|
+
|
|
427
|
+
A streamed `csv` sends rows as they are read, and `attachment` makes the browser save it.
|
|
428
|
+
|
|
429
|
+
```typescript
|
|
430
|
+
import { Policies } from "@sdxc/http/cache";
|
|
431
|
+
import { attachment, csv } from "@sdxc/http/response";
|
|
432
|
+
|
|
433
|
+
export function handler(): Response {
|
|
434
|
+
let encoder = new TextEncoder();
|
|
435
|
+
let body = new ReadableStream<Uint8Array>({
|
|
436
|
+
async start(controller) {
|
|
437
|
+
controller.enqueue(encoder.encode("name,city\r\n"));
|
|
438
|
+
for await (let row of readRows()) {
|
|
439
|
+
controller.enqueue(encoder.encode(`${row.name},${row.city}\r\n`));
|
|
440
|
+
}
|
|
441
|
+
controller.close();
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return csv(body, {
|
|
446
|
+
headers: {
|
|
447
|
+
"Content-Disposition": attachment("Customers – 2026.csv"),
|
|
448
|
+
"Cache-Control": Policies.noStore().toString(),
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Pattern: Post, Redirect, Get
|
|
455
|
+
|
|
456
|
+
A form handler answers each outcome with a builder: `303` sends the browser to the new
|
|
457
|
+
resource with a `GET`, and an invalid submission re-renders with `422`.
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
import { redirect } from "@sdxc/http/response";
|
|
461
|
+
import { unprocessableEntity } from "@sdxc/http/response/html";
|
|
462
|
+
|
|
463
|
+
export async function handler(request: Request): Promise<Response> {
|
|
464
|
+
let form = await request.formData();
|
|
465
|
+
let title = form.get("title");
|
|
466
|
+
if (typeof title !== "string" || title === "") {
|
|
467
|
+
return unprocessableEntity(renderForm({ error: "Title is required" }));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let post = await createPost(title);
|
|
471
|
+
return redirect(`/posts/${post.id}`, { status: redirect.Status.SeeOther });
|
|
472
|
+
}
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
## Pattern: Server-Sent Events
|
|
476
|
+
|
|
477
|
+
`stream` sets the event-stream headers; the body is any `ReadableStream` of encoded events.
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
import { stream } from "@sdxc/http/response";
|
|
481
|
+
|
|
482
|
+
export function handler(request: Request): Response {
|
|
483
|
+
let encoder = new TextEncoder();
|
|
484
|
+
let body = new ReadableStream<Uint8Array>({
|
|
485
|
+
start(controller) {
|
|
486
|
+
let timer = setInterval(() => {
|
|
487
|
+
controller.enqueue(encoder.encode(`data: ${new Date().toISOString()}\n\n`));
|
|
488
|
+
}, 1000);
|
|
489
|
+
request.signal.addEventListener("abort", () => {
|
|
490
|
+
clearInterval(timer);
|
|
491
|
+
controller.close();
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
return stream(body);
|
|
497
|
+
}
|
|
498
|
+
```
|
|
499
|
+
|
|
570
500
|
## Versioning
|
|
571
501
|
|
|
572
502
|
Releases are dated rather than semantic. A version is the UTC date it was published,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdxc/http",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.16",
|
|
4
4
|
"description": "Response builders, content negotiation, and HTTP caching for the Fetch API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,13 +15,15 @@
|
|
|
15
15
|
"./middleware/head-requests": "./dist/middleware/head-requests.js"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@sdxc/crypto": "2026.10.
|
|
19
|
-
"@sdxc/duration": "2026.10.
|
|
20
|
-
"@sdxc/location": "2026.10.
|
|
21
|
-
"@sdxc/result": "2026.10.
|
|
18
|
+
"@sdxc/crypto": "2026.10.13",
|
|
19
|
+
"@sdxc/duration": "2026.10.13",
|
|
20
|
+
"@sdxc/location": "2026.10.13",
|
|
21
|
+
"@sdxc/result": "2026.10.13"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
22
24
|
"remix": "3.0.0"
|
|
23
25
|
},
|
|
24
|
-
"gitHead": "
|
|
26
|
+
"gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
|
|
25
27
|
"publishConfig": {
|
|
26
28
|
"access": "public"
|
|
27
29
|
},
|
|
@@ -30,7 +32,7 @@
|
|
|
30
32
|
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
31
33
|
"directory": "packages/http"
|
|
32
34
|
},
|
|
33
|
-
"homepage": "https://sdxc.
|
|
35
|
+
"homepage": "https://sdxc.dev/api/http",
|
|
34
36
|
"author": {
|
|
35
37
|
"name": "Sergio Xalambrí",
|
|
36
38
|
"url": "https://sergiodxa.com"
|