liaise 0.0.0 → 5.0.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 (55) hide show
  1. package/CHANGELOG.md +961 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +925 -0
  4. package/README.md +1392 -4
  5. package/dist/built-in-middleware.d.ts +232 -0
  6. package/dist/built-in-middleware.js +127 -0
  7. package/dist/create-api.d.ts +120 -0
  8. package/dist/create-api.js +370 -0
  9. package/dist/define-request.d.ts +251 -0
  10. package/dist/define-request.js +4 -0
  11. package/dist/graphql.d.ts +30 -0
  12. package/dist/graphql.js +272 -0
  13. package/dist/index.d.ts +16 -0
  14. package/dist/index.js +6 -0
  15. package/dist/middleware.d.ts +77 -0
  16. package/dist/middleware.js +12 -0
  17. package/dist/paginate.d.ts +71 -0
  18. package/dist/paginate.js +15 -0
  19. package/dist/request.d.ts +136 -0
  20. package/dist/request.js +12 -0
  21. package/dist/result.d.ts +178 -0
  22. package/dist/result.js +20 -0
  23. package/dist/testing.d.ts +51 -0
  24. package/dist/testing.js +135 -0
  25. package/dist/types.d.ts +751 -0
  26. package/dist/types.js +1 -0
  27. package/dist/utils/abort-kind.d.ts +58 -0
  28. package/dist/utils/abort-kind.js +28 -0
  29. package/dist/utils/any-signal.d.ts +18 -0
  30. package/dist/utils/any-signal.js +29 -0
  31. package/dist/utils/backstop.d.ts +49 -0
  32. package/dist/utils/backstop.js +80 -0
  33. package/dist/utils/budget.d.ts +53 -0
  34. package/dist/utils/budget.js +20 -0
  35. package/dist/utils/cache.d.ts +54 -0
  36. package/dist/utils/cache.js +37 -0
  37. package/dist/utils/dedupe.d.ts +90 -0
  38. package/dist/utils/dedupe.js +20 -0
  39. package/dist/utils/headers.d.ts +1 -0
  40. package/dist/utils/headers.js +19 -0
  41. package/dist/utils/path-params.d.ts +89 -0
  42. package/dist/utils/path-params.js +80 -0
  43. package/dist/utils/serialize.d.ts +48 -0
  44. package/dist/utils/serialize.js +21 -0
  45. package/dist/utils/share.d.ts +49 -0
  46. package/dist/utils/share.js +48 -0
  47. package/dist/utils/special-body.d.ts +18 -0
  48. package/dist/utils/special-body.js +7 -0
  49. package/dist/utils/stable-key.d.ts +55 -0
  50. package/dist/utils/stable-key.js +111 -0
  51. package/dist/utils/timeout.d.ts +27 -0
  52. package/dist/utils/timeout.js +7 -0
  53. package/dist/utils/validate.d.ts +32 -0
  54. package/dist/utils/validate.js +6 -0
  55. package/package.json +67 -5
package/README.md CHANGED
@@ -2,9 +2,1397 @@
2
2
 
3
3
  *lee-AYZ* — to act as the link between two parties.
4
4
 
5
- Type-safe API client for REST and GraphQL, built on standard `fetch`. Never throws. Zero dependencies.
5
+ > Formerly published as `@iremlopsum/apify`. Same code, same API, full history — switching takes two steps, see [MIGRATION.md](./MIGRATION.md#upgrading-to-500).
6
6
 
7
- This is a placeholder. The first release under this name is coming soon — it continues
8
- [`@iremlopsum/apify`](https://www.npmjs.com/package/@iremlopsum/apify) with the same API and full history.
7
+ Runtime-agnostic, type-safe HTTP client for REST and GraphQL. Built on standard `fetch`. Zero dependencies.
9
8
 
10
- Follow along at [github.com/iremlopsum/liaise](https://github.com/iremlopsum/liaise).
9
+ - **Unified API** — REST and GraphQL share the same `Result<T>` shape, middleware stack, and error contract
10
+ - **Never throws** — every call returns `{ data, error, response, retry }`, no try/catch required
11
+ - **Composable middleware** — retry, cache, dedupe, auth, logging — applied at global, per-endpoint, or per-call level
12
+ - **Types by inference** — declare params and response once on the endpoint definition; types flow to every call site automatically
13
+ - **Runtime-agnostic** — Node.js 20+, browsers, Bun, Deno, Cloudflare Workers, React Native — any environment with `fetch`
14
+ - **Tiny** — about **2.9 kB gzipped** for a REST-only import, 4.4 kB for everything including GraphQL and all middleware; tree-shaking drops what you do not import
15
+
16
+ ```
17
+ npm install liaise
18
+ ```
19
+
20
+ ## Table of Contents
21
+
22
+ - [Getting Started](#getting-started)
23
+ - [REST API](#rest-api)
24
+ - [Request](#request)
25
+ - [`defineRequest`](#definerequest)
26
+ - [Response validation](#response-validation)
27
+ - [Pagination](#pagination)
28
+ - [Query strings](#query-strings)
29
+ - [Result](#result)
30
+ - [Error handling with `onError`](#error-handling-with-onerror)
31
+ - [Middleware](#middleware)
32
+ - [Built-in middleware (retry, cache, log)](#built-in-middleware)
33
+ - [Content types](#content-types)
34
+ - [Response parsing](#response-parsing)
35
+ - [Cancellation](#cancellation)
36
+ - [Timeout](#timeout)
37
+ - [Sharing](#sharing)
38
+ - [TypeScript](#typescript)
39
+ - [GraphQL Client](#graphql-client)
40
+ - [Queries and mutations](#queries-and-mutations)
41
+ - [GraphQL errors](#graphql-errors)
42
+ - [Middleware](#middleware-1)
43
+ - [Testing](#testing)
44
+ - [Philosophy](#philosophy)
45
+ - [API Reference](#api-reference)
46
+
47
+ ## Getting Started
48
+
49
+ Define your endpoints as `Request` instances, wire them into a client with `createApi`, and call them with full type safety.
50
+
51
+ ```ts
52
+ import { createApi, Request } from 'liaise'
53
+
54
+ // 1. Define your endpoints
55
+ interface User {
56
+ id: string
57
+ name: string
58
+ email: string
59
+ }
60
+
61
+ const getUser = new Request<{ id: string }, User>({
62
+ method: 'GET',
63
+ path: '/users/:id'
64
+ })
65
+
66
+ const createUser = new Request<{ name: string; email: string }, User>({
67
+ method: 'POST',
68
+ path: '/users'
69
+ })
70
+
71
+ // 2. Create the client
72
+ const api = createApi({
73
+ baseUrl: 'https://api.example.com',
74
+ requests: { getUser, createUser },
75
+ onError: (error) => console.error(`${error.request.method} ${error.request.url}`, error.status)
76
+ })
77
+
78
+ // 3. Make a call — params and response are fully typed
79
+ const { data, error, retry } = await api.getUser({ id: '42' })
80
+
81
+ if (error) {
82
+ console.error(error.status, error.body)
83
+ return
84
+ }
85
+
86
+ // data is typed as User
87
+ console.log(data.name)
88
+ ```
89
+
90
+ ## REST API
91
+
92
+ ### Request
93
+
94
+ Each API endpoint is represented by a `Request` instance. The class is a typed config container -- it stores the recipe for how an endpoint should be called, but does not execute anything on its own.
95
+
96
+ ```ts
97
+ import { Request } from 'liaise'
98
+
99
+ const listItems = new Request<{ page: number; limit: number }, Item[]>({
100
+ method: 'GET',
101
+ path: '/items'
102
+ })
103
+ ```
104
+
105
+ The two type parameters drive the entire type system:
106
+
107
+ - `TParams` -- the shape of the params object the caller must provide (path params, query params, and body params combined).
108
+ - `TResponse` -- the shape of the successful response data. This becomes the type of `result.data`.
109
+
110
+ When an endpoint takes no params, use `Record<string, never>` and the generated method will accept an optional (or omitted) params argument:
111
+
112
+ ```ts
113
+ const health = new Request<Record<string, never>, { status: string }>({
114
+ method: 'GET',
115
+ path: '/health'
116
+ })
117
+
118
+ // Both work:
119
+ await api.health()
120
+ await api.health({})
121
+ ```
122
+
123
+ #### Path parameters
124
+
125
+ Use `:param` syntax in the path. Matching keys from the params object are substituted into the URL and excluded from the query string or body:
126
+
127
+ ```ts
128
+ const getItem = new Request<{ orgId: string; id: string }, Item>({
129
+ method: 'GET',
130
+ path: '/orgs/:orgId/items/:id'
131
+ })
132
+
133
+ // Calls GET /orgs/acme/items/42
134
+ await api.getItem({ orgId: 'acme', id: '42' })
135
+ ```
136
+
137
+ #### `responseType`
138
+
139
+ Controls how the response body is parsed. Defaults to `'json'`.
140
+
141
+ ```ts
142
+ const downloadFile = new Request<{ id: string }, Blob>({
143
+ method: 'GET',
144
+ path: '/files/:id',
145
+ responseType: 'blob'
146
+ })
147
+ ```
148
+
149
+ See [Response parsing](#response-parsing) for all options.
150
+
151
+ #### `dedupe`
152
+
153
+ When `true`, firing a new call to this endpoint auto-cancels any previous in-flight call. Useful for search-as-you-type or rapidly changing filters:
154
+
155
+ ```ts
156
+ const searchUsers = new Request<{ q: string }, User[]>({
157
+ method: 'GET',
158
+ path: '/users/search',
159
+ dedupe: true
160
+ })
161
+
162
+ // If a second call starts before the first finishes, the first is aborted
163
+ await api.searchUsers({ q: 'hel' })
164
+ await api.searchUsers({ q: 'hello' }) // previous call is auto-cancelled
165
+ ```
166
+
167
+ #### `share`
168
+
169
+ When `true`, identical concurrent calls to this endpoint join a single in-flight request instead of firing their own. See [Sharing](#sharing) for the full contract — including its mutual exclusion with `dedupe` and what disables it.
170
+
171
+ ```ts
172
+ const getProduct = new Request<{ id: string }, Product>({
173
+ method: 'GET',
174
+ path: '/products/:id',
175
+ share: true
176
+ })
177
+
178
+ // Both calls join the same network request
179
+ await Promise.all([
180
+ api.getProduct({ id: '42' }),
181
+ api.getProduct({ id: '42' })
182
+ ])
183
+ ```
184
+
185
+ #### `bodyAs`
186
+
187
+ Overrides the default body serialization strategy. By default, GET/DELETE serialize params as query strings and POST/PUT/PATCH serialize params as a JSON body. Use `bodyAs` to invert that:
188
+
189
+ ```ts
190
+ // DELETE endpoint that expects a JSON body
191
+ const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
192
+ method: 'DELETE',
193
+ path: '/items',
194
+ bodyAs: 'body'
195
+ })
196
+
197
+ // POST endpoint that sends params as query string
198
+ const triggerJob = new Request<{ priority: number }, Job>({
199
+ method: 'POST',
200
+ path: '/jobs/trigger',
201
+ bodyAs: 'query'
202
+ })
203
+ ```
204
+
205
+ ### `defineRequest`
206
+
207
+ `new Request<TParams, TResponse>` makes you restate what the path already says,
208
+ and nothing checks the two against each other:
209
+
210
+ ```ts
211
+ // The params are restated by hand, and nothing checks them against the path:
212
+ const getUser = new Request<{ userId: string }, User>({ method: 'GET', path: '/users/:id' })
213
+
214
+ api.getUser({ userId: '42' }) // compiles — then fails at runtime: buildUrl finds
215
+ // no `:userId` to substitute, `:id` survives, and
216
+ // the unresolved-token check throws
217
+ ```
218
+
219
+ `defineRequest` infers the params from the path literal instead:
220
+
221
+ ```ts
222
+ import { defineRequest } from 'liaise'
223
+
224
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
225
+
226
+ api.getUser({ id: '42' }) // ✓
227
+ api.getUser({ id: 42 }) // ✓ — numbers are encoded
228
+ api.getUser({ userId: '42' }) // ✗ Object literal may only specify known properties
229
+ ```
230
+
231
+ Params the path does not name — query or body fields — go in the second type
232
+ argument:
233
+
234
+ ```ts
235
+ const listRepos = defineRequest<Repo[], { page?: number }>()({
236
+ method: 'GET',
237
+ path: '/orgs/:org/repos',
238
+ })
239
+
240
+ api.listRepos({ org: 'acme' }) // ✓ page is optional
241
+ api.listRepos({ org: 'acme', page: 2 }) // ✓
242
+ api.listRepos({ page: 2 }) // ✗ org is required
243
+ ```
244
+
245
+ It also enforces the `responseType: 'none'` convention that `new Request` can
246
+ only document:
247
+
248
+ ```ts
249
+ defineRequest<undefined>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✓
250
+ defineRequest<User>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✗
251
+ ```
252
+
253
+ **Why two calls.** TypeScript has no partial type-argument inference: if the response
254
+ type and the config were arguments to one call, supplying the response type explicitly
255
+ would stop the path from being inferred, and the checking would quietly do nothing.
256
+ Splitting them keeps the response type explicit and the path inferred. Calling it
257
+ wrong is a compile error, not a silent one.
258
+
259
+ `new Request(...)` is unchanged and not deprecated — use it when the config is
260
+ not a literal, or when you do not want the path checked.
261
+
262
+ ### Response validation
263
+
264
+ Pass any [Standard Schema](https://standardschema.dev) validator — Zod, Valibot,
265
+ ArkType — and the response is checked before you see it. liaise takes no
266
+ dependency on one; Standard Schema is an interface, not a package.
267
+
268
+ ```ts
269
+ import { z } from 'zod'
270
+
271
+ const getUser = defineRequest()({
272
+ method: 'GET',
273
+ path: '/users/:id',
274
+ schema: z.object({ id: z.string(), name: z.string() }),
275
+ })
276
+
277
+ const { data, error } = await api.getUser({ id: '42' })
278
+ // ^? { id: string; name: string } | null
279
+ ```
280
+
281
+ The schema supplies the response type, so there is no type argument to write —
282
+ and no second place for it to drift out of date.
283
+
284
+ **`data` is the schema's output.** A schema that transforms changes what you
285
+ receive:
286
+
287
+ ```ts
288
+ const getUser = defineRequest()({
289
+ method: 'GET',
290
+ path: '/users/:id',
291
+ schema: z.object({
292
+ id: z.string(),
293
+ createdAt: z.coerce.date(), // the wire sends a string
294
+ role: z.string().default('user'), // absent on the wire
295
+ }),
296
+ })
297
+
298
+ const { data } = await api.getUser({ id: '42' })
299
+ data.createdAt // a real Date
300
+ data.role // 'user' when the server omitted it
301
+ ```
302
+
303
+ That is the point of validating through a schema rather than merely checking
304
+ one — but it does mean `data` is no longer byte-identical to the response.
305
+
306
+ A response the schema refuses is an error `Result`, never a throw:
307
+
308
+ ```ts
309
+ const { error } = await api.getUser({ id: '42' })
310
+ if (error?.kind === 'parse') {
311
+ console.error(error.body) // the validator's issues
312
+ error.status // the response's own status — the server was fine
313
+ }
314
+ ```
315
+
316
+ Only the **success** body is validated. A non-2xx body is diagnostic and often a
317
+ different shape, so it is left alone.
318
+
319
+ Schemas work on the GraphQL client too, validating the response's `data`:
320
+
321
+ ```ts
322
+ const me = new Operation<{}, User>({
323
+ operation: gql`query { me { id name } }`,
324
+ schema: UserSchema,
325
+ })
326
+ ```
327
+
328
+ There the response type stays explicit — only `defineRequest` infers it.
329
+
330
+ ### Pagination
331
+
332
+ `paginate` walks a paginated endpoint, yielding one `Result` per page:
333
+
334
+ ```ts
335
+ import { paginate } from 'liaise'
336
+
337
+ for await (const page of paginate(api.listItems, { limit: 50 }, {
338
+ next: (p, prev) => p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined,
339
+ })) {
340
+ if (page.error) break
341
+ render(page.data.items)
342
+ }
343
+ ```
344
+
345
+ **`next` returns the next params, not a cursor.** That is what keeps this
346
+ library out of the business of guessing where a cursor goes — `cursor`?
347
+ `page_token`? `after`? The previous params arrive as the second argument, so
348
+ the common case is a spread, and the same shape covers every scheme:
349
+
350
+ ```ts
351
+ // offset
352
+ next: (p, prev) => p.data.items.length === prev.limit
353
+ ? { ...prev, offset: prev.offset + prev.limit }
354
+ : undefined
355
+
356
+ // page number, driven by a Link header
357
+ next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
358
+ ? { ...prev, page: prev.page + 1 }
359
+ : undefined
360
+ ```
361
+
362
+ Return `undefined` or `null` to stop.
363
+
364
+ **An error page is yielded, then the walk ends.** There is no data to read the
365
+ next cursor from, so there is nothing to continue with — and you see what
366
+ failed rather than a loop that quietly stopped.
367
+
368
+ **`maxPages` is optional and has no default.** A ceiling exists if you want one;
369
+ the library will not invent a number, because a silent truncation at an
370
+ arbitrary limit looks exactly like reaching the last page.
371
+
372
+ ```ts
373
+ paginate(api.listItems, { limit: 50 }, { next, maxPages: 100 })
374
+ ```
375
+
376
+ Any other [`CallOptions`](#calloptions) — `signal`, `timeout`, `headers` — apply
377
+ to every request, so one signal cancels the whole crawl.
378
+
379
+ `paginate` yields pages, not items. Flattening would mean deciding which field
380
+ holds the array, which is the convention-guessing `next` exists to avoid.
381
+
382
+ ### Query strings
383
+
384
+ For GET and DELETE requests (or any request with `bodyAs: 'query'`), params that are not consumed by path substitution are serialized as a query string using `URLSearchParams`.
385
+
386
+ A `baseUrl` may carry its own query string — a fixed API key, say. Its params
387
+ are merged ahead of the call's:
388
+
389
+ ```ts
390
+ const api = createApi({
391
+ baseUrl: 'https://api.example.com/v1?key=abc',
392
+ requests: { search: new Request<{ q: string }, Hit[]>({ method: 'GET', path: '/search' }) },
393
+ })
394
+
395
+ await api.search({ q: 'hello' })
396
+ // GET https://api.example.com/v1/search?key=abc&q=hello
397
+ ```
398
+
399
+ Merging **accumulates**, it does not override: a call param whose key the base
400
+ already used produces both, `?key=abc&key=xyz`, and which one wins is the
401
+ server's decision. This differs from headers, where a per-call value replaces a
402
+ global one — because array params already serialize as repeated keys
403
+ (`tags=a&tags=b`), so collapsing duplicates would break them. If a base-level
404
+ param needs to vary per call, set it from middleware rather than the `baseUrl`.
405
+
406
+ **A `#fragment` is refused.** A fragment is never sent to the server, so one in
407
+ a `path` or `baseUrl` cannot do what it appears to — and before 4.2.1 it
408
+ silently discarded the query string. It is now an error naming the offending
409
+ value, rather than being stripped, so the dead code does not stay in your
410
+ template.
411
+
412
+ Since 4.4.0 a fragment in a [`defineRequest`](#definerequest) `path` **literal**
413
+ is also a compile error, so the endpoint is rejected where it is declared rather
414
+ than on every call:
415
+
416
+ ```ts
417
+ defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
418
+ // ^ Property '__fragmentInPath' is missing:
419
+ // a URL fragment is never sent to the server
420
+ ```
421
+
422
+ The check reads the literal, so a path assembled at runtime — or a
423
+ `RequestConfig`-typed variable — still compiles and is caught by the runtime
424
+ error instead. `new Request` takes no path literal, so it has no equivalent
425
+ check; this is one of the things `defineRequest` buys you.
426
+
427
+ **A `#` inside a param *value* is not a fragment** and is never refused — it is
428
+ escaped to `%23` and sent as ordinary data:
429
+
430
+ ```ts
431
+ await api.getDoc({ id: 'a#b' }) // → GET /docs/a%23b
432
+ await api.search({ tag: 'a#b' }) // → GET /search?tag=a%23b
433
+ ```
434
+
435
+ Only a `#` written into a `path` or `baseUrl` is refused, because that one was
436
+ never going to reach the server.
437
+
438
+ | Input | Output |
439
+ | ------------------------------ | --------------------------- |
440
+ | `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
441
+ | `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
442
+ | `{ filter: null }` | _(omitted)_ |
443
+ | `{ filter: undefined }` | _(omitted)_ |
444
+ | `{ meta: { nested: true } }` | **TypeError** (see below) |
445
+
446
+ **Arrays** use repeated keys (`tags=a&tags=b`), which is the most widely supported format across server frameworks.
447
+
448
+ **`null` and `undefined`** values are silently omitted from the query string.
449
+
450
+ **Nested objects** throw a `TypeError` with a descriptive message. Flatten the structure before passing. This is intentional -- there is no universal standard for serializing nested objects in query strings (brackets, dots, JSON), so the library refuses to guess.
451
+
452
+ ### Result
453
+
454
+ Every API call returns a `Result<TResponse>` instead of throwing. It's a discriminated union on `error`, not a plain interface:
455
+
456
+ ```ts
457
+ interface SuccessResult<TResponse> {
458
+ data: TResponse // parsed response
459
+ error: null
460
+ response: Response // always present on success
461
+ retry: () => Promise<Result<TResponse>>
462
+ }
463
+
464
+ interface ErrorResult<TResponse> {
465
+ data: null
466
+ error: ApiError // structured error, see below
467
+ response: Response | null // present for HTTP/parse failures, null for network/abort/timeout
468
+ retry: () => Promise<Result<TResponse>>
469
+ }
470
+
471
+ type Result<TResponse> = SuccessResult<TResponse> | ErrorResult<TResponse>
472
+ ```
473
+
474
+ Check `error` first, then use `data` with confidence: `if (error) return` (or any other narrowing check on `error`) narrows `data` to `TResponse` for the rest of the function -- no `data!` assertion needed. That narrowing is only as accurate as `TResponse` itself, though: an endpoint that answers `204` or an empty `200` (a `DELETE`, most commonly) doesn't return a body at all -- declare it with `responseType: 'none'` and `TResponse` of `undefined`, rather than widening `TResponse` to `| null`, which since 4.0.0 does not work
475
+ at all -- an empty body under `'json'` is a `'parse'` error -- see [Response parsing](#response-parsing) below. Branch on `error.kind` rather than `error.status` — `'network'`, `'abort'` and `'timeout'` all carry `status: 0`, but they call for different handling:
476
+
477
+ ```ts
478
+ const { data, error, response, retry } = await api.getUser({ id: '42' })
479
+
480
+ if (error) {
481
+ switch (error.kind) {
482
+ case 'network':
483
+ // fetch itself failed -- user is probably offline
484
+ break
485
+ case 'timeout':
486
+ // the whole-operation deadline fired; report it
487
+ reportTimeout(error)
488
+ break
489
+ case 'abort':
490
+ // this call was cancelled (dedupe supersede, or your own signal) -- usually ignore it
491
+ break
492
+ case 'parse':
493
+ // a 2xx response arrived but its body didn't parse as `responseType`
494
+ console.error('unparseable response', error.status, error.body)
495
+ break
496
+ case 'middleware':
497
+ // a middleware threw -- a bug in your own pipeline, not a transient failure
498
+ console.error('middleware threw', error.body)
499
+ break
500
+ case 'http':
501
+ if (error.status === 401) redirectToLogin()
502
+ else console.error(error.status, error.body)
503
+ break
504
+ }
505
+ return
506
+ }
507
+
508
+ // error is null here, so `data` is narrowed to `User` -- no assertion needed
509
+ // (this assumes getUser always answers with a body; an endpoint that
510
+ // doesn't -- a DELETE returning 204, most commonly -- should use
511
+ // responseType: 'none' instead, see the empty-body note above)
512
+ console.log(data.name)
513
+ ```
514
+
515
+ `response`'s body has already been consumed by the time you see it -- the library reads it to produce `data` (or `error.body`), so calling `response.json()` yourself throws "Body has already been read". Use `data`/`error.body`; `response` is for status, headers, and redirect metadata. (This applies only to results the library produces itself -- a `Response` you construct for `successResult()` in `testing.ts` still has a readable body.)
516
+
517
+ #### `retry()`
518
+
519
+ The `retry` function re-executes the exact same request through the full middleware chain. Auth tokens are re-injected, logging fires again, everything runs fresh. This is useful for retry-after-refresh patterns:
520
+
521
+ ```ts
522
+ const { data, error, retry } = await api.getUser({ id: '42' })
523
+
524
+ if (error?.status === 401) {
525
+ await refreshToken()
526
+ const retried = await retry()
527
+ // retried goes through the full middleware chain again
528
+ }
529
+ ```
530
+
531
+ #### `ApiError`
532
+
533
+ The error object on failed calls. It is not a subclass of `Error` -- it is a structured container for API-level error details.
534
+
535
+ | Property | Type | Description |
536
+ | ------------ | --------- | ----------------------------------------------------------------- |
537
+ | `status` | `number` | HTTP status code (e.g., 404, 500). `0` for network errors, aborts, and timeouts. |
538
+ | `kind` | `'http' \| 'network' \| 'abort' \| 'timeout' \| 'parse' \| 'middleware'` | What category of failure this is. See below. Required -- constructing an `ApiError` yourself (e.g. in custom middleware) must supply it. |
539
+ | `statusText` | `string` | HTTP status text (e.g., 'Not Found'). `''` for network errors. |
540
+ | `body` | `unknown` | Parsed response body -- but for `'parse'`, one of: the thrown exception (a malformed body), the raw response text (an empty body, or a GraphQL response carrying no data), a schema's issues array (the response failed validation), or a value a schema threw. The native Error for network failures. |
541
+ | `headers` | `Headers` | Response headers. Empty `Headers` for network errors. |
542
+ | `request` | `object` | `{ method, url, params }` -- metadata about the failed request; `url` is the resolved, path-substituted address, falling back to the route template only when it could not be built. |
543
+ | `partialData` | `unknown` (optional) | GraphQL data returned alongside `{ errors }` (partial success). Lives here, not on `Result.data`, so the `Result` stays a clean union: `data` is non-null iff `error` is null. `undefined` for every REST error and for GraphQL responses carrying no data. |
544
+
545
+ `kind` exists because `status` alone cannot tell some outcomes apart: an HTTP error (`'http'`), a `fetch` failure with no response (`'network'`), a cancellation — your own signal, a dedupe supersede, or a whole-operation deadline firing — (`'abort'`/`'timeout'`), a 2xx (or non-2xx) body that failed to parse (`'parse'`), and a middleware that threw instead of the request itself failing (`'middleware'`) all need different handling, but `'network'`, `'abort'`, and `'timeout'` all carry `status: 0`.
546
+
547
+ `'parse'` is for a **2xx** response that arrived but whose body failed to parse according to `responseType` -- you get the real `status`, a non-null `response`, and `kind: 'parse'`. A **non-2xx** response with an unparseable body is unaffected and still reports `kind: 'http'` -- the status code is checked before the body is parsed, so a 500 with a broken JSON body is still a 500, and `retryMiddleware`'s default 5xx retry still applies to it. An **empty** body under `'json'` is also `'parse'` -- see [Response
548
+ parsing](#response-parsing). GraphQL applies the same rule to a 2xx response
549
+ carrying neither `data` nor `errors`. An optional [`schema`](#response-validation) on the request adds two more `'parse'` producers: a **2xx** body the schema refuses (`error.body` is its issues array) and a validator that throws (`error.body` is the thrown value) -- both only for the success body, never for a non-2xx one, which is never validated.
550
+
551
+ `'middleware'` means a middleware threw rather than the request itself failing -- a bug in your own pipeline you'd fix, not a transient failure you'd retry. A middleware that propagates the library's own abort/timeout signal (verbatim, or wrapped one level as `.cause`) is classified `'abort'`/`'timeout'` instead, by provenance rather than by the reason's name -- see [Cancellation](#cancellation).
552
+
553
+ You can use `instanceof` to check if a value is an `ApiError`:
554
+
555
+ ```ts
556
+ import { ApiError } from 'liaise'
557
+
558
+ if (error instanceof ApiError) {
559
+ // ...
560
+ }
561
+ ```
562
+
563
+ ### Error handling with `onError`
564
+
565
+ The `onError` callback in `createApi` fires after the full middleware chain completes whenever the final result has an error. If a retry middleware recovers a 5xx to a 200, `onError` does not fire.
566
+
567
+ ```ts
568
+ const api = createApi({
569
+ baseUrl: '/api',
570
+ requests: { getUser, createUser },
571
+ onError: (error) => {
572
+ if (error.status === 401) redirectToLogin()
573
+ Sentry.captureException(error)
574
+ }
575
+ })
576
+ ```
577
+
578
+ This fires for `kind: 'http'`, `'network'`, `'timeout'`, `'parse'`, and `'middleware'`. **It does not fire for `kind: 'abort'`** — a cancellation the library caused deliberately (your own `AbortSignal` firing, or a request superseded by `dedupe`) is not a failure worth reporting to an error tracker, unlike a `'timeout'`, which is a deadline you actually missed. The caller still gets the abort back in the `Result` either way; only the report to this callback is suppressed. It is a global hook for side effects (logging, telemetry, redirects) -- it does not change the result returned to the caller.
579
+
580
+ ### Middleware
581
+
582
+ Middleware follows the onion model (like Koa or Redux middleware). Each middleware wraps the next layer, can modify the request going in and the result coming out.
583
+
584
+ ```
585
+ Request → [Global MW → [Per-request MW → [Per-call MW → [fetch]]]]
586
+ ```
587
+
588
+ A middleware function receives a `context` and a `next` function:
589
+
590
+ ```ts
591
+ import type { Middleware } from 'liaise'
592
+
593
+ const authMiddleware: Middleware = async (ctx, next) => {
594
+ // Before: modify the request
595
+ ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
596
+
597
+ // Call the next layer
598
+ const result = await next()
599
+
600
+ // After: inspect or transform the result
601
+ return result
602
+ }
603
+ ```
604
+
605
+ #### What middleware can do
606
+
607
+ - **Modify the request** -- set headers, change the body, rewrite the URL.
608
+ - **Short-circuit** -- return early without calling `next()` (e.g., serve from cache).
609
+ - **Retry** -- call `next()` multiple times in a loop (e.g., retry on 5xx).
610
+ - **Inspect the result** -- log, report errors, transform response data.
611
+
612
+ #### Three layers
613
+
614
+ Middleware is applied at three levels. The execution order is global first, per-request second, per-call third:
615
+
616
+ ```ts
617
+ // Global -- applies to every endpoint
618
+ const api = createApi({
619
+ baseUrl: '/api',
620
+ requests: { getUser, createUser },
621
+ middleware: [authMiddleware, logMiddleware]
622
+ })
623
+
624
+ // Per-request -- applies only to this endpoint
625
+ const getUser = new Request<{ id: string }, User>({
626
+ method: 'GET',
627
+ path: '/users/:id',
628
+ middleware: [cacheMiddleware]
629
+ })
630
+
631
+ // Per-call -- applies only to this single invocation
632
+ await api.getUser({ id: '42' }, {
633
+ middleware: [customTraceMiddleware]
634
+ })
635
+ ```
636
+
637
+ #### `skipMiddleware`
638
+
639
+ Remove specific middleware for a single call by passing references to `skipMiddleware`:
640
+
641
+ ```ts
642
+ const retry = retryMiddleware(3)
643
+
644
+ const api = createApi({
645
+ baseUrl: '/api',
646
+ requests: { getUser },
647
+ middleware: [retry, logMiddleware]
648
+ })
649
+
650
+ // Skip retry for this one call
651
+ await api.getUser({ id: '42' }, {
652
+ skipMiddleware: [retry]
653
+ })
654
+ ```
655
+
656
+ Comparison is by reference (`===`). Factory-style middleware like `retryMiddleware(3)` must be stored in a variable first -- calling the factory again creates a new reference that will not match.
657
+
658
+ #### `MiddlewareContext`
659
+
660
+ The context object passed to each middleware:
661
+
662
+ | Property | Type | Description |
663
+ | --------------------- | --------- | -------------------------------------------------------- |
664
+ | `request.method` | `string` | HTTP method (GET, POST, etc.) |
665
+ | `request.url` | `string` | Fully resolved URL with path params and query string |
666
+ | `request.path` | `string` | Original path template (e.g., '/users/:id') |
667
+ | `request.params` | `unknown` | Original params object from the caller |
668
+ | `request.headers` | `Headers` | Merged headers -- middleware can add/remove entries |
669
+ | `request.body` | `unknown` | Serialized body, or null for GET/DELETE |
670
+ | `request.signal` | `AbortSignal \| undefined` | The signal handed to `fetch` -- replace it to impose your own cancellation policy |
671
+ | `requestName` | `string` | Key name in the requests object (e.g., 'getUser') |
672
+
673
+ `request.signal` holds the call's own signal: the caller's `options.signal` merged with any `timeout` (and, under `share: true`, with the refcount that aborts the shared request once every sharer has given up). It is `undefined` only when there is none of those. The core fetch reads the field at call time, so replacing it takes effect -- that is all a timeout middleware needs:
674
+
675
+ ```ts
676
+ const timeout = (ms: number): Middleware => async (ctx, next) => {
677
+ ctx.request.signal = AbortSignal.timeout(ms)
678
+ return next()
679
+ }
680
+
681
+ const api = createApi({
682
+ baseUrl: '/api',
683
+ requests: { getUser },
684
+ middleware: [timeout(5000)]
685
+ })
686
+ ```
687
+
688
+ Under `dedupe: true` your signal is merged rather than discarded: the request is cancelled by whichever fires first -- your signal, or a newer call superseding this one. The dedupe signal is installed by the core fetch, so middleware reading `ctx.request.signal` before `next()` sees the caller's signal, not the dedupe one.
689
+
690
+ **Pass `ctx.request.signal` on to any async work your middleware does itself** -- a token refresh, a lookup, a queue. The library will not wait for that work past the call's deadline or the caller's abort either way (see [Timeout](#timeout)), but a promise cannot be cancelled from outside: handing it the signal is the only thing that actually *stops* the work, instead of leaving it running in the background with its result discarded.
691
+
692
+ #### Writing custom middleware
693
+
694
+ A cache middleware that short-circuits on cache hits:
695
+
696
+ ```ts
697
+ const cacheMiddleware: Middleware = async (ctx, next) => {
698
+ const cached = cache.get(ctx.request.url)
699
+ if (cached) return cached
700
+
701
+ const result = await next()
702
+
703
+ if (result.data) {
704
+ cache.set(ctx.request.url, result)
705
+ }
706
+
707
+ return result
708
+ }
709
+ ```
710
+
711
+ An error reporting middleware:
712
+
713
+ ```ts
714
+ const sentryMiddleware: Middleware = async (ctx, next) => {
715
+ const result = await next()
716
+
717
+ if (result.error && result.error.status >= 500) {
718
+ Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
719
+ extra: { status: result.error.status, body: result.error.body }
720
+ })
721
+ }
722
+
723
+ return result
724
+ }
725
+ ```
726
+
727
+ #### Built-in middleware
728
+
729
+ The library ships three optional middleware functions, importable from a separate entry point:
730
+
731
+ ```ts
732
+ import { retryMiddleware, logMiddleware, cacheMiddleware } from 'liaise/middleware'
733
+ ```
734
+
735
+ **`retryMiddleware(options?: number | RetryOptions)`**
736
+
737
+ Automatically retries requests that fail, with a real backoff policy — exponential (or linear, or custom) delay curves, full jitter, `Retry-After` support, a configurable retry predicate, and an observational `onRetry` hook.
738
+
739
+ The numeric shorthand still works exactly as before — `retryMiddleware(2)` retries up to 2 additional times (3 total attempts) on a 5xx response:
740
+
741
+ ```ts
742
+ const api = createApi({
743
+ baseUrl: '/api',
744
+ requests: { getItems },
745
+ middleware: [retryMiddleware(2)]
746
+ })
747
+ ```
748
+
749
+ By default, only server errors (`status >= 500`) are retried. Client errors (4xx), 429, and network errors (`status: 0`) are not — see the opt-in recipes below.
750
+
751
+ **Retry policy**
752
+
753
+ Pass a `RetryOptions` object instead of a number for full control:
754
+
755
+ ```ts
756
+ const api = createApi({
757
+ baseUrl: '/api',
758
+ requests: { getItems },
759
+ middleware: [retryMiddleware({
760
+ max: 5,
761
+ delay: 'exponential',
762
+ baseDelay: 250,
763
+ maxDelay: 10_000,
764
+ jitter: true,
765
+ respectRetryAfter: true,
766
+ onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
767
+ })],
768
+ })
769
+ ```
770
+
771
+ | Option | Type | Default | Description |
772
+ | -------------------- | -------------------------------------------------- | ----------------- | ----------- |
773
+ | `max` | `number` | `3` | Additional attempts after the first. `retryMiddleware({ max: 2 })` means up to 3 total calls. |
774
+ | `delay` | `'exponential' \| 'linear' \| (attempt: number) => number` | `'exponential'` | The delay curve. Exponential is `baseDelay * 2^(attempt-1)`; linear is `baseDelay * attempt`; a function receives the 1-based attempt number and returns milliseconds. |
775
+ | `baseDelay` | `number` | `250` | The first delay, in milliseconds, before jitter and `Retry-After` are applied. |
776
+ | `maxDelay` | `number` | `30000` | Hard cap applied to every computed delay, including a `Retry-After` value. |
777
+ | `jitter` | `boolean` | `true` | Full jitter: the actual delay is `Math.random() * computed`, per AWS's recommendation for de-synchronizing a thundering herd. Never applied to a `Retry-After` value — a server telling you exactly when to come back should not be randomized. |
778
+ | `respectRetryAfter` | `boolean` | `true` | Honor a `Retry-After` response header (delta-seconds or an HTTP-date) when present, replacing the computed delay outright (still capped by `maxDelay`). |
779
+ | `retryOn` | `(result: Result<unknown>, attempt: number) => boolean` | `r => (r.error?.status ?? 0) >= 500` | Whether to retry. Called with the 1-based *candidate* attempt number, even once `max` is reached, so a predicate that counts attempts sees one call per result. |
780
+ | `onRetry` | `(info: RetryInfo) => void` | — | Observational hook fired before each retry's delay elapses. Its return value is ignored, and a throw cannot fail the request — this is the only way to observe an in-progress retry sequence, since the call site sees nothing until the final result. |
781
+
782
+ `RetryInfo` (the argument to `onRetry`): `{ attempt, max, delay, result }` — `attempt` is 1-based (the first retry is `1`), `delay` is the actual delay about to elapse (after jitter and `Retry-After`), and `result` is the `Result` that triggered this retry.
783
+
784
+ **429 and network-error opt-in.** Both are deliberately excluded from the default `retryOn` — retrying a rate limit or a network failure by default would change behavior under existing callers on upgrade. Opt in explicitly:
785
+
786
+ ```ts
787
+ // Retry 429 in addition to 5xx
788
+ retryMiddleware({
789
+ retryOn: r => r.error?.status === 429 || (r.error?.status ?? 0) >= 500
790
+ })
791
+
792
+ // Retry network errors (status 0) too — but not aborts, which are also status 0
793
+ retryMiddleware({
794
+ retryOn: r => (r.error?.status ?? 0) >= 500 || r.error?.kind === 'network'
795
+ })
796
+ ```
797
+
798
+ **An abort during backoff surfaces as the abort, not the stale result it was retrying.** If the signal driving the request — a whole-operation `timeout`, a caller's own `AbortSignal`, or a dedupe supersede — fires while `retryMiddleware` is sleeping between attempts, the backoff sleep resolves immediately and the loop proceeds straight to the next attempt, which the core fetch rejects instantly (no network call) because the signal is already aborted. The caller receives **that abort** — `kind: 'timeout'` for a deadline, `kind: 'abort'` for a cancellation or a dedupe supersede — never the last real HTTP result (e.g. a stale `503`) that triggered the retry in the first place:
799
+
800
+ ```ts
801
+ const api = createApi({
802
+ baseUrl: '/api',
803
+ requests: {
804
+ getItems: new Request<Record<string, never>, Item[]>({
805
+ method: 'GET',
806
+ path: '/items',
807
+ timeout: 2000, // whole-operation deadline
808
+ })
809
+ },
810
+ middleware: [retryMiddleware({ max: 5, baseDelay: 1000 })], // long backoff
811
+ })
812
+
813
+ const { error } = await api.getItems()
814
+ // If the 2s deadline fires while retryMiddleware is asleep between attempts:
815
+ // error.status === 0, error.kind === 'timeout' -- not the 503 being retried
816
+ ```
817
+
818
+ **`logMiddleware`**
819
+
820
+ Logs request start and completion to the console with timing:
821
+
822
+ ```
823
+ [liaise] → GET getItems /api/items
824
+ [liaise] ← getItems OK (142ms)
825
+
826
+ [liaise] → POST createUser /api/users
827
+ [liaise] ← createUser ERROR 422 (89ms)
828
+ ```
829
+
830
+ Intended for development. In production, write a custom middleware that sends telemetry to your observability platform.
831
+
832
+ ```ts
833
+ const api = createApi({
834
+ baseUrl: '/api',
835
+ requests: { getItems },
836
+ middleware: [logMiddleware]
837
+ })
838
+ ```
839
+
840
+ **`cacheMiddleware(options?)`**
841
+
842
+ Caches successful responses in memory, keyed by request name and params. Calls with identical params within the TTL window are served from cache without hitting the network. Each `cacheMiddleware()` call creates an isolated store — different endpoints never share entries.
843
+
844
+ Params are keyed by content, at every depth: plain data as sorted JSON with `undefined` members dropped (so `{ a: undefined }` and `{}` are one key), anything with `toJSON` by what it returns (a `Date` is its ISO string), and `Map`, `Set` and typed arrays by their entries. A call whose params cannot be keyed soundly — a BigInt, an `ArrayBuffer`, `Blob`, `FormData` or `URLSearchParams`, or an object with no enumerable state such as a class instance holding private fields — is never cached and never served from cache. The rule is the same one `share` uses; see [Sharing](#sharing).
845
+
846
+ ```ts
847
+ const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
848
+
849
+ const getUser = new Request<{ id: string }, User>({
850
+ method: 'GET',
851
+ path: '/users/:id',
852
+ middleware: [getUserCache],
853
+ })
854
+
855
+ // On logout — clear all cached entries:
856
+ getUserCache.clear()
857
+
858
+ // Bypass cache for a single call:
859
+ const { data } = await api.getUser({ id: '42' }, { skipMiddleware: [getUserCache] })
860
+ ```
861
+
862
+ Options: `ttl` (milliseconds, default 5 min), `maxSize` (max entries, default 50), `debug` (log hits/misses to console, default false). Only successful results are cached — errors always hit the network again.
863
+
864
+ ### Content types
865
+
866
+ Request bodies are automatically serialized based on the input type. The `Content-Type` header is set for you unless you explicitly provide one.
867
+
868
+ | Input type | Body output | Content-Type |
869
+ | ----------------- | ------------------ | ------------------------------------- |
870
+ | `null`/`undefined` | `null` | _(none)_ |
871
+ | `string` | as-is | `text/plain` |
872
+ | `FormData` | as-is | _(browser sets multipart boundary)_ |
873
+ | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
874
+ | `Blob` | as-is | `application/octet-stream` |
875
+ | `ArrayBuffer` | as-is | `application/octet-stream` |
876
+ | Plain object | `JSON.stringify()` | `application/json` |
877
+
878
+ Header merge precedence (most specific wins):
879
+
880
+ 1. **Global headers** (from `createApi` config) -- lowest priority
881
+ 2. **Per-request headers** (from `Request` config) -- overrides global
882
+ 3. **Per-call headers** (from `CallOptions`) -- highest priority
883
+
884
+ Explicitly set `Content-Type` headers at any level override the auto-detected value.
885
+
886
+ ### Response parsing
887
+
888
+ The `responseType` option on a `Request` determines how the response body is parsed:
889
+
890
+ | `responseType` | Method called | Return type |
891
+ | --------------- | ---------------------- | -------------- |
892
+ | `'json'` | `response.text()` then `JSON.parse()` | parsed object |
893
+ | `'text'` | `response.text()` | `string` |
894
+ | `'blob'` | `response.blob()` | `Blob` |
895
+ | `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
896
+ | `'formData'` | `response.formData()` | `FormData` |
897
+ | `'none'` | *(not read -- stream cancelled)* | `undefined` |
898
+
899
+ The default is `'json'`. An **empty body under `'json'` is an error**, not a
900
+ `null`: you declared JSON and the server sent none, so there is no value that
901
+ could honestly satisfy `TResponse`. You get `kind: 'parse'` with the
902
+ response's own status (a `204` reports `204`), a non-null `response`, and the
903
+ raw body text -- always `''` for this case -- in `error.body`:
904
+
905
+ ```ts
906
+ const { data, error } = await api.deleteUser({ id: '42' })
907
+ // 204 No Content, responseType left at the 'json' default:
908
+ // error.kind === 'parse', error.status === 204, data === null
909
+ ```
910
+
911
+ A literal `null` body is **not** empty -- `JSON.parse("null")` is valid JSON,
912
+ and that response still succeeds with `data: null`.
913
+
914
+ **`responseType: 'none'`** is the declaration for an endpoint that returns no
915
+ body on success -- a `204`, or a `200` with an empty body, most commonly a
916
+ `DELETE`:
917
+
918
+ ```ts
919
+ const deleteUser = new Request<{ id: string }, undefined>({
920
+ method: 'DELETE',
921
+ path: '/users/:id',
922
+ responseType: 'none',
923
+ })
924
+ ```
925
+
926
+ No body is read on a successful (2xx) response: `data` is `undefined`, and any body the server sends anyway is discarded -- its stream is cancelled, so a keep-alive connection is released rather than held open by an unread body. Declare `TResponse` as `undefined` when using `responseType: 'none'` -- but this is a convention, not a compile-time guarantee: `new Request<{ id: string }, User>({ responseType: 'none' })` compiles clean, and if the two disagree, `data` is `undefined` at runtime behind whatever type you declared.
927
+
928
+ `'none'` only describes the **success** shape. A non-2xx response is still read and parsed as JSON for `error.body` -- an error body is diagnostic (a message, a code) and worth reading even when the caller wants nothing back on success:
929
+
930
+ ```ts
931
+ const { error } = await api.deleteUser({ id: '42' })
932
+ if (error) {
933
+ // A 409 { "error": "already deleted" } still lands in error.body here,
934
+ // even though deleteUser declares responseType: 'none'.
935
+ console.error(error.status, error.body)
936
+ }
937
+ ```
938
+
939
+ (3.0.0's advice for this case was to widen the endpoint's `TResponse` to
940
+ `| null`. That advice is superseded: `responseType: 'none'` declares "no body"
941
+ rather than "body or null", and since 4.0.0 the `| null` workaround no longer
942
+ works at all -- the empty body is an error before `TResponse` is ever
943
+ consulted. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400).)
944
+
945
+ ### Cancellation
946
+
947
+ #### Manual abort via `AbortSignal`
948
+
949
+ Pass an `AbortSignal` through `CallOptions` to cancel a request:
950
+
951
+ ```ts
952
+ const controller = new AbortController()
953
+
954
+ const promise = api.getItems({ page: 1 }, {
955
+ signal: controller.signal
956
+ })
957
+
958
+ // Cancel the request
959
+ controller.abort()
960
+
961
+ const { error } = await promise
962
+ // error.status === 0, error.kind === 'abort', error.body is a DOMException with name 'AbortError'
963
+ ```
964
+
965
+ A cancellation you caused yourself is not reported to `onError` (`kind: 'abort'` is the one kind that's suppressed there) -- see [Error handling with `onError`](#error-handling-with-onerror). It is classified by **provenance**, not by sniffing the thrown value's shape: whatever a middleware or `fetch` actually throws, if it happened because *this request's own signal* aborted, the `Result` is `kind: 'abort'` (or `'timeout'` for a deadline) regardless of the reason's name or type -- a caller-supplied custom abort reason (`controller.abort(new Error('unmounted'))`, or a plain string) still classifies as `'abort'`, not `'network'`.
966
+
967
+ Aborting settles the call even while a middleware is still awaiting work of its own that ignores the signal -- the same backstop that bounds `timeout`, described under [Timeout](#timeout).
968
+
969
+ #### Auto-cancel via `dedupe`
970
+
971
+ When a `Request` has `dedupe: true`, each new call automatically aborts the previous in-flight call for that endpoint. Identity is per `Request` instance -- different endpoints do not interfere with each other.
972
+
973
+ ```ts
974
+ const searchUsers = new Request<{ q: string }, User[]>({
975
+ method: 'GET',
976
+ path: '/users/search',
977
+ dedupe: true
978
+ })
979
+
980
+ const api = createApi({
981
+ baseUrl: '/api',
982
+ requests: { searchUsers }
983
+ })
984
+
985
+ // Rapid calls -- only the last one completes
986
+ api.searchUsers({ q: 'h' }) // aborted by next call
987
+ api.searchUsers({ q: 'he' }) // aborted by next call
988
+ api.searchUsers({ q: 'hel' }) // this one completes
989
+ ```
990
+
991
+ Dedupe and manual abort signals work together. If both are active, the request is cancelled if either fires.
992
+
993
+ ### Timeout
994
+
995
+ Set `timeout` (milliseconds) on a `Request` or per-call to abort a request that takes too long:
996
+
997
+ ```ts
998
+ const getUser = new Request<{ id: string }, User>({
999
+ method: 'GET',
1000
+ path: '/users/:id',
1001
+ timeout: 5000
1002
+ })
1003
+
1004
+ const { error } = await api.getUser({ id: '42' })
1005
+ // error.status === 0, error.kind === 'timeout' if it fired
1006
+ ```
1007
+
1008
+ **`timeout` is a whole-operation deadline, not a per-attempt budget.** It covers the entire middleware chain, including every retry and every backoff delay. `timeout: 5000` combined with `retryMiddleware(3)` still means "an answer within 5 seconds" for the call as a whole — not five seconds for each individual attempt. This is a deliberate choice, and it **differs from axios, XHR, and `got`**, all of which apply a timeout per attempt and therefore let a retrying request run for a multiple of the configured timeout. Know which behavior you're assuming before you tune the number.
1009
+
1010
+ If you want a per-attempt budget instead — the axios-style behavior — write a small signal-replacing middleware and place it *inside* the retry middleware, so a fresh signal is installed on every attempt:
1011
+
1012
+ ```ts
1013
+ const perAttempt = (ms: number): Middleware => async (ctx, next) => {
1014
+ ctx.request.signal = AbortSignal.timeout(ms)
1015
+ return next()
1016
+ }
1017
+
1018
+ const api = createApi({
1019
+ baseUrl: '/api',
1020
+ requests: { getUser },
1021
+ middleware: [retryMiddleware(3), perAttempt(5000)]
1022
+ })
1023
+ ```
1024
+
1025
+ Because middleware order is outermost-to-innermost, `retryMiddleware(3)` re-invokes everything below it — including `perAttempt(5000)` — on every retry, so each attempt gets its own fresh 5-second budget instead of sharing one.
1026
+
1027
+ A few more details:
1028
+
1029
+ - `CallOptions.timeout` overrides `RequestConfig.timeout` for a single call; a per-call `timeout: 0` disables a per-request timeout rather than falling back to it.
1030
+ - A timeout produces an error with `status: 0` and `kind: 'timeout'` — distinguishable from a caller-initiated cancellation (`kind: 'abort'`) and from a genuine network failure (`kind: 'network'`).
1031
+ - `result.retry()` always starts a fresh deadline. A retried call is not charged against the original budget.
1032
+ - Non-positive or omitted `timeout` disables it entirely (the default).
1033
+ - `timeout` composes with `dedupe: true` — the deadline is merged with the dedupe signal rather than discarded by it.
1034
+ - Under `share: true` the two timeouts have different owners. `RequestConfig.timeout` belongs to the *operation*: it bounds the one shared request for every caller, measured from when that request started, so a single caller can neither extend it nor disable it with a per-call `timeout: 0`. `CallOptions.timeout` bounds only the caller that passed it — see [Sharing](#sharing).
1035
+
1036
+ **The deadline bounds middleware that never looks at the signal, too.** A middleware that awaits something of its own before calling `next()` — a token refresh, say — cannot hold the call past its `timeout`, even if that work never settles:
1037
+
1038
+ ```ts
1039
+ const auth: Middleware = async (ctx, next) => {
1040
+ const token = await user.getIdToken() // stalls on a bad network
1041
+ ctx.request.headers.set('Authorization', `Bearer ${token}`)
1042
+ return next()
1043
+ }
1044
+ // With timeout: 45_000, the call still settles at ~45s: kind 'timeout', status 0.
1045
+ ```
1046
+
1047
+ When the deadline passes, the chain gets one macrotask to answer by itself. That is enough for everything that already responds to the abort — `fetch` rejecting, a middleware rethrowing the reason, a fallback middleware that turns a timeout into a cached response — so all of those keep their own `Result` exactly as before. A chain still pending after that is waiting on something the signal does not reach, and the call settles with the same `Result` an aborted `fetch` would have produced: `kind: 'timeout'`, `status: 0`, reported to `onError` once. A caller's own `signal` works the same way, with `kind: 'abort'`, which is not reported.
1048
+
1049
+ A promise cannot be cancelled, so the stalled middleware keeps running. Whatever it eventually returns or throws is discarded — no second `Result`, no second `onError` — and if it calls `next()` after the call has settled, no request is sent: `next()` hands back the `Result` the caller already has. To stop the work itself, pass `ctx.request.signal` into it (see [`MiddlewareContext`](#middlewarecontext)).
1050
+
1051
+ ### Sharing
1052
+
1053
+ Set `share: true` on a `Request` to coalesce identical concurrent calls onto a single in-flight request, instead of each caller firing its own:
1054
+
1055
+ ```ts
1056
+ const getProduct = new Request<{ id: string }, Product>({
1057
+ method: 'GET',
1058
+ path: '/products/:id',
1059
+ share: true
1060
+ })
1061
+
1062
+ // Only one network request is made; both callers get the same response
1063
+ const [a, b] = await Promise.all([
1064
+ api.getProduct({ id: '42' }),
1065
+ api.getProduct({ id: '42' })
1066
+ ])
1067
+ ```
1068
+
1069
+ `share` is the sibling of `dedupe`, with the opposite intent: **dedupe cancels** the older call in favor of the newer one, **share joins** the existing call instead of starting a new one. Because the two behaviors contradict each other, setting both on the same `Request` throws at `createApi(...)` time — not at call time — so the mistake surfaces immediately rather than the first time the endpoint is called.
1070
+
1071
+ **What counts as "identical":** the request name plus a content-based key of the params — object keys sorted, `undefined` members dropped (so `{ a: undefined }` and `{}` are one key), anything with `toJSON` keyed by what it returns (a `Date` is its ISO string), `Map`, `Set` and typed arrays keyed by their entries. Two calls with the same params to the same endpoint share; different params (or different endpoints) never do.
1072
+
1073
+ **What disables sharing for a single call:**
1074
+
1075
+ - A per-call `headers` or `middleware` — these change *what* is requested, so handing that caller another caller's response would be a real bug, not just a missed optimization. A call carrying either always gets its own, unshared request.
1076
+ - Params that cannot be keyed soundly, at any depth: a BigInt, an `ArrayBuffer`, `Blob`, `FormData` or `URLSearchParams`, a circular structure, or an object with no enumerable state (a class instance keeping its state in private fields, an `Error`). Two different values of these kinds would otherwise risk one key, and one caller could receive the response meant for the other's payload. Declining to share is always safe; handing back the wrong response never is. A `Date`, `Map`, `Set` or typed array is keyed by its content and shares normally, and a raw `string` keys distinguishably, so a string-param endpoint is coalesced like any other.
1077
+
1078
+ **What does *not* disable sharing:** a per-call `signal` or `timeout`. These bound *who is still waiting*, not *what is being asked for*, so they're tracked with a per-caller refcount instead: each sharer's own signal/timeout only removes that caller from the wait list. The underlying request keeps running for everyone else, and is only aborted once every sharer — including the one that gave up — has stopped waiting. A sharer that gives up gets an error `Result` (`kind: 'timeout'` or `kind: 'abort'`), reported to `onError` exactly as the identical non-shared call would be — which means a `'timeout'` give-up reports and an `'abort'` give-up does not (see [Error handling with `onError`](#error-handling-with-onerror)).
1079
+
1080
+ **A per-*request* `timeout` is different: it belongs to the operation.** `RequestConfig.timeout` bounds the single shared request itself, measured from when that request started — not from when each caller joined it. Every sharer is therefore bounded by it, a late joiner cannot extend it, and a caller passing `timeout: 0` cannot switch it off for everyone else. Without that, a steadily arriving stream of joiners would keep one socket open indefinitely against a deadline that was supposed to cap it.
1081
+
1082
+ ```ts
1083
+ const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
1084
+ const patient = api.getProduct({ id: '42' }) // keeps waiting
1085
+
1086
+ // impatient's early timeout does not cancel the shared request —
1087
+ // patient still gets a real response.
1088
+ ```
1089
+
1090
+ **`result.retry()` on a shared result** re-runs the pipeline using the *acquiring caller's* own per-call options (headers, signal, timeout) — that is, whichever call first started the shared request, not whichever caller happens to invoke `retry()`. This falls out of every non-aborting sharer receiving the literal same `Result` object; it's unavoidable given that design, but worth knowing before relying on it.
1091
+
1092
+ **Signal-replacing middleware is safe under `share: true`.** A middleware that installs its own `ctx.request.signal` (a per-attempt timeout, say) does not detach the shared request from the refcount: the refcount signal is merged back in before `fetch`, so the request is still aborted once every sharer has given up.
1093
+
1094
+ ### TypeScript
1095
+
1096
+ Type inference flows automatically from `Request` generics through `createApi` to the call site. You never annotate the API methods manually.
1097
+
1098
+ ```ts
1099
+ // 1. Types are declared on the Request
1100
+ const getUser = new Request<{ id: string }, User>({
1101
+ method: 'GET',
1102
+ path: '/users/:id'
1103
+ })
1104
+
1105
+ // 2. createApi infers method signatures from the requests record
1106
+ const api = createApi({
1107
+ baseUrl: '/api',
1108
+ requests: { getUser }
1109
+ })
1110
+
1111
+ // 3. Call site is fully typed -- no annotations needed
1112
+ const { data, error } = await api.getUser({ id: '42' })
1113
+ // ^? User | null
1114
+ ```
1115
+
1116
+ The inference chain works like this:
1117
+
1118
+ - `Request<TParams, TResponse>` carries the type info.
1119
+ - `createApi` uses internal conditional types to pull the `TParams` and `TResponse` generics from each `Request` instance.
1120
+ - A mapped type transforms the requests record into callable methods: each key becomes `(params: TParams, options?: CallOptions) => Promise<Result<TResponse>>`.
1121
+ - When `TParams` is `Record<string, never>` (no params), the params argument becomes optional.
1122
+
1123
+ All exported types are available for annotation when needed:
1124
+
1125
+ ```ts
1126
+ import type {
1127
+ Result,
1128
+ CallOptions,
1129
+ Middleware,
1130
+ MiddlewareContext,
1131
+ MiddlewareNext,
1132
+ RequestConfig,
1133
+ ApiConfig
1134
+ } from 'liaise'
1135
+ ```
1136
+
1137
+ ## GraphQL Client
1138
+
1139
+ Use `createGraphQL` when your backend speaks GraphQL. **Everything in the REST API section applies here too** — `retryMiddleware`, `cacheMiddleware`, `logMiddleware`, `dedupe`, per-call `signal`, `onError`, `retry()`, `skipMiddleware`, header merging — all of it works identically for GraphQL operations. The only difference is transport: every operation is sent as an HTTP POST with `{ query, variables }`.
1140
+
1141
+ Both clients return the same `Result<T>` shape — `result.data`, `result.error`, `result.response`, and `result.retry` work identically.
1142
+
1143
+ ```ts
1144
+ import { createGraphQL, Operation, gql } from 'liaise'
1145
+
1146
+ interface Category {
1147
+ id: string
1148
+ name: string
1149
+ status: string
1150
+ }
1151
+
1152
+ const GET_CATEGORY = gql`
1153
+ query GetCategory($id: String!) {
1154
+ category(id: $id) {
1155
+ id
1156
+ name
1157
+ status
1158
+ }
1159
+ }
1160
+ `
1161
+
1162
+ const getCategory = new Operation<{ id: string }, Category>({
1163
+ operation: GET_CATEGORY,
1164
+ })
1165
+
1166
+ const graphql = createGraphQL({
1167
+ endpoint: 'https://api.example.com/graphql',
1168
+ operations: { getCategory },
1169
+ onError: (error) => console.error(error.status, error.body),
1170
+ })
1171
+
1172
+ const { data, error, response, retry } = await graphql.getCategory({ id: '123' })
1173
+ ```
1174
+
1175
+ Operations with no variables can be called without arguments. Use `Record<string, never>` as `TVariables` to mark an operation as variable-free:
1176
+
1177
+ ```ts
1178
+ const getViewer = new Operation<Record<string, never>, ViewerData>({ operation: GET_VIEWER })
1179
+ const graphql = createGraphQL({ endpoint, operations: { getViewer } })
1180
+
1181
+ const { data } = await graphql.getViewer() // params argument is optional
1182
+ ```
1183
+
1184
+ ### Queries and mutations
1185
+
1186
+ When you want to distinguish queries from mutations in the client structure, use the `queries` and `mutations` keys instead of `operations`. The `operations` flat shape and the `queries`/`mutations` split are mutually exclusive — TypeScript enforces this at compile time.
1187
+
1188
+ ```ts
1189
+ const graphql = createGraphQL({
1190
+ endpoint: 'https://api.example.com/graphql',
1191
+ queries: {
1192
+ getCategory: new Operation<{ id: string }, Category>({ operation: GET_CATEGORY }),
1193
+ },
1194
+ mutations: {
1195
+ updateCategory: new Operation<{ id: string; name: string }, Category>({
1196
+ operation: gql`
1197
+ mutation UpdateCategory($id: String!, $name: String!) {
1198
+ updateCategory(id: $id, name: $name) { id name status }
1199
+ }
1200
+ `,
1201
+ }),
1202
+ },
1203
+ })
1204
+
1205
+ graphql.query.getCategory({ id: '123' })
1206
+ graphql.mutation.updateCategory({ id: '123', name: 'New Name' })
1207
+ ```
1208
+
1209
+ ### GraphQL errors
1210
+
1211
+ GraphQL errors (any 2xx with `{ errors: [...] }`) surface as `result.error` with the response's own `status` and `error.body` typed as `GraphQLError[]` — no special handling needed. The same `if (error) { ... }` check covers GraphQL errors, HTTP errors, and network errors uniformly.
1212
+
1213
+ GraphQL allows **partial success** -- a nullable field errors while the rest of the query resolves. That data is not discarded: it's available as `error.partialData`, never on `result.data` (which stays `null` whenever `error` is non-null, keeping `Result` a clean discriminated union):
1214
+
1215
+ ```ts
1216
+ const { error } = await graphql.getCategory({ id: '123' })
1217
+ if (error) {
1218
+ console.log(error.body) // GraphQLError[]
1219
+ console.log(error.partialData) // whatever `data` the server sent alongside the errors, or undefined
1220
+ }
1221
+ ```
1222
+
1223
+ GraphQL's own empty-success rule mirrors the REST client's: a 2xx response carrying neither `data` nor `errors` is `kind: 'parse'`, not a success with `data: null`. This covers an empty body, `{}`, a literal `{"data": null}`, and a non-object JSON root -- anything that reaches a 2xx without a `data` or `errors` key. `error.body` holds the raw response text, not a parsed value:
1224
+
1225
+ ```ts
1226
+ const { error } = await graphql.getCategory({ id: '123' })
1227
+ if (error) {
1228
+ console.log(error.kind) // 'parse'
1229
+ console.log(error.body) // raw response text, e.g. '' or '{}'
1230
+ }
1231
+ ```
1232
+
1233
+ A `{"data": null, "errors": [...]}` response is unchanged -- it's still `kind: 'http'`, with any partial result in `error.partialData`, since the GraphQL-errors branch runs first. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400).
1234
+
1235
+ Operations support `dedupe: true` in the same way `Request` does — see [Auto-cancel via `dedupe`](#auto-cancel-via-dedupe).
1236
+
1237
+ ### Middleware
1238
+
1239
+ `createGraphQL` accepts the same middleware options as `createApi` — global, per-operation, and per-call — and the `MiddlewareContext` shape is identical, so middleware written for `createApi` works here too.
1240
+
1241
+ ```ts
1242
+ const graphql = createGraphQL({
1243
+ endpoint: 'https://api.example.com/graphql',
1244
+ operations: { getCategory },
1245
+ middleware: [authMiddleware],
1246
+ })
1247
+ ```
1248
+
1249
+ ### API Reference additions
1250
+
1251
+ | Export | Kind | Description |
1252
+ | ------------------- | -------- | ---------------------------------------------------------------------- |
1253
+ | `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
1254
+ | `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
1255
+ | `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
1256
+ | `OperationConfig` | type | Config object for the `Operation` constructor |
1257
+ | `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
1258
+ | `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
1259
+
1260
+ ## Testing
1261
+
1262
+ `liaise/testing` is a separate, framework-agnostic entry point for testing consumers of this library — it has no test-runner dependency, so it works the same under Vitest, Jest, or anything else. It gives you a `fetch` stub with route matching, so your tests exercise the real pipeline — URL building, path substitution, header merging, body serialization, response parsing, your own middleware — rather than stubbing an API method to return a canned `Result` and silently drifting out of sync with what the library actually does.
1263
+
1264
+ ```ts
1265
+ import { mockFetch, jsonResponse } from 'liaise/testing'
1266
+
1267
+ const mock = mockFetch({
1268
+ 'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
1269
+ 'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
1270
+ })
1271
+
1272
+ mock.install() // replaces globalThis.fetch
1273
+ // ... exercise your code, which calls the real api.getUser(...) ...
1274
+ mock.restore() // puts the original globalThis.fetch back
1275
+ ```
1276
+
1277
+ Routes are keyed as `"METHOD /path"`, with `:token` segments captured and handed to a route function as `{ params, request }`. A route value can also be a plain `Response` (built with the `jsonResponse` helper, or your own), or an array of either — the array is consumed one response per matching call, and the final entry repeats once exhausted (handy for "fail twice, then succeed").
1278
+
1279
+ ```ts
1280
+ const mock = mockFetch({
1281
+ 'GET /api/flaky': [jsonResponse(null, { status: 503 }), jsonResponse({ ok: true })],
1282
+ })
1283
+ ```
1284
+
1285
+ `mock.calls` records every request (`{ method, url, headers, body }`); `mock.callCount('GET /api/users/:id')` and `mock.lastCall(...)` key off the same `"METHOD /path"` strings as the routes object.
1286
+
1287
+ An unmatched request makes the stub throw rather than invent a 404 — a mocked test should not quietly pass for a typo'd path. Note what your code actually sees, though: the library catches every `fetch` rejection by design, so that throw arrives as an ordinary `Result` with `error.kind === 'network'` and the `Error` itself as `error.body`, whose message names the method, the URL, and every route that was defined. Assert on the result (or read it in `onError`); do not expect the call to reject.
1288
+
1289
+ ```ts
1290
+ const r = await api.getUser({ id: '42' }) // routes only define 'GET /api/user/:id'
1291
+ expect(r.error?.kind).toBe('network')
1292
+ expect(String(r.error?.body)).toMatch(/no route matched GET \/api\/users\/42/)
1293
+ ```
1294
+
1295
+ An empty response array for a route behaves the same way — a descriptive `Error` reaching you as `error.body`, not a bare `TypeError`.
1296
+
1297
+ For stubbing at the `Result` level instead of the `fetch` level, `successResult(data)` and `errorResult(status, body)` build a well-formed `Result` directly (shown here with Vitest's `vi.spyOn`, but any runner's equivalent works the same way):
1298
+
1299
+ ```ts
1300
+ import { successResult, errorResult } from 'liaise/testing'
1301
+
1302
+ vi.spyOn(api, 'getUser').mockResolvedValue(successResult({ id: '42', name: 'Ada' }))
1303
+ vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
1304
+ ```
1305
+
1306
+ A few behaviors worth knowing:
1307
+
1308
+ - **`mock.fetch` honours `init.signal`, like real `fetch`.** An already-aborted signal rejects with its `reason`, and so does one that aborts while a route handler is still pending — so a stalled route (`() => new Promise(() => {})`) lets you test your own `timeout` and cancellation handling through the stub. An aborted call is still recorded in `calls` and counted by `callCount`, but does not use up a response from a sequence.
1309
+ - **`restore()` assumes `globalThis.fetch` was defined when `install()` ran** — true on Node 20+ (and in every browser), since `fetch` is a global there. If you somehow call `install()` in an environment where `globalThis.fetch` is `undefined` beforehand, `restore()` puts back that `undefined` rather than inventing a real `fetch`.
1310
+ - **A route key must be `"METHOD /path"`.** A key with no space (`'/users'`) throws at `mockFetch(...)` time, naming the offending key, rather than silently registering a route that can never match.
1311
+ - **Declaration order decides when two same-length routes could both match.** Routes are matched in the order they appear in the object you pass to `mockFetch`, and the first structural match wins — put more specific routes first if two patterns could both match the same path.
1312
+ - **Trailing and duplicate slashes are normalized away on both sides.** `/a/b/`, `/a//b`, and `/a/b` all match the same route, whether the extra slash is in the route key or in the URL the library actually built.
1313
+
1314
+ ## Philosophy
1315
+
1316
+ ### Never throws
1317
+
1318
+ Every API call returns a `Result<T>`. HTTP errors, network failures, parse errors, and even synchronous exceptions during request setup are all captured and returned as structured `{ data, error, response, retry }` objects. No try/catch required at call sites.
1319
+
1320
+ ### Zero dependencies
1321
+
1322
+ The library uses only the standard `fetch` API and built-in web platform types (`Headers`, `AbortController`, `FormData`, `URLSearchParams`, `Blob`, `ArrayBuffer`). There is nothing to install, audit, or bundle beyond the library itself.
1323
+
1324
+ ### Middleware over interceptors
1325
+
1326
+ Instead of separate `onRequest`/`onResponse` interceptor hooks, the library uses a composable onion model where each middleware wraps the next. This means a single function can modify the request, inspect the response, retry on failure, or short-circuit entirely. Three layers (global, per-request, per-call) plus `skipMiddleware` give fine-grained control without configuration complexity.
1327
+
1328
+ ### Typed dot-access
1329
+
1330
+ Type safety comes from inference, not annotation. Define `Request<TParams, TResponse>` once, and `createApi` infers everything downstream. The call site (`api.getUser(...)`) is fully typed with zero extra work.
1331
+
1332
+ ### Runtime-agnostic
1333
+
1334
+ No assumptions about Node.js, browsers, or any specific runtime. If your environment has `fetch`, the library works -- browsers, Node.js 20+, Bun, Deno, React Native, Cloudflare Workers, edge runtimes.
1335
+
1336
+ ### Framework-agnostic
1337
+
1338
+ The library has no opinion about your UI framework, or whether you have one. A client is a plain object of functions that return promises, so the same definitions work in React, Vue, Svelte, Solid or Angular, in server loaders and actions, in workers, scripts and CLIs — and keep working when you change frameworks. It also means there are no hooks out of the box: pair it with the state or query library you already use (TanStack Query, SWR, Pinia, a store of your own). Those libraries expect a failed request to throw, so the query function is the place to turn a `Result`'s `error` into a throw — at the edge of your code, not inside this library.
1339
+
1340
+ ## API Reference
1341
+
1342
+ ### Core (`liaise`)
1343
+
1344
+ | Export | Kind | Description |
1345
+ | --------------- | -------- | ------------------------------------------------------------------ |
1346
+ | `createApi` | function | Creates a typed API client from a config of Request definitions |
1347
+ | `Request` | class | Typed endpoint definition -- one instance per endpoint |
1348
+ | `defineRequest` | function | Typed factory — infers path params from the `path` literal, and enforces `responseType: 'none'`. |
1349
+ | `paginate` | function | Walks a paginated endpoint, yielding one `Result` per page. |
1350
+ | `PaginateOptions` | type | `next`, `maxPages`, and any `CallOptions`. |
1351
+ | `StandardSchemaV1` | type | The Standard Schema contract — for typing a helper that takes a validator. |
1352
+ | `InferOutput` | type | The type a schema produces on success. |
1353
+ | `StandardIssue` | type | One validation failure -- the shape of each entry in `error.body` when a schema refuses. |
1354
+ | `ApiError` | class | Structured error with status, kind, body, headers, and request metadata |
1355
+ | `ApiErrorKind` | type | `'http' \| 'network' \| 'abort' \| 'timeout' \| 'parse' \| 'middleware'` -- discriminates `ApiError.kind` |
1356
+ | `RequestConfig` | type | Config object for the `Request` constructor |
1357
+ | `ApiConfig` | type | Config object for `createApi` |
1358
+ | `CallOptions` | type | Per-call overrides (`middleware`, `skipMiddleware`, `headers`, `signal`, `timeout`) |
1359
+ | `Result` | type | Discriminated union of every API call's outcome: `SuccessResult<T> \| ErrorResult<T>` |
1360
+ | `SuccessResult` | type | The success branch of `Result`: `{ data: T, error: null, response: Response, retry }` |
1361
+ | `ErrorResult` | type | The error branch of `Result`: `{ data: null, error: ApiError, response: Response \| null, retry }` |
1362
+ | `Middleware` | type | Middleware function signature: `(ctx, next) => Promise<Result>` |
1363
+ | `MiddlewareContext` | type | Request context passed to middleware |
1364
+ | `MiddlewareNext` | type | The `next` function passed to middleware |
1365
+ | `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
1366
+ | `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
1367
+ | `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
1368
+ | `OperationConfig` | type | Config object for the `Operation` constructor |
1369
+ | `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
1370
+ | `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
1371
+
1372
+ ### Built-in middleware (`liaise/middleware`)
1373
+
1374
+ | Export | Kind | Description |
1375
+ | ----------------- | -------- | ------------------------------------------------------------- |
1376
+ | `retryMiddleware` | function | Factory that returns middleware retrying on 5xx by default, with a configurable backoff policy |
1377
+ | `RetryOptions` | type | Options object accepted by `retryMiddleware` (`max`, `delay`, `baseDelay`, `maxDelay`, `jitter`, `respectRetryAfter`, `retryOn`, `onRetry`) |
1378
+ | `RetryInfo` | type | Shape of the argument passed to `RetryOptions.onRetry` |
1379
+ | `logMiddleware` | const | Middleware that logs request lifecycle to the console |
1380
+ | `cacheMiddleware` | function | Factory that returns a per-request in-memory cache with `clear()` |
1381
+ | `CacheMiddleware` | type | Return type of `cacheMiddleware()` -- a `Middleware` with an attached `clear()` |
1382
+
1383
+ ### Testing (`liaise/testing`)
1384
+
1385
+ | Export | Kind | Description |
1386
+ | --------------- | -------- | ------------------------------------------------------------------ |
1387
+ | `mockFetch` | function | Builds a route-matching `fetch` stub, with `install()`/`restore()`, call recording, and response sequencing |
1388
+ | `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header, for use as a route value |
1389
+ | `successResult` | function | Builds a well-formed success `Result<T>` directly, for stubbing at the `Result` level |
1390
+ | `errorResult` | function | Builds a well-formed error `Result<T>` with a given HTTP status, for stubbing at the `Result` level |
1391
+ | `RouteContext` | type | `{ params, request }` passed to a route handler function |
1392
+ | `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>` -- a route value that computes its response |
1393
+ | `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
1394
+ | `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
1395
+
1396
+ ## License
1397
+
1398
+ MIT