@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 +21 -0
- package/README.md +549 -0
- package/dist/cache/conditional.d.ts +24 -0
- package/dist/cache/conditional.js +108 -0
- package/dist/cache/etag.d.ts +37 -0
- package/dist/cache/etag.js +33 -0
- package/dist/cache/http-date.d.ts +38 -0
- package/dist/cache/http-date.js +56 -0
- package/dist/cache/if-modified-since.d.ts +36 -0
- package/dist/cache/if-modified-since.js +53 -0
- package/dist/cache/index.d.ts +20 -0
- package/dist/cache/index.js +16 -0
- package/dist/cache/policies.d.ts +73 -0
- package/dist/cache/policies.js +78 -0
- package/dist/cache/policy.d.ts +63 -0
- package/dist/cache/policy.js +54 -0
- package/dist/cache/precondition.d.ts +53 -0
- package/dist/cache/precondition.js +53 -0
- package/dist/cache/vary.d.ts +24 -0
- package/dist/cache/vary.js +33 -0
- package/dist/content-type.d.ts +312 -0
- package/dist/content-type.js +312 -0
- package/dist/middleware/head-requests.d.ts +19 -0
- package/dist/middleware/head-requests.js +30 -0
- package/dist/negotiate.d.ts +97 -0
- package/dist/negotiate.js +156 -0
- package/dist/response/html.d.ts +272 -0
- package/dist/response/html.js +316 -0
- package/dist/response/json.d.ts +272 -0
- package/dist/response/json.js +316 -0
- package/dist/response.d.ts +171 -0
- package/dist/response.js +225 -0
- package/dist/status-code.d.ts +620 -0
- package/dist/status-code.js +486 -0
- package/package.json +33 -0
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>;
|