@sdxc/http 0.0.0-pre.1

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergio Xalambrí
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,549 @@
1
+ # @sdxc/http
2
+
3
+ Response builders, content negotiation, and HTTP caching for the Fetch API.
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.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm add @sdxc/http
14
+ ```
15
+
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.
24
+
25
+ ## Usage
26
+
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
42
+
43
+ ```typescript
44
+ import { badRequest, notFound, ok } from "@sdxc/http/response/json";
45
+
46
+ export async function handler(request: Request): Promise<Response> {
47
+ let id = new URL(request.url).searchParams.get("id");
48
+ if (!id) return badRequest({ error: "Missing id" });
49
+
50
+ let user = await findUser(id);
51
+ if (!user) return notFound({ error: "User not found" });
52
+
53
+ return ok({ user });
54
+ }
55
+ ```
56
+
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.
59
+
60
+ ### Serve The Format A Client Asked For
61
+
62
+ ```typescript
63
+ import { respond } from "@sdxc/http/negotiate";
64
+ import { csv, html, json } from "@sdxc/http/response";
65
+
66
+ export async function handler(request: Request): Promise<Response> {
67
+ let report = await buildReport();
68
+
69
+ return respond(request, {
70
+ json: () => json(report),
71
+ html: () => html(renderReport(report)),
72
+ csv: () => csv(toCsv(report)),
73
+ default: () => json(report),
74
+ });
75
+ }
76
+ ```
77
+
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.
82
+
83
+ ### Cache A Page And Answer Its Revalidations
84
+
85
+ ```typescript
86
+ import { conditional, etag, Policies, vary } from "@sdxc/http/cache";
87
+ import { html } from "@sdxc/http/response";
88
+ import { isSuccess } from "@sdxc/result";
89
+
90
+ export async function handler(request: Request): Promise<Response> {
91
+ let body = await renderPage();
92
+
93
+ let headers = new Headers({ "Cache-Control": Policies.revalidate().toString() });
94
+ vary(headers, ["Accept-Language"]);
95
+
96
+ let tag = await etag(body, { weak: true });
97
+ if (isSuccess(tag)) headers.set("ETag", tag.data);
98
+
99
+ return await conditional(request, html(body, { headers }));
100
+ }
101
+ ```
102
+
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.
106
+
107
+ ### Answer `HEAD` Like `GET`
108
+
109
+ ```typescript
110
+ import { headRequests } from "@sdxc/http/middleware/head-requests";
111
+ import { html } from "@sdxc/http/response";
112
+ import { createRouter } from "remix/router";
113
+
114
+ let router = createRouter({ middleware: [headRequests()] });
115
+
116
+ router.get("/page", () => html(renderPage()));
117
+ ```
118
+
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.
121
+
122
+ ## API
123
+
124
+ ### `@sdxc/http/content-type`
125
+
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`.
145
+
146
+ **Streaming:** `EventStream`, `NDJson`.
147
+
148
+ `Json` is spelled that way rather than `JSON` so it does not shadow the global `JSON`.
149
+
150
+ ### `@sdxc/http/status-code`
151
+
152
+ Each constant is a `{ status, statusText }` object, which is exactly the shape a
153
+ `ResponseInit` wants, so it drops straight into a response.
154
+
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`
179
+
180
+ The type every constant satisfies: `{ readonly status: number; readonly statusText: string }`.
181
+
182
+ ### `@sdxc/http/response`
183
+
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).
187
+
188
+ ```typescript
189
+ import { csv, markdown, xml } from "@sdxc/http/response";
190
+
191
+ xml("<root><item>Hello</item></root>"); // text/xml
192
+ csv("name,age\nAda,36"); // text/csv
193
+ markdown("# Hello"); // text/markdown
194
+ ```
195
+
196
+ `json(body, init?)` serializes any value through `Response.json`. `text`, `html`, `css`,
197
+ `javascript`, `xml`, `csv` 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`.
200
+
201
+ #### `file(body: Blob | ArrayBuffer | ReadableStream, filename: string, init?: ResponseInit): Response`
202
+
203
+ A download: `application/octet-stream` plus
204
+ `Content-Disposition: attachment; filename="…"`.
205
+
206
+ #### `stream(body: ReadableStream, init?: ResponseInit): Response`
207
+
208
+ A Server-Sent Events stream: `text/event-stream`, `Cache-Control: no-cache`, and
209
+ `Connection: keep-alive`.
210
+
211
+ #### `noContent(init?): Response`
212
+
213
+ A `204 No Content` with no body. The status is fixed, so `init` carries headers only.
214
+
215
+ #### `redirect(target: URL | Location | string, init?: redirect.Init): Response`
216
+
217
+ Writes `Location` and defaults to `307`. The target is validated first, so an unparsable one
218
+ throws at the call site rather than reaching a client as a broken redirect.
219
+
220
+ `redirect.Status` names the three worth choosing between: `SeeOther` (303) turns a `POST`
221
+ into a `GET`, while `Temporary` (307) and `Permanent` (308) keep the method.
222
+
223
+ ### `@sdxc/http/response/json`
224
+
225
+ One function per status, each taking a value plus optional headers and writing the JSON body,
226
+ the status, and the status text together.
227
+
228
+ `ok`, `created`, `accepted`, `badRequest`, `unauthorized`, `paymentRequired`, `forbidden`,
229
+ `notFound`, `methodNotAllowed`, `notAcceptable`, `conflict`, `gone`, `preconditionFailed`,
230
+ `payloadTooLarge`, `unsupportedMediaType`, `unprocessableEntity`, `tooManyRequests`,
231
+ `internalServerError`, `notImplemented`, `badGateway`, `serviceUnavailable`,
232
+ `gatewayTimeout`.
233
+
234
+ ```typescript
235
+ import { created } from "@sdxc/http/response/json";
236
+
237
+ return created({ id: user.id }); // 201 Created, application/json
238
+ ```
239
+
240
+ ### `@sdxc/http/response/html`
241
+
242
+ The same names, taking an HTML string and writing `text/html`.
243
+
244
+ ```typescript
245
+ import { notFound } from "@sdxc/http/response/html";
246
+
247
+ return notFound("<h1>Page Not Found</h1>"); // 404 Not Found, text/html
248
+ ```
249
+
250
+ ### `@sdxc/http/negotiate`
251
+
252
+ #### `accepts(request: Request): AcceptList`
253
+
254
+ Reads the request's `Accept` header, treating an absent one as `*/*`.
255
+
256
+ #### `new AcceptList(header: string)`
257
+
258
+ A parsed `Accept` header, sorted by quality, so preference is queried without re-parsing.
259
+
260
+ #### `list.includes(type: string): boolean`
261
+
262
+ Whether a type is accepted. Takes a full MIME type or a shorthand — `json`, `html`, `xml`,
263
+ `text`, `markdown`, `css`, `javascript`, `csv`, `pdf` — and a wildcard header matches
264
+ everything.
265
+
266
+ #### `list.all(): string[]`
267
+
268
+ The accepted types, highest quality first.
269
+
270
+ #### `list.preferred(...types: string[]): string | null`
271
+
272
+ The first of your candidates the client asks for, walking its preferences in order. A
273
+ wildcard header picks the first candidate, making your own order the tiebreaker.
274
+
275
+ #### `list.toShortType(mimeType: string): string | null`
276
+
277
+ The shorthand for a MIME type, or `null` when it has none.
278
+
279
+ ```typescript
280
+ let list = new AcceptList("text/html, application/json;q=0.9");
281
+
282
+ list.includes("json"); // true
283
+ list.all(); // ["text/html", "application/json"]
284
+ list.preferred("application/json", "text/html"); // "text/html"
285
+ list.toShortType("application/json"); // "json"
286
+ ```
287
+
288
+ #### `respond(request: Request, handlers: respond.Handlers): Response`
289
+
290
+ Calls the handler for the client's most preferred type, falling back to `default` and
291
+ otherwise answering `406 Not Acceptable`.
292
+
293
+ #### `respond.Handlers`
294
+
295
+ The handlers object: an optional `() => Response` under each shorthand, plus `default`.
296
+
297
+ ### `@sdxc/http/cache`
298
+
299
+ `Cache-Control` policies, validators, and conditional requests, built on the typed
300
+ `CacheControl`, `IfNoneMatch`, `IfMatch`, and `Vary` classes from `remix/headers`. Every age
301
+ is a duration, so `"1 hour"` reads as an hour at the call site and converts to whole seconds
302
+ internally.
303
+
304
+ #### `policy(options?: PolicyOptions): CacheControl`
305
+
306
+ Builds a
307
+ [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control)
308
+ value from a description of intent, returning the header class itself so the result composes
309
+ with anything that accepts one.
310
+
311
+ ```typescript
312
+ import { policy } from "@sdxc/http/cache";
313
+
314
+ policy({ visibility: "public", maxAge: "1 hour", sMaxAge: "1 day" }).toString();
315
+ // "public, max-age=3600, s-maxage=86400"
316
+ ```
317
+
318
+ `PolicyOptions` carries `visibility` (`"public" | "private"`), the ages `maxAge`, `sMaxAge`,
319
+ `staleWhileRevalidate` and `staleIfError`, and the flags `noCache`, `noStore`, `noTransform`,
320
+ `mustRevalidate`, `proxyRevalidate` and `immutable`. Only what you pass is emitted.
321
+ `visibility` has no default: where an edge cache sits in front of the origin, `public` is
322
+ what allows one client's body to be served to another, so it is always written out.
323
+
324
+ #### `Policies`
325
+
326
+ The recurring policies, named after the outcome each produces, so the safe answer is also the
327
+ short one to write.
328
+
329
+ ```typescript
330
+ import { Policies } from "@sdxc/http/cache";
331
+
332
+ Policies.noStore(); // "no-store"
333
+ Policies.private({ maxAge: "5 minutes" }); // "private, max-age=300"
334
+ Policies.immutable(); // "public, max-age=31536000, immutable"
335
+ Policies.revalidate(); // "private, no-cache"
336
+ ```
337
+
338
+ `noStore()` suits one-time payloads and anything a stored copy would turn into a security
339
+ problem. `private({ maxAge })` requires its age, because a browser given none applies its own
340
+ heuristic freshness. `immutable()` fits URLs whose bytes never change, meaning fingerprinted
341
+ asset names. `revalidate()` is the policy for authenticated HTML, and pairs `private` with
342
+ `no-cache` because that first directive is what keeps a shared cache out.
343
+
344
+ #### `etag(body: BinaryLike, options?: EtagOptions): Promise<Result<string, CryptoError>>`
345
+
346
+ Derives a validator from the bytes of a payload: SHA-256, base64url, quoted. Pass
347
+ `{ weak: true }` for content that varies in insignificant ways between renders, such as
348
+ server-rendered HTML carrying a timestamp.
349
+
350
+ ```typescript
351
+ await etag(body); // '"uU0nuZNNPgilLlLX2n2r-sSE7-N6U4DukIj3rOLvzek"'
352
+ await etag(body, { weak: true }); // 'W/"uU0nuZNNPgilLlLX2n2r-…"'
353
+ ```
354
+
355
+ Hashing costs CPU proportional to the payload, which suits HTML and JSON responses rather
356
+ than large bodies that are never revalidated.
357
+
358
+ #### `lastModified(date: Date | number): string`
359
+
360
+ Formats a `Date` or epoch milliseconds as the HTTP-date a `Last-Modified` validator carries.
361
+ HTTP dates hold whole seconds, so two writes in the same second share a validator; a
362
+ content-derived `ETag` is the stronger choice where one is available.
363
+
364
+ ```typescript
365
+ lastModified(new Date("2015-10-21T07:28:00Z")); // "Wed, 21 Oct 2015 07:28:00 GMT"
366
+ ```
367
+
368
+ #### `ifModifiedSince(headers: Headers): Date | null`
369
+
370
+ Reads the `If-Modified-Since` date from a request. An absent or unparsable value reads as
371
+ `null`, so callers send the full body rather than assert freshness they cannot prove.
372
+
373
+ #### `isModifiedSince(modifiedAt: Date | number, since: Date | number): boolean`
374
+
375
+ Whether a resource changed after the copy a client holds. Both times are compared as whole
376
+ seconds, so a change in the same second as the client's copy counts as unmodified.
377
+
378
+ #### `conditional(request: Request, response: Response): Promise<Response>`
379
+
380
+ Downgrades a response to a `304` when the request's validators still describe it.
381
+ `If-None-Match` is evaluated with weak comparison and decides on its own whenever present;
382
+ `If-Modified-Since` is consulted in its absence.
383
+
384
+ Only a `GET` or `HEAD` answered with `200` is eligible, and every other method and status
385
+ passes through untouched, so this is safe at the end of any handler. The `304` keeps only
386
+ `Cache-Control`, `Content-Location`, `Date`, `ETag`, `Expires`, and `Vary`. Repeating `Vary`
387
+ matters: a shared cache without it can no longer tell which negotiated variant was validated.
388
+
389
+ #### `precondition(request: Request, options: PreconditionOptions): Result<string, PreconditionFailedError>`
390
+
391
+ Checks a write request's `If-Match` against the resource's current validator, so a client
392
+ cannot overwrite a change it never saw. An absent `If-Match` passes, `*` passes, and every
393
+ other value is compared strongly, so a weak tag fails. The failure is returned rather than
394
+ thrown, which keeps answering with a `412` your decision.
395
+
396
+ ```typescript
397
+ import { precondition } from "@sdxc/http/cache";
398
+ import { preconditionFailed } from "@sdxc/http/response/html";
399
+ import { isFailure } from "@sdxc/result";
400
+
401
+ let checked = precondition(request, { etag: current });
402
+ if (isFailure(checked)) return preconditionFailed("<h1>Precondition Failed</h1>");
403
+ ```
404
+
405
+ #### `PreconditionFailedError`
406
+
407
+ The failed precondition, carrying the `etag` the resource is actually at for logs and
408
+ diagnostics.
409
+
410
+ #### `vary(headers: Headers, names: string | string[]): Headers`
411
+
412
+ Adds request header names to a response's
413
+ [`Vary`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Vary), merging
414
+ into whatever is already there. The `Headers` object is mutated in place and returned, and
415
+ names are normalized to lowercase.
416
+
417
+ ```typescript
418
+ let headers = new Headers({ Vary: "Accept-Encoding" });
419
+ vary(headers, ["Accept-Language", "Cookie"]);
420
+ headers.get("Vary"); // "accept-encoding, accept-language, cookie"
421
+ ```
422
+
423
+ Each listed header multiplies the variants a shared cache stores for the URL, so the list is
424
+ a cost rather than documentation. Varying on `Cookie` leaves shared caching off for any
425
+ request that carries one; a response that genuinely differs per user wants
426
+ `Policies.private()`.
427
+
428
+ #### Types
429
+
430
+ `CacheVisibility`, `PolicyOptions`, `PrivatePolicyOptions`, `EtagOptions`, and
431
+ `PreconditionOptions` are exported from this subpath.
432
+
433
+ ### `@sdxc/http/middleware/head-requests`
434
+
435
+ #### `headRequests(): Middleware`
436
+
437
+ Router middleware that dispatches a `HEAD` as a `GET`, then strips the body while keeping the
438
+ status and headers, as RFC 9110 requires. Place it first in the global chain so `HEAD` runs
439
+ through the same auth and rate limiting as `GET`.
440
+
441
+ ## Pattern: A Validated API Endpoint
442
+
443
+ A `Result` from [`@sdxc/validate`](https://www.npmjs.com/package/@sdxc/validate) and the
444
+ status-named JSON helpers put the whole endpoint in one straight line, with each outcome
445
+ spelled as the status it answers with:
446
+
447
+ ```typescript
448
+ import { badRequest, created } from "@sdxc/http/response/json";
449
+ import { isFailure } from "@sdxc/result";
450
+ import { validate } from "@sdxc/validate";
451
+ import * as s from "remix/data-schema";
452
+ import { email, minLength } from "remix/data-schema/checks";
453
+
454
+ let Signup = s.object({
455
+ email: s.string().pipe(email()),
456
+ name: s.string().pipe(minLength(1)),
457
+ });
458
+
459
+ export async function handler(request: Request): Promise<Response> {
460
+ let result = await validate(request, Signup);
461
+ if (isFailure(result)) return badRequest({ errors: result.error.issues });
462
+
463
+ let user = await createUser(result.data);
464
+ return created({ user });
465
+ }
466
+ ```
467
+
468
+ `validate` reads the
469
+ [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) body according to its
470
+ `Content-Type`, so the same endpoint accepts JSON and a form submission.
471
+
472
+ ## Pattern: A Conditional Write
473
+
474
+ Read and write can share one validator: the read hands the client an `ETag`, and the write
475
+ requires that same tag back, which is what turns a blind overwrite into a detected conflict:
476
+
477
+ ```typescript
478
+ import { etag, precondition } from "@sdxc/http/cache";
479
+ import { conflict, ok, preconditionFailed } from "@sdxc/http/response/json";
480
+ import { isFailure, unwrap } from "@sdxc/result";
481
+
482
+ export async function show(id: string): Promise<Response> {
483
+ let doc = await readDocument(id);
484
+ let tag = unwrap(await etag(JSON.stringify(doc)));
485
+
486
+ return ok(doc, { headers: { ETag: tag } });
487
+ }
488
+
489
+ export async function update(request: Request, id: string): Promise<Response> {
490
+ let doc = await readDocument(id);
491
+ let current = unwrap(await etag(JSON.stringify(doc)));
492
+
493
+ let checked = precondition(request, { etag: current });
494
+ if (isFailure(checked)) return preconditionFailed({ etag: checked.error.etag });
495
+
496
+ let saved = await writeDocument(id, await request.json());
497
+ if (!saved) return conflict({ error: "Document changed" });
498
+
499
+ return ok(saved);
500
+ }
501
+ ```
502
+
503
+ ## Pattern: A Cached Asset Route
504
+
505
+ Fingerprinted asset URLs are the one case where a response can be cached for a year without
506
+ revalidation, since a new build produces a new URL rather than new bytes at the old one:
507
+
508
+ ```typescript
509
+ import { Policies } from "@sdxc/http/cache";
510
+ import { css, javascript } from "@sdxc/http/response";
511
+
512
+ export async function handler(request: Request): Promise<Response> {
513
+ let asset = await readAsset(new URL(request.url).pathname);
514
+ let headers = { "Cache-Control": Policies.immutable().toString() };
515
+
516
+ if (asset.type === "css") return css(asset.source, { headers });
517
+ return javascript(asset.source, { headers });
518
+ }
519
+ ```
520
+
521
+ ## Versioning
522
+
523
+ Releases are dated rather than semantic. A version is the UTC date it was published,
524
+ written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
525
+ release goes out per day.
526
+
527
+ Those numbers say when, not what: a later date means a later release and carries no
528
+ compatibility promise. Any release may change or remove an export.
529
+
530
+ Depend on one exact date, and move it when you are ready to take the change:
531
+
532
+ ```json
533
+ {
534
+ "dependencies": {
535
+ "@sdxc/http": "2026.9.4"
536
+ }
537
+ }
538
+ ```
539
+
540
+ A caret or tilde range reads the date as major, minor and patch, so it accepts every
541
+ later release in the same year. An exact version keeps the upgrade yours to schedule.
542
+
543
+ ## License
544
+
545
+ MIT
546
+
547
+ ## Author
548
+
549
+ [Sergio Xalambrí](https://sergiodxa.com)
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Answering conditional requests: when the validators a client sends back still
3
+ * describe the response about to be sent, the body is dropped and a `304` is
4
+ * returned instead, carrying only the headers a `304` is allowed to carry.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /**
10
+ * Downgrades a response to a `304` when the request's validators still hold.
11
+ * Only a `GET` or `HEAD` answered with `200` is eligible, so this runs safely
12
+ * at the end of any handler, using `ETag` or `Last-Modified` set on the response.
13
+ *
14
+ * @param request - The incoming request, carrying the client's validators.
15
+ * @param response - The response the handler produced.
16
+ * @returns A `304` with no body, or the original response unchanged.
17
+ *
18
+ * @example
19
+ * return await conditional(request, html(body, { headers }));
20
+ * @example
21
+ * let response = await conditional(request, json(payload, { headers }));
22
+ * response.status; // 304 when the client's ETag still matches
23
+ */
24
+ export declare function conditional(request: Request, response: Response): Promise<Response>;