@sdxc/http 2026.10.9 → 2026.10.13

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.
Files changed (2) hide show
  1. package/README.md +241 -311
  2. package/package.json +6 -6
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
- Named status codes and content types, response helpers that carry both, `Accept`
6
- negotiation, and the `Cache-Control` and `ETag` bookkeeping a conditional response needs.
7
- Each concern is its own subpath export, so importing the status codes leaves the caching
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
- ```bash
11
+ ```sh
13
12
  npm add @sdxc/http
14
13
  ```
15
14
 
16
- `etag` and `precondition` report their outcome as a `Result` from
17
- [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which is where `isFailure` and
18
- `isSuccess` come from. Cache ages are written as
19
- [`@sdxc/duration`](https://www.npmjs.com/package/@sdxc/duration) values, digests come from
20
- [`@sdxc/crypto`](https://www.npmjs.com/package/@sdxc/crypto), redirect targets are parsed by
21
- [`@sdxc/location`](https://www.npmjs.com/package/@sdxc/location), and the cache and middleware
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
- Each entry point is imported on its own, so a handler carries only the surface it uses:
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, and `Content-Type: application/json` for
58
- you, so the name of the function is the whole declaration.
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 The Format A Client Asked For
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
- return respond(request, {
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 client's `Accept` header prefers, and one URL serves a
79
- browser, a spreadsheet, and a script. Without a `default`, a request that matches nothing is
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 A Page And Answer Its Revalidations
80
+ ### Cache a page and answer its revalidations
84
81
 
85
82
  ```typescript
86
- import { conditional, etag, Policies, vary } from "@sdxc/http/cache";
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 stores the page and checks back before every reuse; `conditional` answers that
104
- check with a `304` whenever the validator still holds, so the body crosses the network only
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` Like `GET`
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` now reports the status and headers of the `GET` with no body, and runs
120
- through the same middleware chain, so a guard that refuses one refuses the other.
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` parameter, so one value both
127
- compares against a parsed header and writes into a new one.
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
- **Streaming:** `EventStream`, `NDJson`.
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 that way rather than `JSON` so it does not shadow the global `JSON`.
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, which is exactly the shape a
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
- ```typescript
156
- import { NotFound } from "@sdxc/http/status-code";
157
-
158
- return Response.json({ error: "Not found" }, NotFound); // 404 Not Found
159
- ```
160
-
161
- **1xx:** `Continue`, `SwitchingProtocols`.
162
-
163
- **2xx:** `Ok`, `Created`, `Accepted`, `NoContent`, `ResetContent`, `PartialContent`.
164
-
165
- **3xx:** `MultipleChoices`, `MovedPermanently`, `Found`, `SeeOther`, `NotModified`,
166
- `TemporaryRedirect`, `PermanentRedirect`.
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
- The type every constant satisfies: `{ readonly status: number; readonly statusText: string }`.
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 matching `Content-Type`,
185
- and returns a plain
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
- ```typescript
189
- import { csv, markdown, xml } from "@sdxc/http/response";
157
+ #### `json<T>(body: T, init?: ResponseInit): Response`
190
158
 
191
- xml("<root><item>Hello</item></root>"); // text/xml
192
- csv("name,age\nAda,36"); // text/csv; charset=utf-8
193
- markdown("# Hello"); // text/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
- `json(body, init?)` serializes any value through `Response.json`. `text`, `html`, `css`,
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`, so a client decodes accented and non-Latin cells as 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
- ```typescript
208
- import { csv } from "@sdxc/http/response";
169
+ #### `pdf(body: Blob | ArrayBuffer | ReadableStream, init?: ResponseInit): Response`
209
170
 
210
- let encoder = new TextEncoder();
171
+ Writes `application/pdf`.
211
172
 
212
- return csv(
213
- new ReadableStream<Uint8Array>({
214
- async start(controller) {
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 makes a response a download saved under `filename`. The quoted `filename` always
229
- holds an ASCII version of the name, with quotes and backslashes escaped and every other
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 stream: `text/event-stream`, `Cache-Control: no-cache`, and
190
+ A Server-Sent Events response: `text/event-stream`, `Cache-Control: no-cache` and
258
191
  `Connection: keep-alive`.
259
192
 
260
- #### `noContent(init?): Response`
193
+ #### `noContent(init?: Omit<ResponseInit, "status" | "statusText">): Response`
261
194
 
262
- A `204 No Content` with no body. The status is fixed, so `init` carries headers only.
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` and defaults to `307`. The target is validated first, so an unparsable one
267
- throws at the call site rather than reaching a client as a broken redirect.
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
- Reads the request's `Accept` header, treating an absent one as `*/*`.
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
- #### `list.all(): string[]`
222
+ #### `AcceptList`
316
223
 
317
- The accepted types, highest quality first.
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
- #### `list.preferred(...types: string[]): string | null`
320
-
321
- The first of your candidates the client asks for, walking its preferences in order. A
322
- wildcard header picks the first candidate, making your own order the tiebreaker.
323
-
324
- #### `list.toShortType(mimeType: string): string | null`
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 client's most preferred type, falling back to `default` and
340
- otherwise answering `406 Not Acceptable`.
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
- `Cache-Control` policies, validators, and conditional requests, built on the typed
349
- `CacheControl`, `IfNoneMatch`, `IfMatch`, and `Vary` classes from `remix/headers`. Every age
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
- value from a description of intent, returning the header class itself so the result composes
358
- with anything that accepts one.
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, named after the outcome each produces, so the safe answer is also the
376
- short one to write.
265
+ The recurring policies, each returning a `CacheControl`:
377
266
 
378
- ```typescript
379
- import { Policies } from "@sdxc/http/cache";
380
-
381
- Policies.noStore(); // "no-store"
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
- Derives a validator from the bytes of a payload: SHA-256, base64url, quoted. Pass
396
- `{ weak: true }` for content that varies in insignificant ways between renders, such as
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 `Date` or epoch milliseconds as the HTTP-date a `Last-Modified` validator carries.
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
- Reads the `If-Modified-Since` date from a request. An absent or unparsable value reads as
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 a resource changed after the copy a client holds. Both times are compared as whole
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
- Downgrades a response to a `304` when the request's validators still describe it.
430
- `If-None-Match` is evaluated with weak comparison and decides on its own whenever present;
431
- `If-Modified-Since` is consulted in its absence.
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 request's `If-Match` against the resource's current validator, so a client
441
- cannot overwrite a change it never saw. An absent `If-Match` passes, `*` passes, and every
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 failed precondition, carrying the `etag` the resource is actually at for logs and
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
- Adds request header names to a response's
462
- [`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary), merging
463
- into whatever is already there. The `Headers` object is mutated in place and returned, and
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`, and
480
- `PreconditionOptions` are exported from this subpath.
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`, then strips the body while keeping the
487
- status and headers, as RFC 9110 requires. Place it first in the global chain so `HEAD` runs
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
- A `Result` from [`@sdxc/validate`](https://www.npmjs.com/package/@sdxc/validate) and the
493
- status-named JSON helpers put the whole endpoint in one straight line, with each outcome
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
- Read and write can share one validator: the read hands the client an `ETag`, and the write
524
- requires that same tag back, which is what turns a blind overwrite into a detected conflict:
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 { conflict, ok, preconditionFailed } from "@sdxc/http/response/json";
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 are the one case where a response can be cached for a year without
555
- revalidation, since a new build produces a new URL rather than new bytes at the old one:
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.9",
3
+ "version": "2026.10.13",
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,13 @@
15
15
  "./middleware/head-requests": "./dist/middleware/head-requests.js"
16
16
  },
17
17
  "dependencies": {
18
- "@sdxc/crypto": "2026.10.9",
19
- "@sdxc/duration": "2026.10.6",
20
- "@sdxc/location": "2026.10.2",
21
- "@sdxc/result": "2026.10.6",
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
22
  "remix": "3.0.0"
23
23
  },
24
- "gitHead": "64fa732ed0a8ca8693a35aeef6345ae5be2534ef",
24
+ "gitHead": "5ff758a57e35480a8db7abd24f1d9527b81bfb51",
25
25
  "publishConfig": {
26
26
  "access": "public"
27
27
  },