@nxgt/httpyz 0.1.0

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 (54) hide show
  1. package/README.md +1295 -0
  2. package/dist/cancel/abort.d.ts +20 -0
  3. package/dist/cancel/abort.d.ts.map +1 -0
  4. package/dist/cancel/latest.d.ts +3 -0
  5. package/dist/cancel/latest.d.ts.map +1 -0
  6. package/dist/client/create-http-client.d.ts +14 -0
  7. package/dist/client/create-http-client.d.ts.map +1 -0
  8. package/dist/client/types.d.ts +123 -0
  9. package/dist/client/types.d.ts.map +1 -0
  10. package/dist/errors/errors.d.ts +80 -0
  11. package/dist/errors/errors.d.ts.map +1 -0
  12. package/dist/index.d.ts +15 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +1110 -0
  15. package/dist/index.js.map +27 -0
  16. package/dist/integration/index.d.ts +9 -0
  17. package/dist/integration/index.d.ts.map +1 -0
  18. package/dist/integration/index.js +1051 -0
  19. package/dist/integration/index.js.map +25 -0
  20. package/dist/middleware/auth.d.ts +18 -0
  21. package/dist/middleware/auth.d.ts.map +1 -0
  22. package/dist/middleware/cache.d.ts +42 -0
  23. package/dist/middleware/cache.d.ts.map +1 -0
  24. package/dist/middleware/compose.d.ts +22 -0
  25. package/dist/middleware/compose.d.ts.map +1 -0
  26. package/dist/middleware/retry.d.ts +36 -0
  27. package/dist/middleware/retry.d.ts.map +1 -0
  28. package/dist/reply/media-type.d.ts +18 -0
  29. package/dist/reply/media-type.d.ts.map +1 -0
  30. package/dist/reply/read-reply.d.ts +25 -0
  31. package/dist/reply/read-reply.d.ts.map +1 -0
  32. package/dist/reply/types.d.ts +55 -0
  33. package/dist/reply/types.d.ts.map +1 -0
  34. package/dist/reply/unwrap.d.ts +31 -0
  35. package/dist/reply/unwrap.d.ts.map +1 -0
  36. package/dist/request/encode.d.ts +24 -0
  37. package/dist/request/encode.d.ts.map +1 -0
  38. package/dist/request/types.d.ts +57 -0
  39. package/dist/request/types.d.ts.map +1 -0
  40. package/dist/schema/standard-schema.d.ts +46 -0
  41. package/dist/schema/standard-schema.d.ts.map +1 -0
  42. package/dist/stream/connection.d.ts +45 -0
  43. package/dist/stream/connection.d.ts.map +1 -0
  44. package/dist/stream/event-stream.d.ts +15 -0
  45. package/dist/stream/event-stream.d.ts.map +1 -0
  46. package/dist/stream/line-parser.d.ts +13 -0
  47. package/dist/stream/line-parser.d.ts.map +1 -0
  48. package/dist/stream/line-stream.d.ts +10 -0
  49. package/dist/stream/line-stream.d.ts.map +1 -0
  50. package/dist/stream/sse-parser.d.ts +25 -0
  51. package/dist/stream/sse-parser.d.ts.map +1 -0
  52. package/dist/stream/types.d.ts +66 -0
  53. package/dist/stream/types.d.ts.map +1 -0
  54. package/package.json +54 -0
package/README.md ADDED
@@ -0,0 +1,1295 @@
1
+ # @nxgt/httpyz
2
+
3
+ A typed HTTP client over the standard `fetch`. A call's path parameters are
4
+ typed from the path it names, and its replies from the schemas it declares.
5
+ A reply is a union narrowed on its status. Middleware, auth with a shared
6
+ token refresh, retries and timeouts come built in. It has no runtime
7
+ dependencies, so it runs the same in a browser, in Bun and in Node.
8
+
9
+ It needs no spec and no generated code: give it paths and any
10
+ [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType…). For
11
+ a client driven by an OpenAPI spec, bind the generated operations onto it
12
+ with [`@nxgt/openapi-httpyz`](https://www.npmjs.com/package/@nxgt/openapi-httpyz).
13
+ For TanStack Query, [`@nxgt/httpyz-query`](https://www.npmjs.com/package/@nxgt/httpyz-query)
14
+ turns its calls into query options.
15
+
16
+ > **0.x.** The API is still settling.
17
+
18
+ ## Install
19
+
20
+ ```sh
21
+ bun add @nxgt/httpyz
22
+ ```
23
+
24
+ - `typescript` 6: required peer, the version every `@nxgt` package pins.
25
+ - A Standard Schema library: optional, and not a peer. Bring your own if you
26
+ declare replies.
27
+
28
+ ## Setup
29
+
30
+ ```ts
31
+ import { createHttpClient } from '@nxgt/httpyz';
32
+
33
+ export const http = createHttpClient({
34
+ baseUrl: 'https://api.example.com',
35
+ headers: async () => ({ 'x-request-id': crypto.randomUUID() }),
36
+ timeout: 10_000,
37
+ retry: 2,
38
+ });
39
+ ```
40
+
41
+ | Option | Default | |
42
+ | --- | --- | --- |
43
+ | `baseUrl` | none: relative URLs, which only a browser resolves | may carry a path prefix: `https://example.com/api` |
44
+ | `fetch` | `globalThis.fetch`, looked up at each call | anything that takes a `Request` and resolves to a `Response`: `async (request) => app.fetch(request)` for a Hono app, in tests |
45
+ | `headers` | none | sent with every request: an object, or a function run before each one |
46
+ | `init` | none | fetch options for every request: `credentials`, `mode`, `cache`… |
47
+ | `timeout` | none | milliseconds before a call fails with `TimeoutError` |
48
+ | `auth` | none | `{ token, refresh, scheme }`. See [Auth](#auth) |
49
+ | `retry` | never | a number of retries, `RetryOptions`, or `false`. See [Retries](#retries) |
50
+ | `use` | none | middleware around each request. See [Middleware](#middleware) |
51
+
52
+ ## Subpaths
53
+
54
+ | Import | For |
55
+ | --- | --- |
56
+ | `@nxgt/httpyz` | the client, its types and its errors |
57
+ | `@nxgt/httpyz/integration` | for bindings such as `@nxgt/openapi-httpyz` only: the helpers that write and check requests exactly as the client does. An app has no use for it |
58
+
59
+ ## Calls
60
+
61
+ There is a method per HTTP method, `query` included:
62
+ `get`, `put`, `post`, `delete`, `options`, `head`, `patch`, `trace` and
63
+ `query`. `request` takes the method as a value:
64
+
65
+ ```ts
66
+ await http.get('/employees/{id}', { param: { id: 7 } });
67
+ await http.request('get', '/employees/{id}', { param: { id: 7 } });
68
+ ```
69
+
70
+ ### Path parameters
71
+
72
+ The path's `{name}`s are typed from the path itself. `param` is required,
73
+ with exactly those names, when the path has any. Each value is written as
74
+ text and URL-encoded, and a `Date` as its ISO string. One missing at run
75
+ time throws a `TypeError` before anything is sent.
76
+
77
+ ```ts
78
+ http.get('/teams/{team}/members/{id}', { param: { team: 'core', id: 7 } });
79
+ // @ts-expect-error id is missing
80
+ http.get('/teams/{team}/members/{id}', { param: { team: 'core' } });
81
+ ```
82
+
83
+ ### Query
84
+
85
+ ```ts
86
+ http.get('/employees', {
87
+ query: { page: 2, tag: ['a', 'b'], since: new Date(), skip: undefined },
88
+ });
89
+ // ?page=2&tag=a&tag=b&since=2024-05-01T10%3A00%3A00.000Z
90
+ ```
91
+
92
+ A list is sent as a repeated key, and `null` and `undefined` are left out. A
93
+ `URLSearchParams` is sent as it is: pass one for any other style
94
+ (`ids=1,2`).
95
+
96
+ ### Bodies
97
+
98
+ A call sends at most one body. Its kind sets the `Content-Type`:
99
+
100
+ | Key | Takes | Sent as |
101
+ | --- | --- | --- |
102
+ | `json` | any value | `JSON.stringify`, as `application/json` |
103
+ | `form` | an object of fields, a `FormData` or a `URLSearchParams` | fields: URL-encoded, or multipart once a field is a `Blob`; a list as a repeated field, any other object as JSON. A `FormData` is always multipart, a `URLSearchParams` always URL-encoded |
104
+ | `text` | a string | `text/plain` |
105
+ | `body` | `Blob`, `ArrayBuffer`, a typed array or a `ReadableStream` | as it is: a `Blob`'s own type, else `application/octet-stream` |
106
+
107
+ A `Content-Type` in the call's own `headers` wins over the one for its kind,
108
+ for `json`, `text` and `body`:
109
+
110
+ ```ts
111
+ http.patch('/employees/{id}', {
112
+ param: { id },
113
+ json: { name: 'Ada' },
114
+ headers: { 'content-type': 'application/merge-patch+json' },
115
+ });
116
+ ```
117
+
118
+ A `form` always lets fetch write its type, since a multipart one carries its
119
+ boundary. The client's shared `headers` never set a body's type.
120
+
121
+ ### Per-call options
122
+
123
+ A call also takes every fetch option (`signal`, `cache`, `credentials`…),
124
+ and:
125
+
126
+ | Option | |
127
+ | --- | --- |
128
+ | `headers` | over the client's `headers` |
129
+ | `timeout` | this call's, instead of the client's |
130
+ | `retry` | this call's, instead of the client's: `false` never retries |
131
+ | `operationId` | names the call in its errors and to middleware |
132
+ | `latest` | a key: this call aborts the one before it with the same key. See [Cancelling](#cancelling) |
133
+
134
+ ### A `Request` of your own
135
+
136
+ `send` takes a `Request` and returns its `Response` unread. The request goes
137
+ through the client's `retry`, `auth`, `use` and timeout, and the client's
138
+ `headers` are added to it; its own headers win.
139
+
140
+ ```ts
141
+ const response = await http.send(new Request('https://api.example.com/export'), {
142
+ timeout: 60_000,
143
+ });
144
+ ```
145
+
146
+ ## Replies
147
+
148
+ With `responses`, a call declares the replies it expects, by status:
149
+
150
+ ```ts
151
+ import { z } from 'zod';
152
+
153
+ const Employee = z.object({ id: z.int(), name: z.string() });
154
+ const Problem = z.object({ title: z.string() });
155
+
156
+ const reply = await http.get('/employees/{id}', {
157
+ param: { id },
158
+ responses: { 200: Employee, 404: Problem },
159
+ });
160
+ if (reply.status === 404) throw new Error(reply.data.title);
161
+ reply.data.name; // string: status is 200 here
162
+ reply.type; // 'application/json'
163
+ reply.response.headers.get('etag'); // the Response, for its headers: its body is read
164
+ ```
165
+
166
+ Each status takes:
167
+
168
+ | Declared | Means |
169
+ | --- | --- |
170
+ | a schema | a JSON reply, checked by the schema |
171
+ | `null` | no content: `type` and `data` are `undefined` |
172
+ | `{ [mediaType]: schema \| null }` | a reply per media type. `null` reads it without a check: JSON parsed, as `unknown`; text as a `string`; a form as `FormData`; anything else as a `Blob` |
173
+
174
+ ```ts
175
+ const csv = await http.get('/export', {
176
+ responses: { 200: { 'text/csv': z.string() }, 204: null },
177
+ });
178
+ ```
179
+
180
+ A status the call does not declare throws an `UndeclaredStatusError`, whose
181
+ `response` is still unread. Without `responses`, every reply is returned as
182
+ it came, typed `{ status: number; type: string | undefined; data: unknown }`,
183
+ and read by its media type: JSON parsed, text as a string, a form as
184
+ `FormData`, a reply with no `Content-Type` as text, anything else as a
185
+ `Blob`. A reply with no body, or an empty one, has `undefined` as `data`.
186
+
187
+ `unwrap` returns the data of the statuses it names, narrowed to theirs, and
188
+ throws a `ReplyStatusError` for any other reply. `ok` returns the data of
189
+ any 2xx reply, narrowed to the success statuses the call declares:
190
+
191
+ ```ts
192
+ import { ok, unwrap } from '@nxgt/httpyz';
193
+
194
+ const responses = { 200: Employee, 404: Problem };
195
+ const employee = unwrap(await http.get('/employees/{id}', { param: { id }, responses }), 200);
196
+ const same = ok(await http.get('/employees/{id}', { param: { id }, responses }));
197
+ ```
198
+
199
+ A reply's media type is matched to a declared one exactly, then by
200
+ `type/*`, then by `*/*`, then to the first declared type of the same kind.
201
+ So any JSON reply stands for a declared JSON type such as
202
+ `application/problem+json`, and a `text/plain` reply for a declared
203
+ `text/csv`.
204
+
205
+ ## Validating and decoding
206
+
207
+ A declared reply is checked with its schema, and returned as the schema
208
+ outputs it. Two call options change that:
209
+
210
+ | Option | Default | |
211
+ | --- | --- | --- |
212
+ | `validate` | `true` | `false` takes the reply at its word, unchecked |
213
+ | `decode` | `true` | `false` returns what the schema was given, rather than what it outputs |
214
+
215
+ ```ts
216
+ const Item = z.object({
217
+ createdAt: z.iso.datetime().transform((value) => new Date(value)),
218
+ });
219
+ const decoded = await http.get('/items/1', { responses: { 200: Item } });
220
+ decoded.data.createdAt; // Date
221
+ const wire = await http.get('/items/1', { responses: { 200: Item }, decode: false });
222
+ wire.data.createdAt; // string
223
+ ```
224
+
225
+ Only JSON and text replies are checked. A reply that fails its schema, JSON
226
+ that does not parse, or a media type the status does not declare throws a
227
+ `ValidationError`. Its `failure` lists every issue, each with a `target`, a
228
+ `path`, a `code` and a `message`. The client's own issues have the codes
229
+ `invalid_json` and `invalid_content_type`. An issue from a validator without
230
+ codes has the code `custom`.
231
+
232
+ ## Errors
233
+
234
+ | Error | When |
235
+ | --- | --- |
236
+ | `NetworkError` | fetch failed: no connection, a refused one, a CORS refusal; or a stream's connection dropped for good. `cause` is fetch's error |
237
+ | `TimeoutError` | no reply within `timeout`. `timeout` is the limit |
238
+ | `UndeclaredStatusError` | a status the call's `responses` do not declare, or a stream opened with any status but a 2xx or 204. `status`, and `response`, unread |
239
+ | `ValidationError` | a reply its declaration does not describe; or, from a binding, a request refused before it was sent. `failure` has every issue |
240
+ | `ReplyStatusError` | from `unwrap()` or `ok()`: a reply with none of the statuses asked for. `status`, `data` and `response` |
241
+
242
+ The first four extend `ClientError`, whose message names the call,
243
+ `GET /employees/{id}: no reply came back`, or with its `operationId`,
244
+ `getEmployee (GET /employees/{id}): …`. It carries `method`, `path` and
245
+ `operationId`. `ReplyStatusError` extends `Error`.
246
+
247
+ An abort the caller asked for through `signal` is not wrapped: it comes
248
+ through as the `AbortError` its signal gave. `isAbortError(error)` tells it
249
+ from a failure.
250
+
251
+ ## Cancelling
252
+
253
+ A call ends early in three ways, and each rejects with an abort, never a
254
+ `ClientError`:
255
+
256
+ ```ts
257
+ import { isAbortError } from '@nxgt/httpyz';
258
+
259
+ // Its own signal
260
+ await http.get('/items', { signal: controller.signal });
261
+
262
+ // A later call with the same `latest` key: typing ahead keeps one search in flight
263
+ await http.get('/search', { query: { q }, latest: 'search' });
264
+
265
+ // Its group's cancel(): every call of a page, a component, a job
266
+ const page = http.group();
267
+ onLeave(() => page.cancel());
268
+
269
+ async function loadItems() {
270
+ try {
271
+ return await page.get('/items');
272
+ } catch (error) {
273
+ if (isAbortError(error)) return undefined; // cancelled: nothing to report
274
+ throw error;
275
+ }
276
+ }
277
+ ```
278
+
279
+ - **`signal`** aborts with its reason: an `AbortError`, unless you gave it
280
+ another.
281
+ - **`latest`** aborts the previous call with the same key, if it still
282
+ runs, with an `AbortError`. Keys are per client. `send`, `events` and
283
+ `lines` take it too: a stream it replaces ends with an `AbortError`.
284
+ - **`http.group()`** returns the same client, whose calls also end on
285
+ `group.cancel(reason?)`: every call, `send` and stream made through it
286
+ that still runs aborts, with `reason` or an `AbortError`. The group goes
287
+ on: a call made after `cancel()` runs. A group's `group()` is cancelled
288
+ with it, but not the reverse. `group.signal` aborts on the next
289
+ `cancel()`, for work of your own that ends with the group's calls.
290
+
291
+ An abort is never retried, and ends a retry's wait, a stream's reconnection
292
+ and, for that call alone, the wait for an [auth](#auth) refresh.
293
+
294
+ `isAbortError(error)` is true for an `AbortError`, and for the
295
+ `TimeoutError` of an `AbortSignal.timeout()` you passed as `signal`. The
296
+ client's own `TimeoutError`, past `timeout`, is a failure: it is false for
297
+ it.
298
+
299
+ ## Auth
300
+
301
+ ```ts
302
+ createHttpClient({
303
+ baseUrl,
304
+ auth: {
305
+ token: () => session.accessToken,
306
+ refresh: () => session.refresh(),
307
+ },
308
+ });
309
+ ```
310
+
311
+ `token` is read, and awaited, before each request, and sent as
312
+ `Authorization: Bearer <token>`; none sends no header. On a 401, `refresh`
313
+ runs, and the request is sent again once, body included, with the token
314
+ `token` then returns:
315
+
316
+ - Calls refused together share one refresh.
317
+ - A call refused with a token that has since been replaced is sent again
318
+ without another refresh.
319
+ - When `refresh` throws, or `token` then returns nothing or the same token,
320
+ the 401 is the reply, for the app to sign out on.
321
+ - A call aborted while it waits stops waiting, and rejects with its abort;
322
+ the refresh runs on for the others.
323
+
324
+ `scheme` replaces `Bearer`. Without `refresh`, a 401 is simply the reply.
325
+
326
+ ## Retries
327
+
328
+ ```ts
329
+ const http = createHttpClient({ baseUrl, retry: 2 });
330
+ await http.put('/items/{id}', { param: { id }, json: item, retry: false }); // or per call
331
+ ```
332
+
333
+ A retry sends the request again, body included, after a failure that may
334
+ pass:
335
+
336
+ - no reply at all, a `NetworkError`;
337
+ - a 408, 429, 502, 503 or 504.
338
+
339
+ Only a method that may be repeated is retried, which is every method but
340
+ POST and PATCH: a POST that got no reply may still have been carried out.
341
+
342
+ The wait before each retry is random, up to 300 ms doubled at each retry. A
343
+ reply's `Retry-After`, in seconds or as a date, wins over it. A `Retry-After`
344
+ longer than `maxDelay` is not waited for: that reply is the reply. The wait
345
+ ends early when the call times out or is aborted: `timeout` bounds the whole
346
+ call, its retries and their waits included.
347
+
348
+ | `RetryOptions` | Default |
349
+ | --- | --- |
350
+ | `attempts` | 2: tries after the first |
351
+ | `methods` | every method but `post` and `patch` |
352
+ | `statuses` | 408, 429, 502, 503, 504 |
353
+ | `delay(attempt)` | random, up to 300 ms × 2^(attempt − 1) |
354
+ | `maxDelay` | 10 000 |
355
+
356
+ Retries are off by default. In a browser, TanStack Query already retries.
357
+ Turn them on for server-to-server calls.
358
+
359
+ ## Middleware
360
+
361
+ A middleware runs around each request. It can change the request or the
362
+ response, or answer on its own, in which case `fetch` is never called:
363
+
364
+ ```ts
365
+ import { createHttpClient, type Middleware } from '@nxgt/httpyz';
366
+
367
+ const timing: Middleware = async (request, next, call) => {
368
+ const start = performance.now();
369
+ try {
370
+ return await next(request);
371
+ } finally {
372
+ metrics.record(call.operationId ?? call.path, performance.now() - start);
373
+ }
374
+ };
375
+ createHttpClient({ baseUrl, use: [timing] });
376
+ ```
377
+
378
+ `call` is the call's `{ method, path, operationId }`, with the path as the
379
+ caller wrote it: `/employees/{id}`.
380
+
381
+ The layers nest as retry → auth → `use` → fetch. The first in `use` is the
382
+ outermost, and all of them run inside `retry` and `auth`, so they see each
383
+ try, with its token. A middleware's own error comes through as it is, and is
384
+ not retried.
385
+
386
+ Headers that only need a value, such as a `traceparent` from the current
387
+ span, need no middleware: `headers` may be a function.
388
+
389
+ ### Caching
390
+
391
+ `cache()` is a middleware that keeps `ok` replies in memory. A request made
392
+ again while its reply is fresh is answered from memory, and `fetch` is never
393
+ called:
394
+
395
+ ```ts
396
+ import { cache, createHttpClient } from '@nxgt/httpyz';
397
+
398
+ const replies = cache({ ttl: 30_000 });
399
+ createHttpClient({ baseUrl, auth, use: [replies] });
400
+ ```
401
+
402
+ | Option | Default | |
403
+ | --- | --- | --- |
404
+ | `ttl` | 5 minutes | how long a reply stays fresh, in milliseconds |
405
+ | `maxEntries` | 500 | the least recently used reply goes first past it |
406
+ | `cacheable` | the reads: `GET`, `HEAD` and `QUERY` | `(request, call) => boolean`, which requests are cached |
407
+ | `vary` | `['authorization']` | the request headers that tell two replies apart |
408
+
409
+ - **The key** is the method, the URL, the `vary` headers and the body. A
410
+ `QUERY` is cached by what it searches for, and, since the middleware runs
411
+ inside `auth`, no caller is answered with another's reply.
412
+ - **One cache is one store.** Share the middleware between clients to share
413
+ the store, as a server that makes a client per request does.
414
+ - **`replies.clear()` empties it,** after a write, say.
415
+ - It is in memory, per process: two instances of a service do not share it.
416
+
417
+ ## Streams
418
+
419
+ ### Server-sent events
420
+
421
+ `events` reads a `text/event-stream` with `for await`. It connects when the
422
+ loop starts, and each event is narrowed on its `event`:
423
+
424
+ ```ts
425
+ const feed = http.events('/items/{id}/events', {
426
+ param: { id },
427
+ events: { updated: Item, removed: z.object({ id: z.int() }), ping: null },
428
+ onUnknownEvent: (event) => console.warn('unknown event', event.event),
429
+ });
430
+ for await (const event of feed) {
431
+ if (event.event === 'updated') render(event.data); // Item
432
+ if (event.event === 'removed') drop(event.data.id);
433
+ }
434
+ ```
435
+
436
+ | Declared | The event's `data` |
437
+ | --- | --- |
438
+ | a schema | parsed as JSON, checked, and decoded, as a reply is |
439
+ | `null` | the text as it came |
440
+ | no `events` at all | every event yielded as `{ event, data, id }`, its data as text |
441
+
442
+ An event `events` does not declare is not yielded: `onUnknownEvent` gets it.
443
+ An event with no `event:` field is named `message`. Each event carries the
444
+ stream's last `id`, and `feed.lastEventId` holds it.
445
+
446
+ It reconnects as `EventSource` does: when the connection drops or the
447
+ stream ends, it waits, then connects again with `Last-Event-ID`. The wait is
448
+ 3 seconds until the stream sends a `retry:`.
449
+
450
+ - It never reconnects after an error status, which throws an
451
+ `UndeclaredStatusError`, nor after a 204, which ends the stream. A server
452
+ ends a stream for good with a 204.
453
+ - It does not reconnect a POST or a PATCH, unless `reconnect` says so.
454
+ - `reconnect: false` never reconnects. `{ attempts, delay }` gives up after
455
+ `attempts` reconnections in a row without an event, and waits `delay`
456
+ until a `retry:`.
457
+ - `lastEventId` resumes a stream from an ID of your own.
458
+
459
+ Each connection is a request of its own: its `headers` run again, and it
460
+ goes through `retry`, `auth` and `use`, so a refreshed token is sent.
461
+
462
+ ### JSON Lines
463
+
464
+ `lines` reads a stream of JSON texts a record at a time: JSON Lines, NDJSON
465
+ or a JSON text sequence. `item` checks each one:
466
+
467
+ ```ts
468
+ for await (const row of http.lines('/export', { item: Row })) save(row);
469
+ ```
470
+
471
+ A blank line is skipped, and the last record needs no line end. `lines`
472
+ never reconnects. A 204 ends it, and any other status but a 2xx throws an
473
+ `UndeclaredStatusError`. It asks for `application/jsonl, application/x-ndjson`
474
+ and reads `application/jsonl`, `application/x-ndjson`, `application/ndjson`,
475
+ `application/jsonlines`, `application/x-jsonlines` and `application/json-seq`.
476
+
477
+ ### Ending a stream
478
+
479
+ Both end on `close()`, on `break`, or on the call's `signal`, and the
480
+ connection closes with them. `close()` and `break` end the loop quietly, and
481
+ the `signal` throws its `AbortError`. `timeout` bounds the wait for the
482
+ headers of each connection, not the stream: a stream has no end to wait for.
483
+
484
+ `validate: false` and `decode: false` apply to each item as to a reply.
485
+ JSON that does not parse, an item its schema refuses, and a reply of
486
+ another media type throw a `ValidationError`. A stream is read once: a
487
+ second `for await` throws a `TypeError`.
488
+
489
+ ## API
490
+
491
+ ### `@nxgt/httpyz`
492
+
493
+ #### Functions
494
+
495
+ ##### `createHttpClient`
496
+
497
+ ```ts
498
+ function createHttpClient(options?: HttpClientOptions): HttpClient;
499
+ ```
500
+
501
+ Makes a client. Every option is optional, and it throws nothing: the errors
502
+ come from its calls. See [Setup](#setup).
503
+
504
+ | Option | Type | Default | Description |
505
+ | --- | --- | --- | --- |
506
+ | `baseUrl` | `string \| URL` | none | where the API is served; a trailing `/` is dropped |
507
+ | `fetch` | `(request: Request) => Promise<Response>` | `globalThis.fetch`, looked up at each call | sends each request |
508
+ | `headers` | `HeadersInit \| (() => HeadersInit \| Promise<HeadersInit>)` | none | sent with every request; a function runs before each one |
509
+ | `init` | `Omit<RequestInit, 'method' \| 'body' \| 'headers' \| 'signal'>` | none | fetch options for every request |
510
+ | `timeout` | `number` | none | milliseconds before a call fails with a `TimeoutError` |
511
+ | `auth` | `AuthOptions` | none | a token on every request, refreshed once on a 401 |
512
+ | `retry` | `number \| RetryOptions \| false` | never | sends a request again after a failure that may pass |
513
+ | `use` | `readonly Middleware[]` | none | around every request, the first outermost |
514
+
515
+ Returns an [`HttpClient`](#httpclient).
516
+
517
+ ##### `isAbortError`
518
+
519
+ ```ts
520
+ function isAbortError(error: unknown): boolean;
521
+ ```
522
+
523
+ Whether `error` ended a call because it was aborted rather than failed: a
524
+ `DOMException` named `AbortError` or `TimeoutError`, or any `Error` named
525
+ `AbortError`. It is false for the client's own `TimeoutError`, and is a
526
+ plain `boolean`, not a type guard. See [Cancelling](#cancelling).
527
+
528
+ ##### `unwrap`
529
+
530
+ ```ts
531
+ function unwrap<
532
+ R extends { readonly status: number; readonly data: unknown },
533
+ S extends R['status'],
534
+ >(reply: R, ...statuses: [S, ...S[]]): Extract<R, { status: S }>['data'];
535
+ ```
536
+
537
+ Returns `reply.data` when its status is one of `statuses`, at least one of
538
+ them, narrowed to theirs. Throws a `ReplyStatusError` for any other status.
539
+ See [Replies](#replies).
540
+
541
+ ##### `ok`
542
+
543
+ ```ts
544
+ function ok<R extends { readonly status: number; readonly data: unknown }>(
545
+ reply: R,
546
+ ): Success<R>['data'];
547
+ ```
548
+
549
+ Returns `reply.data` for a status from 200 to 299, narrowed to the 2xx
550
+ members of the reply union. Throws a `ReplyStatusError` for any other. On a
551
+ reply that declares nothing, the data stays `unknown`. See
552
+ [Replies](#replies).
553
+
554
+ ##### `cache`
555
+
556
+ ```ts
557
+ function cache(options?: CacheOptions): Cache;
558
+ ```
559
+
560
+ A middleware, for `use`, that keeps replies whose `response.ok` is true in
561
+ memory, and answers a request made again from a clone of its reply while it
562
+ is fresh. See [Caching](#caching).
563
+
564
+ | Option | Type | Default | Description |
565
+ | --- | --- | --- | --- |
566
+ | `ttl` | `number` | `300_000` (5 minutes) | milliseconds a reply stays fresh |
567
+ | `maxEntries` | `number` | `500` | the most replies kept; the least recently used goes first |
568
+ | `cacheable` | `(request: Request, call: CallContext) => boolean` | `GET`, `HEAD` and `QUERY` | which requests are cached |
569
+ | `vary` | `readonly string[]` | `['authorization']` | the request headers that tell two replies apart |
570
+
571
+ Returns a [`Cache`](#cache-1): the middleware, with `clear()`.
572
+
573
+ #### Classes
574
+
575
+ ##### `ClientError`
576
+
577
+ ```ts
578
+ class ClientError extends Error {
579
+ constructor(context: CallContext, message: string, options?: ErrorOptions);
580
+ }
581
+ ```
582
+
583
+ The base of every error a call throws, but an abort. Its message is
584
+ `message` after the call: `GET /employees/{id}: …`, or
585
+ `getEmployee (GET /employees/{id}): …` with an `operationId`. See
586
+ [Errors](#errors).
587
+
588
+ | Member | Type | Description |
589
+ | --- | --- | --- |
590
+ | `name` | `string` | `'ClientError'`, or the subclass's name |
591
+ | `method` | `string` | the call's method, lower case: `get` |
592
+ | `path` | `string` | as the caller wrote it, `/employees/{id}`; for `send`, the URL's pathname |
593
+ | `operationId` | `string \| undefined` | the call's name, when it was given one |
594
+ | `message` | `string` | the call, then what went wrong |
595
+ | `cause` | `unknown` | the error underneath, when there is one |
596
+
597
+ ##### `NetworkError`
598
+
599
+ ```ts
600
+ class NetworkError extends ClientError {
601
+ constructor(context: CallContext, options?: ErrorOptions);
602
+ }
603
+ ```
604
+
605
+ fetch threw, or a stream's connection dropped and was not reconnected. Its
606
+ message ends `no reply came back`, and `cause` is fetch's error. It is the
607
+ one error `retry` retries.
608
+
609
+ ##### `TimeoutError`
610
+
611
+ ```ts
612
+ class TimeoutError extends ClientError {
613
+ constructor(context: CallContext, timeout: number, options?: ErrorOptions);
614
+ readonly timeout: number;
615
+ }
616
+ ```
617
+
618
+ No reply within the call's `timeout`, retries included; for a stream, no
619
+ headers within it on one connection. `timeout` is the limit in milliseconds,
620
+ and the message ends `no reply within 10000 ms`. `isAbortError` is false for
621
+ it.
622
+
623
+ ##### `UndeclaredStatusError`
624
+
625
+ ```ts
626
+ class UndeclaredStatusError extends ClientError {
627
+ constructor(context: CallContext, response: Response);
628
+ readonly status: number;
629
+ readonly response: Response;
630
+ }
631
+ ```
632
+
633
+ A status the call's `responses` do not declare, or a stream opened with any
634
+ status but a 2xx or 204. `response` is unread, its body still there to read.
635
+
636
+ ##### `ValidationError`
637
+
638
+ ```ts
639
+ class ValidationError extends ClientError {
640
+ constructor(context: CallContext, failure: ValidationFailure);
641
+ readonly failure: ValidationFailure;
642
+ }
643
+ ```
644
+
645
+ A reply or stream item its declaration does not describe, or, from a
646
+ binding, a request refused before it was sent. Its message joins the issues'
647
+ messages with `; `. See [Validating and decoding](#validating-and-decoding).
648
+
649
+ ##### `ReplyStatusError`
650
+
651
+ ```ts
652
+ class ReplyStatusError extends Error {
653
+ constructor(
654
+ reply: { status: number; data: unknown; response?: Response },
655
+ expected: readonly number[],
656
+ );
657
+ }
658
+ ```
659
+
660
+ From `unwrap()` or `ok()`: a reply with none of the statuses asked for. Its
661
+ message is `Expected a 200 or 204 reply, got 404`, or `Expected a 2xx reply,
662
+ got 404` when `expected` is empty. It extends `Error`, not `ClientError`.
663
+
664
+ | Member | Type | Description |
665
+ | --- | --- | --- |
666
+ | `name` | `string` | `'ReplyStatusError'` |
667
+ | `status` | `number` | the reply's status |
668
+ | `data` | `unknown` | the reply's data, already read |
669
+ | `response` | `Response \| undefined` | the reply's `Response`, when it had one |
670
+
671
+ #### Types
672
+
673
+ ##### `HttpClient`
674
+
675
+ What `createHttpClient` returns.
676
+
677
+ | Member | Signature | Description |
678
+ | --- | --- | --- |
679
+ | `get`, `put`, `post`, `delete`, `options`, `head`, `patch`, `trace`, `query` | `Call` | a call with that method. See [Calls](#calls) |
680
+ | `request` | `(method: Method, path: Path, ...args: RequestArgs<Path, R, Decoded>) => Promise<HttpReply<R, Decoded>>` | a call with the method as a value |
681
+ | `send` | `(request: Request, options?: SendOptions) => Promise<Response>` | a `Request` of your own, its `Response` unread. See [A `Request` of your own](#a-request-of-your-own) |
682
+ | `events` | `(path: Path, ...args: EventsArgs<Path, E, Decoded>) => EventStream<StreamEvent<E, Decoded>>` | server-sent events. See [Server-sent events](#server-sent-events) |
683
+ | `lines` | `(path: Path, ...args: LinesArgs<Path, I, Decoded>) => Stream<StreamItem<I, Decoded>>` | JSON Lines. See [JSON Lines](#json-lines) |
684
+ | `group` | `() => HttpGroup` | the same client, for calls that end together. See [Cancelling](#cancelling) |
685
+
686
+ ##### `HttpGroup`
687
+
688
+ ```ts
689
+ type HttpGroup = HttpClient & {
690
+ cancel(reason?: unknown): void;
691
+ readonly signal: AbortSignal;
692
+ };
693
+ ```
694
+
695
+ What `http.group()` returns. `cancel()` aborts every call and stream of the
696
+ group still running; `signal` aborts on the next `cancel()`.
697
+
698
+ ##### `HttpClientOptions`
699
+
700
+ The options of `createHttpClient`, in its [table](#createhttpclient).
701
+
702
+ ##### `Method`
703
+
704
+ ```ts
705
+ type Method = 'get' | 'put' | 'post' | 'delete' | 'options' | 'head' | 'patch' | 'trace' | 'query';
706
+ ```
707
+
708
+ HTTP's methods, as OpenAPI lists them, and `query`. It names the client's
709
+ call methods and `RetryOptions.methods`.
710
+
711
+ ##### `Call`
712
+
713
+ ```ts
714
+ type Call = <Path extends string, R extends Responses | undefined = undefined, Decoded extends boolean = true>(
715
+ path: Path,
716
+ ...args: RequestArgs<Path, R, Decoded>
717
+ ) => Promise<HttpReply<R, Decoded>>;
718
+ ```
719
+
720
+ The type of `http.get` and the other method calls.
721
+
722
+ ##### `CallOptions`
723
+
724
+ What any call may add, on top of fetch's own options:
725
+ `Omit<RequestInit, 'method' | 'body' | 'headers'>`, `signal` included.
726
+
727
+ | Field | Type | Description |
728
+ | --- | --- | --- |
729
+ | `headers` | `HeadersInit` | over the client's `headers`; a `Content-Type` here wins over the body's own |
730
+ | `timeout` | `number` | this call's, instead of the client's |
731
+ | `retry` | `number \| RetryOptions \| false` | this call's, instead of the client's; `false` never retries |
732
+ | `operationId` | `string` | names the call in its errors and to middleware |
733
+ | `latest` | `string` | aborts the running call with the same key |
734
+
735
+ ##### `ReplyOptions`
736
+
737
+ ```ts
738
+ interface ReplyOptions<R extends Responses | undefined, Decoded extends boolean> {
739
+ responses?: R;
740
+ validate?: boolean; // default: true
741
+ decode?: Decoded; // default: true
742
+ }
743
+ ```
744
+
745
+ The reply options of a call. See [Validating and decoding](#validating-and-decoding).
746
+
747
+ ##### `RequestOptions`
748
+
749
+ ```ts
750
+ type RequestOptions<Path extends string, R extends Responses | undefined = undefined, Decoded extends boolean = true> =
751
+ RequestInput<Path> & CallOptions & ReplyOptions<R, Decoded>;
752
+ ```
753
+
754
+ Everything a call to `Path` takes, as its second argument.
755
+
756
+ ##### `SendOptions`
757
+
758
+ | Field | Type | Description |
759
+ | --- | --- | --- |
760
+ | `timeout` | `number` | this request's, instead of the client's |
761
+ | `retry` | `number \| RetryOptions \| false` | this request's, instead of the client's |
762
+ | `operationId` | `string` | names the request in its errors and to middleware |
763
+ | `latest` | `string` | aborts the running request with the same key |
764
+
765
+ The options of `send`. Its signal is the `Request`'s own.
766
+
767
+ ##### `ArgsFor`
768
+
769
+ Options for a call to `Path`: optional when the path has no `{name}`,
770
+ required when it does.
771
+
772
+ ```ts
773
+ type A = ArgsFor<'/items/{id}', Options>; // [options: Options]
774
+ type B = ArgsFor<'/items', Options>; // [options?: Options]
775
+ ```
776
+
777
+ ##### `RequestArgs`
778
+
779
+ ```ts
780
+ type RequestArgs<Path, R, Decoded> = ArgsFor<Path, RequestOptions<Path, R, Decoded>>;
781
+ ```
782
+
783
+ The rest arguments of a call after its path.
784
+
785
+ ##### `EventsArgs`
786
+
787
+ ```ts
788
+ type EventsArgs<Path, E, Decoded> = ArgsFor<Path, RequestInput<Path> & EventsOptions<E, Decoded>>;
789
+ ```
790
+
791
+ The rest arguments of `http.events()` after its path.
792
+
793
+ ##### `LinesArgs`
794
+
795
+ ```ts
796
+ type LinesArgs<Path, I, Decoded> = ArgsFor<Path, RequestInput<Path> & LinesOptions<I, Decoded>>;
797
+ ```
798
+
799
+ The rest arguments of `http.lines()` after its path.
800
+
801
+ ##### `RequestInput`
802
+
803
+ ```ts
804
+ type RequestInput<Path extends string> = PathInput<Path> & BodyInput & { readonly query?: QueryInput };
805
+ ```
806
+
807
+ What a call to `Path` sends: `param`, `query` and at most one body.
808
+
809
+ ##### `PathInput`
810
+
811
+ `param`: required, with exactly the path's names, when it has any; else
812
+ absent.
813
+
814
+ ```ts
815
+ type P = PathInput<'/items/{id}'>; // { readonly param: { readonly id: ParamValue } }
816
+ ```
817
+
818
+ ##### `PathParamNames`
819
+
820
+ The `{name}`s of a path, as a union.
821
+
822
+ ```ts
823
+ type N = PathParamNames<'/items/{id}/notes/{noteId}'>; // 'id' | 'noteId'
824
+ ```
825
+
826
+ ##### `ParamValue`
827
+
828
+ ```ts
829
+ type ParamValue = string | number | boolean | bigint | Date;
830
+ ```
831
+
832
+ A path parameter or query value, written as text, a `Date` as its ISO
833
+ string.
834
+
835
+ ##### `QueryValue`
836
+
837
+ ```ts
838
+ type QueryValue = ParamValue | readonly ParamValue[] | null | undefined;
839
+ ```
840
+
841
+ One query value: a list is a repeated key, `null` and `undefined` are left
842
+ out.
843
+
844
+ ##### `QueryInput`
845
+
846
+ ```ts
847
+ type QueryInput = { readonly [name: string]: QueryValue } | URLSearchParams;
848
+ ```
849
+
850
+ A call's `query`. See [Query](#query).
851
+
852
+ ##### `BodyInput`
853
+
854
+ ```ts
855
+ type BodyInput =
856
+ | { json: unknown }
857
+ | { form: FormFields | FormData | URLSearchParams }
858
+ | { text: string }
859
+ | { body: Blob | ArrayBuffer | ArrayBufferView | ReadableStream }
860
+ | {}; // simplified: the other three keys are `?: undefined` in each member
861
+ ```
862
+
863
+ At most one body; two is a type error. See [Bodies](#bodies).
864
+
865
+ ##### `FormFields`
866
+
867
+ ```ts
868
+ type FormFields = { readonly [name: string]: unknown };
869
+ ```
870
+
871
+ A `form` as an object: each value as text, a list as a repeated field, a
872
+ `Blob` as a file, any other object as JSON.
873
+
874
+ ##### `Responses`
875
+
876
+ ```ts
877
+ type Responses = { readonly [status: number]: Declared };
878
+ ```
879
+
880
+ A call's `responses`: `{ 200: Employee, 404: Problem, 204: null }`.
881
+
882
+ ##### `Declared`
883
+
884
+ ```ts
885
+ type Declared = StandardSchemaV1 | null | { readonly [mediaType: string]: StandardSchemaV1 | null };
886
+ ```
887
+
888
+ The reply of one status: a schema for JSON, `null` for no content, or a
889
+ schema per media type. See [Replies](#replies).
890
+
891
+ ##### `HttpReply`
892
+
893
+ ```ts
894
+ type HttpReply<R extends Responses | undefined, Decoded extends boolean = true> =
895
+ WithResponse<R extends Responses ? ReplyOf<R, Decoded> : AnyReply>;
896
+ ```
897
+
898
+ What a call resolves to.
899
+
900
+ ##### `ReplyOf`
901
+
902
+ The declared replies as a union narrowed on `status`.
903
+
904
+ ```ts
905
+ type R = ReplyOf<{ 200: typeof Employee; 204: null }>;
906
+ // { status: 200; type: string; data: Employee } | { status: 204; type: undefined; data: undefined }
907
+ ```
908
+
909
+ With a media type map, `type` is that media type, and an unchecked `data` is
910
+ `string` for `text/*`, `FormData` for a form, `unknown` for JSON and `Blob`
911
+ for the rest.
912
+
913
+ ##### `AnyReply`
914
+
915
+ ```ts
916
+ interface AnyReply {
917
+ readonly status: number;
918
+ readonly type: string | undefined;
919
+ readonly data: unknown;
920
+ }
921
+ ```
922
+
923
+ The reply of a call without `responses`.
924
+
925
+ ##### `WithResponse`
926
+
927
+ A reply with the `Response` it was read from, for its headers.
928
+
929
+ ```ts
930
+ type W = WithResponse<AnyReply>; // AnyReply & { readonly response: Response }
931
+ ```
932
+
933
+ ##### `SchemaData`
934
+
935
+ A schema's output, or its input with `decode: false`; `never` for anything
936
+ but a schema.
937
+
938
+ ```ts
939
+ type D = SchemaData<typeof Item, false>; // { createdAt: string }
940
+ ```
941
+
942
+ ##### `Success`
943
+
944
+ The members of a reply union with a 2xx status; all of it when its status
945
+ is any `number`. It types `ok()`.
946
+
947
+ ```ts
948
+ type S = Success<{ status: 200; data: Employee } | { status: 404; data: Problem }>; // { status: 200; data: Employee }
949
+ ```
950
+
951
+ ##### `EventSchemas`
952
+
953
+ ```ts
954
+ type EventSchemas = { readonly [event: string]: StandardSchemaV1 | null };
955
+ ```
956
+
957
+ An events stream's `events`: a schema for JSON data, `null` for text.
958
+
959
+ ##### `EventsOptions`
960
+
961
+ The options of `http.events()`, with `CallOptions` and the request's
962
+ `param`, `query` and body.
963
+
964
+ | Field | Type | Description |
965
+ | --- | --- | --- |
966
+ | `events` | `E extends EventSchemas` | the events, by name; without it, every event is yielded as it came |
967
+ | `onUnknownEvent` | `(event: ServerEvent) => void` | gets an event `events` does not declare |
968
+ | `reconnect` | `boolean \| ReconnectOptions` | default: on, but for POST and PATCH |
969
+ | `lastEventId` | `string` | sent as `Last-Event-ID` on the first connection |
970
+ | `method` | `Method` | default: `get` |
971
+ | `validate` | `boolean` | checks each event's data; default: `true` |
972
+ | `decode` | `boolean` | yields what each schema outputs; default: `true` |
973
+
974
+ ##### `ReconnectOptions`
975
+
976
+ | Field | Type | Description |
977
+ | --- | --- | --- |
978
+ | `attempts` | `number` | reconnections in a row without an event before the stream gives up; default: no limit |
979
+ | `delay` | `number` | milliseconds before reconnecting, until the stream sends a `retry:`; default: `3000` |
980
+
981
+ The object form of `EventsOptions.reconnect`.
982
+
983
+ ##### `LinesOptions`
984
+
985
+ The options of `http.lines()`, with `CallOptions` and the request's
986
+ `param`, `query` and body.
987
+
988
+ | Field | Type | Description |
989
+ | --- | --- | --- |
990
+ | `item` | `I extends StandardSchemaV1` | checks each line; without it, each is yielded parsed, as `unknown` |
991
+ | `method` | `Method` | default: `get` |
992
+ | `validate` | `boolean` | default: `true` |
993
+ | `decode` | `boolean` | default: `true` |
994
+
995
+ ##### `Stream`
996
+
997
+ ```ts
998
+ interface Stream<T> extends AsyncIterable<T> {
999
+ close(): void;
1000
+ }
1001
+ ```
1002
+
1003
+ What `http.lines()` returns: read once, with `for await`. `close()` ends
1004
+ the loop and the connection.
1005
+
1006
+ ##### `EventStream`
1007
+
1008
+ ```ts
1009
+ interface EventStream<T> extends Stream<T> {
1010
+ readonly lastEventId: string | undefined;
1011
+ }
1012
+ ```
1013
+
1014
+ What `http.events()` returns; `lastEventId` is what a reconnection sends.
1015
+
1016
+ ##### `StreamEvent`
1017
+
1018
+ An event of `http.events()`: a declared one narrowed on `event`, or a
1019
+ `ServerEvent` when none is declared.
1020
+
1021
+ ```ts
1022
+ type E = StreamEvent<{ updated: typeof Item; ping: null }>;
1023
+ // { event: 'updated'; data: Item; id: string | undefined } | { event: 'ping'; data: string; id: string | undefined }
1024
+ ```
1025
+
1026
+ ##### `StreamItem`
1027
+
1028
+ A line of `http.lines()`, as its schema checks it; `unknown` without one.
1029
+
1030
+ ```ts
1031
+ type I = StreamItem<typeof Row>; // z.output<typeof Row>
1032
+ ```
1033
+
1034
+ ##### `ServerEvent`
1035
+
1036
+ | Field | Type | Description |
1037
+ | --- | --- | --- |
1038
+ | `event` | `string` | its `event:` field, or `message` |
1039
+ | `data` | `string` | its `data:` lines, joined with a line feed |
1040
+ | `id` | `string \| undefined` | the last `id:` the stream set |
1041
+
1042
+ An event as the stream sent it: what `onUnknownEvent` gets, and what
1043
+ `events` yields without `events` declared.
1044
+
1045
+ ##### `Middleware`
1046
+
1047
+ ```ts
1048
+ type Middleware = (request: Request, next: Next, call: CallContext) => Promise<Response>;
1049
+ ```
1050
+
1051
+ An entry of `use`. See [Middleware](#middleware).
1052
+
1053
+ ##### `Next`
1054
+
1055
+ ```ts
1056
+ type Next = (request: Request) => Promise<Response>;
1057
+ ```
1058
+
1059
+ The rest of the chain, as a middleware calls it.
1060
+
1061
+ ##### `AuthOptions`
1062
+
1063
+ | Field | Type | Description |
1064
+ | --- | --- | --- |
1065
+ | `token` | `() => string \| null \| undefined \| Promise<string \| null \| undefined>` | the current token, read before each request; none sends no header |
1066
+ | `refresh` | `() => unknown` | gets a new token after a 401, for `token` to return; optional |
1067
+ | `scheme` | `string` | default: `Bearer` |
1068
+
1069
+ The client's `auth`. See [Auth](#auth).
1070
+
1071
+ ##### `RetryOptions`
1072
+
1073
+ | Field | Type | Description |
1074
+ | --- | --- | --- |
1075
+ | `attempts` | `number` | tries after the first; default: `2`; `0` never retries |
1076
+ | `methods` | `readonly Method[]` | default: every method but `post` and `patch` |
1077
+ | `statuses` | `readonly number[]` | default: 408, 429, 502, 503, 504 |
1078
+ | `delay` | `(attempt: number) => number` | milliseconds before retry `attempt`, from 1; default: random, up to 300 × 2^(attempt − 1) |
1079
+ | `maxDelay` | `number` | the longest wait; a longer `Retry-After` is the reply; default: `10000` |
1080
+
1081
+ The object form of `retry`. See [Retries](#retries).
1082
+
1083
+ ##### `Cache`
1084
+
1085
+ ```ts
1086
+ type Cache = Middleware & { clear(): void };
1087
+ ```
1088
+
1089
+ What `cache()` returns; `clear()` empties its store.
1090
+
1091
+ ##### `CacheOptions`
1092
+
1093
+ The options of `cache()`, in its [table](#cache).
1094
+
1095
+ ##### `CallContext`
1096
+
1097
+ ```ts
1098
+ interface CallContext {
1099
+ readonly method: string;
1100
+ readonly path: string; // as the caller wrote it: /employees/{id}
1101
+ readonly operationId?: string;
1102
+ }
1103
+ ```
1104
+
1105
+ Which call it is: a middleware's third argument, and `cacheable`'s second.
1106
+
1107
+ ##### `ValidationFailure`
1108
+
1109
+ ```ts
1110
+ interface ValidationFailure {
1111
+ kind: 'request' | 'response';
1112
+ operationId?: string;
1113
+ method: string;
1114
+ path: string;
1115
+ status?: number;
1116
+ issues: ValidationIssue[];
1117
+ }
1118
+ ```
1119
+
1120
+ A `ValidationError`'s `failure`: one failure, every issue in it.
1121
+
1122
+ ##### `ValidationIssue`
1123
+
1124
+ ```ts
1125
+ interface ValidationIssue {
1126
+ target: 'param' | 'query' | 'header' | 'json' | 'form' | 'body' | 'response';
1127
+ path: (string | number)[]; // [] for the whole target
1128
+ code: string;
1129
+ message: string;
1130
+ }
1131
+ ```
1132
+
1133
+ One issue of a `ValidationFailure`; the client's own are on `response`.
1134
+
1135
+ ##### `StandardSchemaV1`
1136
+
1137
+ ```ts
1138
+ interface StandardSchemaV1<Input = unknown, Output = Input> {
1139
+ readonly '~standard': {
1140
+ readonly version: 1;
1141
+ readonly vendor: string;
1142
+ readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
1143
+ readonly types?: { readonly input: Input; readonly output: Output } | undefined;
1144
+ };
1145
+ }
1146
+ ```
1147
+
1148
+ The part of [Standard Schema](https://standardschema.dev) the client calls:
1149
+ any Zod 4, Valibot or ArkType schema fits it.
1150
+
1151
+ ##### `StandardResult`
1152
+
1153
+ ```ts
1154
+ type StandardResult<Output> =
1155
+ | { readonly value: Output; readonly issues?: undefined }
1156
+ | { readonly issues: readonly StandardIssue[] };
1157
+ ```
1158
+
1159
+ What a schema's `validate` returns.
1160
+
1161
+ ##### `StandardIssue`
1162
+
1163
+ ```ts
1164
+ interface StandardIssue {
1165
+ readonly message: string;
1166
+ readonly path?: readonly (PropertyKey | { readonly key: PropertyKey })[] | undefined;
1167
+ }
1168
+ ```
1169
+
1170
+ One issue of a `StandardResult`.
1171
+
1172
+ ##### `InferInput`
1173
+
1174
+ What a schema accepts.
1175
+
1176
+ ```ts
1177
+ type In = InferInput<typeof Item>; // { createdAt: string }
1178
+ ```
1179
+
1180
+ ##### `InferOutput`
1181
+
1182
+ What a schema gives back.
1183
+
1184
+ ```ts
1185
+ type Out = InferOutput<typeof Item>; // { createdAt: Date }
1186
+ ```
1187
+
1188
+ ### `@nxgt/httpyz/integration`
1189
+
1190
+ For bindings only: the pieces a binding reuses so that the requests it
1191
+ writes and checks match the client's own.
1192
+
1193
+ #### Constants
1194
+
1195
+ ##### `METHODS`
1196
+
1197
+ ```ts
1198
+ const METHODS: readonly Method[];
1199
+ ```
1200
+
1201
+ The nine methods, in order: `get`, `put`, `post`, `delete`, `options`,
1202
+ `head`, `patch`, `trace`, `query`.
1203
+
1204
+ #### Functions
1205
+
1206
+ ##### `text`
1207
+
1208
+ ```ts
1209
+ function text(value: unknown): string;
1210
+ ```
1211
+
1212
+ A value as the client writes it: a `Date` as its ISO string, anything else
1213
+ through `String()`.
1214
+
1215
+ ##### `absent`
1216
+
1217
+ ```ts
1218
+ function absent(value: unknown): value is undefined | null;
1219
+ ```
1220
+
1221
+ Whether the client leaves `value` out: `null` or `undefined`.
1222
+
1223
+ ##### `fields`
1224
+
1225
+ ```ts
1226
+ function fields(form: FormFields): Generator<[string, string | Blob]>;
1227
+ ```
1228
+
1229
+ A form's fields as the client sends them: every item of a list, `null` and
1230
+ `undefined` left out, a `Blob` as it is, any other object but a `Date` as
1231
+ JSON, the rest through `text`.
1232
+
1233
+ ##### `toFormData`
1234
+
1235
+ ```ts
1236
+ function toFormData(form: FormFields): FormData;
1237
+ ```
1238
+
1239
+ The `fields` of `form`, in a `FormData`.
1240
+
1241
+ ##### `toSearchParams`
1242
+
1243
+ ```ts
1244
+ function toSearchParams(form: FormFields): URLSearchParams;
1245
+ ```
1246
+
1247
+ The `fields` of `form`, URL-encoded. Throws a `TypeError` when a field is a
1248
+ `Blob`.
1249
+
1250
+ ##### `check`
1251
+
1252
+ ```ts
1253
+ function check(schema: StandardSchemaV1, value: unknown, target: ValidationIssue['target']): Promise<Checked>;
1254
+ ```
1255
+
1256
+ Runs `value` through `schema`. Returns its output, or its issues on
1257
+ `target`, their paths flattened to keys, and their `code` kept when it is a
1258
+ string, else `custom`. It does not throw for a refused value.
1259
+
1260
+ #### Types
1261
+
1262
+ ##### `Checked`
1263
+
1264
+ ```ts
1265
+ type Checked =
1266
+ | { readonly ok: true; readonly value: unknown }
1267
+ | { readonly ok: false; readonly issues: ValidationIssue[] };
1268
+ ```
1269
+
1270
+ What `check` resolves to.
1271
+
1272
+ ## Traps
1273
+
1274
+ - **Outside a browser, set `baseUrl`.** A call builds a `Request`, which needs
1275
+ an absolute URL: without a `baseUrl`, Bun and Node throw a `TypeError`.
1276
+ - **An abort is not a timeout.** Past `timeout`, a call throws
1277
+ `TimeoutError`. An abort through the call's own `signal`, `latest` or its
1278
+ group throws an `AbortError`, unwrapped, and is never retried: check it
1279
+ with `isAbortError()`, not `instanceof ClientError`.
1280
+ - **`latest` keys are per client.** Two clients never share one, but a
1281
+ client and its groups do: a group's call with a key replaces the client's.
1282
+ Use distinct keys for calls that must not replace each other.
1283
+ - **`decode: false` changes the types.** A reply is then typed as what the
1284
+ schema takes, `z.input`, not what it gives, `z.output`: a date-time is a
1285
+ string.
1286
+ - **`validate: false` keeps the types.** The reply is still typed by its
1287
+ schema, but nothing checked it.
1288
+ - **Bun adds a charset to text `Blob`s.** A `Blob` of `text/html` has the
1289
+ type `text/html;charset=utf-8` in Bun, and that is the `Content-Type` a
1290
+ `body` of it is sent with.
1291
+ - **Declare the statuses you handle.** With `responses`, any other status
1292
+ throws, a 500 included. Leave `responses` out to get every reply back.
1293
+ - **A finite event stream reconnects.** A GET stream that simply ends is
1294
+ read again, as `EventSource` would. End it with a 204 from the server,
1295
+ `close()` it, or pass `reconnect: false`.