@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.
- package/README.md +1295 -0
- package/dist/cancel/abort.d.ts +20 -0
- package/dist/cancel/abort.d.ts.map +1 -0
- package/dist/cancel/latest.d.ts +3 -0
- package/dist/cancel/latest.d.ts.map +1 -0
- package/dist/client/create-http-client.d.ts +14 -0
- package/dist/client/create-http-client.d.ts.map +1 -0
- package/dist/client/types.d.ts +123 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/errors/errors.d.ts +80 -0
- package/dist/errors/errors.d.ts.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1110 -0
- package/dist/index.js.map +27 -0
- package/dist/integration/index.d.ts +9 -0
- package/dist/integration/index.d.ts.map +1 -0
- package/dist/integration/index.js +1051 -0
- package/dist/integration/index.js.map +25 -0
- package/dist/middleware/auth.d.ts +18 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/cache.d.ts +42 -0
- package/dist/middleware/cache.d.ts.map +1 -0
- package/dist/middleware/compose.d.ts +22 -0
- package/dist/middleware/compose.d.ts.map +1 -0
- package/dist/middleware/retry.d.ts +36 -0
- package/dist/middleware/retry.d.ts.map +1 -0
- package/dist/reply/media-type.d.ts +18 -0
- package/dist/reply/media-type.d.ts.map +1 -0
- package/dist/reply/read-reply.d.ts +25 -0
- package/dist/reply/read-reply.d.ts.map +1 -0
- package/dist/reply/types.d.ts +55 -0
- package/dist/reply/types.d.ts.map +1 -0
- package/dist/reply/unwrap.d.ts +31 -0
- package/dist/reply/unwrap.d.ts.map +1 -0
- package/dist/request/encode.d.ts +24 -0
- package/dist/request/encode.d.ts.map +1 -0
- package/dist/request/types.d.ts +57 -0
- package/dist/request/types.d.ts.map +1 -0
- package/dist/schema/standard-schema.d.ts +46 -0
- package/dist/schema/standard-schema.d.ts.map +1 -0
- package/dist/stream/connection.d.ts +45 -0
- package/dist/stream/connection.d.ts.map +1 -0
- package/dist/stream/event-stream.d.ts +15 -0
- package/dist/stream/event-stream.d.ts.map +1 -0
- package/dist/stream/line-parser.d.ts +13 -0
- package/dist/stream/line-parser.d.ts.map +1 -0
- package/dist/stream/line-stream.d.ts +10 -0
- package/dist/stream/line-stream.d.ts.map +1 -0
- package/dist/stream/sse-parser.d.ts +25 -0
- package/dist/stream/sse-parser.d.ts.map +1 -0
- package/dist/stream/types.d.ts +66 -0
- package/dist/stream/types.d.ts.map +1 -0
- 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`.
|