liaise 5.0.1 → 5.0.3

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 CHANGED
@@ -2,271 +2,539 @@
2
2
 
3
3
  *lee-AYZ* — to act as the link between two parties.
4
4
 
5
- > Formerly published as `@iremlopsum/apify`. Same code, same API, full history — switching takes two steps, see [MIGRATION.md](./MIGRATION.md#upgrading-to-500).
5
+ **Your API calls, minus the surprises.**
6
6
 
7
- Runtime-agnostic, type-safe HTTP client for REST and GraphQL. Built on standard `fetch`. Zero dependencies.
7
+ Type-safe REST and GraphQL on plain fetch. Never throws. Zero dependencies. Works with any framework.
8
8
 
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 (its built-in `fetch`; not tested in CI) — any environment with `fetch`
14
- - **Tiny** — about **5.6 kB gzipped** for a REST-only import, 6.7 kB for the core entry, 7.8 kB with all middleware (measured by `npm run size`); tree-shaking drops what you do not import
9
+ [![npm](https://img.shields.io/npm/v/liaise)](https://www.npmjs.com/package/liaise) [![CI](https://github.com/iremlopsum/liaise/actions/workflows/ci.yml/badge.svg)](https://github.com/iremlopsum/liaise/actions/workflows/ci.yml) ![5.8 kB gzipped](https://img.shields.io/badge/gzipped-5.8%20kB-blue) ![MIT](https://img.shields.io/badge/license-MIT-blue)
15
10
 
16
- ```
11
+ ```bash
17
12
  npm install liaise
18
13
  ```
19
14
 
20
- ## Table of Contents
15
+ Formerly published as `@iremlopsum/apify`; switching takes two steps, see [MIGRATION.md](./MIGRATION.md#upgrading-to-500).
16
+
17
+ **Contents**
21
18
 
22
- - [Getting Started](#getting-started)
23
- - [REST API](#rest-api)
24
- - [Request](#request)
25
- - [`defineRequest`](#definerequest)
26
- - [Response validation](#response-validation)
19
+ - [The problem it solves](#the-problem-it-solves)
20
+ - [Quick start](#quick-start)
21
+ - [How it fits together](#how-it-fits-together)
22
+ - [Guide](#guide)
23
+ - [Defining endpoints](#defining-endpoints)
24
+ - [Handling errors](#handling-errors)
25
+ - [Sending data](#sending-data)
26
+ - [Reading responses](#reading-responses)
27
+ - [Validating responses](#validating-responses)
28
+ - [Cancelling, deadlines and stale requests](#cancelling-deadlines-and-stale-requests)
29
+ - [Sharing identical requests](#sharing-identical-requests)
30
+ - [Retries, caching and logging](#retries-caching-and-logging)
31
+ - [Writing middleware](#writing-middleware)
27
32
  - [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
- - [Contributing](#contributing)
47
-
48
- ## Getting Started
49
-
50
- Define your endpoints as `Request` instances, wire them into a client with `createApi`, and call them with full type safety.
33
+ - [GraphQL](#graphql)
34
+ - [Testing your code](#testing-your-code)
35
+ - [Recipes](#recipes)
36
+ - [Add an auth header and refresh the token on a 401](#add-an-auth-header-and-refresh-the-token-on-a-401)
37
+ - [Search as you type](#search-as-you-type)
38
+ - [Use with TanStack Query](#use-with-tanstack-query)
39
+ - [Use with React](#use-with-react)
40
+ - [Load the current user into a store](#load-the-current-user-into-a-store)
41
+ - [One /me per page view on the server](#one-me-per-page-view-on-the-server)
42
+ - [Retry a flaky backend within one deadline](#retry-a-flaky-backend-within-one-deadline)
43
+ - [Give each attempt its own timeout](#give-each-attempt-its-own-timeout)
44
+ - [Report errors to Sentry](#report-errors-to-sentry)
45
+ - [Upload and download files](#upload-and-download-files)
46
+ - [Choosing liaise](#choosing-liaise)
47
+ - [When it fits, and when it doesn't](#when-it-fits-and-when-it-doesnt)
48
+ - [How it compares](#how-it-compares)
49
+ - [Where it runs](#where-it-runs)
50
+ - [Design principles](#design-principles)
51
+ - [Reference](#reference)
52
+ - [createApi options](#createapi-options)
53
+ - [createGraphQL options](#creategraphql-options)
54
+ - [Endpoint options](#endpoint-options)
55
+ - [Operation options](#operation-options)
56
+ - [CallOptions](#calloptions)
57
+ - [Result and ApiError](#result-and-apierror)
58
+ - [MiddlewareContext](#middlewarecontext)
59
+ - [Built-in middleware options](#built-in-middleware-options)
60
+ - [liaise/testing](#liaisetesting)
61
+ - [Exports](#exports)
62
+ - [Behaviour in detail](#behaviour-in-detail)
63
+ - [Upgrading, contributing, licence](#upgrading-contributing-licence)
64
+
65
+ ## The problem it solves
66
+
67
+ `fetch` is a good building block. Every project still ends up writing the same few things around it, and they are easy to get subtly wrong.
68
+
69
+ | With plain fetch | liaise | See |
70
+ | ---------------- | ------ | --- |
71
+ | Typing fast in a search box shows old results. A slow early search lands last. | `dedupe` cancels the older call. | [Stale requests](#drop-stale-calls-with-dedupe) |
72
+ | Five components load the same data, or five 401s each refresh the token. That's five identical requests. | `share` sends one and hands everyone the answer. | [Sharing identical requests](#sharing-identical-requests) |
73
+ | A 500 counts as success, offline throws, a hung server waits forever. | Every call returns `{ data, error }`, and `error.kind` names the failure. With `timeout` set, a hung server becomes an error too. | [Handling errors](#handling-errors) |
74
+ | Retries run straight past your timeout. | `timeout` covers the whole operation, retries included. | [Deadlines](#set-a-deadline-with-timeout) |
75
+ | The backend changes a field and the page crashes three components later. | A schema checks the response. A bad shape is an error you handle. | [Validating responses](#validating-responses) |
76
+
77
+ ### Before and after
78
+
79
+ Here is one form submit, written both ways. `show` stands for whatever puts a message on screen.
80
+
81
+ **With plain fetch**
51
82
 
52
83
  ```ts
53
- import { createApi, Request } from 'liaise'
84
+ try {
85
+ const res = await fetch('/api/orders', { method: 'POST', body: JSON.stringify(order) })
86
+ show(`Order ${(await res.json()).id} confirmed`) // a 500 lands here too
87
+ } catch {
88
+ show('Something went wrong') // offline? broken JSON? no way to tell
89
+ }
90
+ ```
91
+
92
+ **With liaise**
93
+
94
+ <!-- tested: problem-after -->
95
+ ```ts
96
+ async function submit(order: { items: string[] }) {
97
+ const { data, error } = await api.placeOrder(order)
98
+ if (!error) return show(`Order ${data.id} confirmed`)
54
99
 
55
- // 1. Define your endpoints
56
- interface User {
57
- id: string
58
- name: string
59
- email: string
100
+ switch (error.kind) {
101
+ case 'http': return show(`The server said no (${error.status})`)
102
+ case 'network': return show("You're offline. We'll try again.")
103
+ case 'timeout': return show('This is taking too long. Try again.')
104
+ case 'parse': return show('The server sent something unexpected.')
105
+ }
60
106
  }
107
+ ```
61
108
 
62
- const getUser = new Request<{ id: string }, User>({
63
- method: 'GET',
64
- path: '/users/:id'
65
- })
109
+ `api.placeOrder` is an endpoint defined like the ones in [Quick start](#quick-start), with `timeout: 5000` so a hung server gives up after five seconds.
110
+
111
+ The `switch` leaves out `'abort'`, because nothing here cancels a call, and `'middleware'`, which points at a bug in your own code ([all six kinds](#handling-errors)).
112
+
113
+ ### Why another API client?
114
+
115
+ Without it, you have two options. You can hand-roll a wrapper around fetch, which means writing the same boilerplate on every project: a typed function per endpoint, status checks, error handling, retries, cancellation.
66
116
 
67
- const createUser = new Request<{ name: string; email: string }, User>({
117
+ Or you can reach for a large library like Apollo or urql. They're built for very large apps with complex data needs. For most products, that's bringing a tank to a chess match, and you spend hours on setup and configuration for features you never use.
118
+
119
+ liaise sits in between. It's the wrapper you'd otherwise hand-roll, already written and tested. Your whole setup is a base URL and your endpoints.
120
+
121
+ **The API client you'd build on your third project, with the edge cases already handled.**
122
+
123
+ ## Quick start
124
+
125
+ Define two endpoints, create a client, and make a call.
126
+
127
+ <!-- tested: quick-start -->
128
+ ```ts
129
+ import { createApi, defineRequest } from 'liaise'
130
+
131
+ type User = { id: string; name: string; email: string }
132
+
133
+ // 1. Describe your endpoints. The path decides which params are required.
134
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
135
+ const createUser = defineRequest<User, { name: string; email: string }>()({
68
136
  method: 'POST',
69
- path: '/users'
137
+ path: '/users',
70
138
  })
71
139
 
72
- // 2. Create the client
140
+ // 2. Create the client.
73
141
  const api = createApi({
74
142
  baseUrl: 'https://api.example.com',
75
143
  requests: { getUser, createUser },
76
- onError: (error) => console.error(`${error.request.method} ${error.request.url}`, error.status)
77
144
  })
78
145
 
79
- // 3. Make a call — params and response are fully typed
80
- const { data, error, retry } = await api.getUser({ id: '42' })
146
+ // 3. Call it. This never throws: you always get { data, error }.
147
+ const { data, error } = await api.getUser({ id: '42' })
81
148
 
82
149
  if (error) {
83
- console.error(error.status, error.body)
84
- return
150
+ // error.kind says what went wrong: 'http', 'network', 'timeout', ...
151
+ console.error(error.kind, error.status)
152
+ } else {
153
+ console.log(data.name) // data is a User here
85
154
  }
155
+ ```
156
+
157
+ ### What you just got
158
+
159
+ - The params are checked against the path, so `getUser({ userId: '42' })` is a compile error.
160
+ - `data` is typed from `defineRequest<User>`.
161
+ - Nothing throws, not even when you're offline.
162
+ - Checking `error` first narrows `data` to `User`, so you never write `data!`.
163
+
164
+ ### Next
165
+
166
+ - [Handling errors](#handling-errors)
167
+ - [Add an auth header and refresh the token on a 401](#add-an-auth-header-and-refresh-the-token-on-a-401)
168
+ - [Use with TanStack Query](#use-with-tanstack-query)
169
+ - [Use with React](#use-with-react)
170
+
171
+ ## How it fits together
86
172
 
87
- // data is typed as User
88
- console.log(data.name)
173
+ liaise has four pieces, and every call takes the same path through them. Middleware is a function that wraps a call, so it can add a header, retry or log.
174
+
175
+ ### Four pieces
176
+
177
+ | Piece | What it is |
178
+ | ----- | ---------- |
179
+ | Endpoint definition (`defineRequest`) | A recipe for one endpoint: its method, its path and the types of its params and response. It does nothing on its own. |
180
+ | Client (`createApi`) | Turns your recipes into typed functions, one per endpoint. |
181
+ | Call | `api.getUser(params, options)`, where `options` can set `signal`, `timeout`, `headers` or `middleware` for this call only. |
182
+ | `Result` | `{ data, error, response, retry }`, which is what every call returns. `data` and `error` are never both set. `retry()` runs the same call again. |
183
+
184
+ Types flow from the endpoint definition through `createApi` to every call, so you never annotate a call. When you need a type by name, it is listed under [Exports](#exports).
185
+
186
+ ### The path of one call
187
+
188
+ ```text
189
+ params → URL + body → your middleware → fetch → parse → validate → Result
89
190
  ```
90
191
 
91
- ## REST API
192
+ Any step can fail, and the failure lands in `error` instead of being thrown.
193
+
194
+ ### Three levels of settings
92
195
 
93
- ### Request
196
+ You can write a setting in three places: on the client, on the endpoint, or on one call. For headers, the most specific one wins. For middleware, every level runs, the client's first.
94
197
 
95
- 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.
198
+ | Level | Where you write it | `headers` | `middleware` |
199
+ | ----- | ------------------ | --------- | ------------ |
200
+ | Client | `createApi({ headers, middleware })` | Sent with every call | Runs first, around everything else |
201
+ | Endpoint | `defineRequest<T>()({ headers, middleware })` | Replaces the client's value for the same header | Runs second |
202
+ | Call | `api.getUser(params, { headers, middleware })` | Replaces the client's and the endpoint's value for the same header | Runs last, closest to `fetch` |
203
+
204
+ Headers merge by name, so setting one header on a call keeps every other header from the client and the endpoint. A `Content-Type` you set at any level replaces the one liaise picks from the body.
96
205
 
97
206
  ```ts
98
- import { Request } from 'liaise'
207
+ import { createApi, defineRequest } from 'liaise'
99
208
 
100
- const listItems = new Request<{ page: number; limit: number }, Item[]>({
101
- method: 'GET',
102
- path: '/items'
209
+ type Report = { total: number }
210
+
211
+ const api = createApi({
212
+ baseUrl: 'https://api.example.com',
213
+ headers: { 'x-api-version': '1' }, // every call
214
+ requests: {
215
+ getReport: defineRequest<Report>()({
216
+ method: 'GET',
217
+ path: '/report',
218
+ headers: { 'x-api-version': '2' }, // this endpoint: replaces the client's
219
+ }),
220
+ },
103
221
  })
222
+
223
+ await api.getReport() // sends x-api-version: 2
224
+ await api.getReport({}, { headers: { 'x-api-version': '3' } }) // sends x-api-version: 3
104
225
  ```
105
226
 
106
- The two type parameters drive the entire type system:
227
+ ## Guide
107
228
 
108
- - `TParams` -- the shape of the params object the caller must provide (path params, query params, and body params combined).
109
- - `TResponse` -- the shape of the successful response data. This becomes the type of `result.data`.
229
+ Each section starts with the problem it solves, then shows the smallest example, then lists the rules.
110
230
 
111
- When an endpoint takes no params, use `Record<string, never>` and the generated method will accept an optional (or omitted) params argument:
231
+ ### Defining endpoints
232
+
233
+ You describe each endpoint once, with its method, its path, and the types of its params and response. Every call to it is then checked against that description.
112
234
 
113
235
  ```ts
114
- const health = new Request<Record<string, never>, { status: string }>({
236
+ import { createApi, defineRequest } from 'liaise'
237
+
238
+ type Repo = { id: number; name: string }
239
+
240
+ // The first type is the response. The second is the params the path doesn't name.
241
+ const listRepos = defineRequest<Repo[], { page?: number }>()({
115
242
  method: 'GET',
116
- path: '/health'
243
+ path: '/orgs/:org/repos',
117
244
  })
118
245
 
119
- // Both work:
120
- await api.health()
121
- await api.health({})
246
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { listRepos } })
247
+
248
+ await api.listRepos({ org: 'acme' }) // GET /orgs/acme/repos
249
+ await api.listRepos({ org: 'acme', page: 2 }) // GET /orgs/acme/repos?page=2
250
+ await api.listRepos({ page: 2 }) // ✗ compile error: org is required
122
251
  ```
123
252
 
124
- #### Path parameters
253
+ - **The path names the required params.** Each `:name` in the path becomes a required param of type `string | number`. Its value is filled into the URL and left out of the query string and the body.
254
+ - **Every other param goes in the second type argument.** For `GET` and `DELETE`, these params go in the query string. For `POST`, `PUT` and `PATCH`, they go in a JSON body. [Sending data](#sending-data) covers what each one can hold.
255
+ - **`bodyAs` flips that default.** Use it for an API that does it the other way round:
256
+
257
+ ```ts
258
+ type Job = { id: string }
259
+
260
+ // A DELETE that takes a JSON body
261
+ const bulkDelete = defineRequest<{ deleted: number }, { ids: string[] }>()({
262
+ method: 'DELETE',
263
+ path: '/items',
264
+ bodyAs: 'body',
265
+ })
125
266
 
126
- 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:
267
+ // A POST that sends its params in the query string
268
+ const triggerJob = defineRequest<Job, { priority: number }>()({
269
+ method: 'POST',
270
+ path: '/jobs/trigger',
271
+ bodyAs: 'query',
272
+ })
273
+ ```
274
+
275
+ - **A path param must have a usable value.** It must be a non-empty string, a finite number, a bigint or a boolean. `undefined`, `null`, `''`, an object, an array, a `Date` and `NaN` are refused before anything is sent, with an error naming the param.
276
+ - That catches the most common mistake, which is calling before an id has loaded. An `org` that is still `undefined` at runtime returns an error instead of fetching `/orgs/undefined/repos`.
277
+ - **An endpoint with no params** is called with no arguments. With `defineRequest<{ status: string }>()({ method: 'GET', path: '/health' })`, both `api.health()` and `api.health({})` work.
278
+ - **`responseType`** says how to read the response body. It defaults to `'json'`. The options are under [Reading responses](#reading-responses).
279
+ - **`responseType: 'none'` needs the response type `undefined`.** Anything else is a compile error, because `data` is always `undefined` for an endpoint that sends no body:
280
+
281
+ ```ts
282
+ defineRequest<undefined>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✓
283
+ defineRequest<Repo>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✗
284
+ ```
285
+
286
+ - **A `#` in the path is a compile error.** A URL fragment is never sent to the server, so `path: '/docs#section'` is refused where you write it. [URL fragments](#url-fragments) has the details.
287
+
288
+ **Why two calls.** `defineRequest<Repo[]>()({ ... })` is two calls because TypeScript can't infer some type arguments while you write others. The first call takes the response type you write, and the second infers the params from the path. In a single call, writing the response type would quietly turn the path checking off.
289
+
290
+ #### Without defineRequest
291
+
292
+ `new Request<TParams, TResponse>(config)` is the class that `defineRequest` builds. You write the params type yourself, covering path, query and body params together, and nothing checks it against the path:
127
293
 
128
294
  ```ts
129
- const getItem = new Request<{ orgId: string; id: string }, Item>({
130
- method: 'GET',
131
- path: '/orgs/:orgId/items/:id'
132
- })
295
+ import { createApi, Request } from 'liaise'
296
+
297
+ type User = { id: string; name: string }
298
+
299
+ const getUser = new Request<{ userId: string }, User>({ method: 'GET', path: '/users/:id' })
300
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
133
301
 
134
- // Calls GET /orgs/acme/items/42
135
- await api.getItem({ orgId: 'acme', id: '42' })
302
+ await api.getUser({ userId: '42' })
303
+ // Compiles. The call then returns an error, because the path has no :userId and :id is never filled in.
136
304
  ```
137
305
 
138
- #### `responseType`
306
+ Use `new Request` when the config isn't a literal, for example when you build it at runtime, or when you don't want the path checked. It is not deprecated. For an endpoint with no params, write `Record<string, never>` as `TParams`.
139
307
 
140
- Controls how the response body is parsed. Defaults to `'json'`.
308
+ ### Handling errors
309
+
310
+ Plain fetch reports failures three different ways. A 500 resolves like a success, being offline throws, and a hung server never answers. In liaise every call returns `{ data, error }`, and `error.kind` names what went wrong.
141
311
 
142
312
  ```ts
143
- const downloadFile = new Request<{ id: string }, Blob>({
144
- method: 'GET',
145
- path: '/files/:id',
146
- responseType: 'blob'
147
- })
313
+ import { createApi, defineRequest } from 'liaise'
314
+
315
+ type User = { id: string; name: string }
316
+
317
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id', timeout: 5000 })
318
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
319
+
320
+ async function loadUser(id: string) {
321
+ const { data, error } = await api.getUser({ id })
322
+
323
+ if (error) {
324
+ switch (error.kind) {
325
+ case 'http': // the server answered with a non-2xx status
326
+ if (error.status === 401) redirectToLogin()
327
+ else show(`The server said no (${error.status})`)
328
+ break
329
+ case 'network': // no response arrived
330
+ show("You're offline. Try again.")
331
+ break
332
+ case 'timeout': // the 5 second deadline passed
333
+ show('This is taking too long.')
334
+ break
335
+ case 'abort': // you cancelled the call, so there is nothing to show
336
+ break
337
+ case 'parse': // a 2xx body that didn't parse or failed the schema
338
+ case 'middleware': // your own middleware threw
339
+ report(error)
340
+ break
341
+ }
342
+ return
343
+ }
344
+
345
+ show(`Hello, ${data.name}`) // data is a User here
346
+ }
148
347
  ```
149
348
 
150
- See [Response parsing](#response-parsing) for all options.
349
+ `show`, `redirectToLogin` and `report` stand for your own code.
151
350
 
152
- #### `dedupe`
351
+ | `kind` | What happened | `status` | What you usually do | Reported to [`onError`](#reporting-errors-with-onerror)? |
352
+ | ------ | ------------- | -------- | ------------------- | ---------------------- |
353
+ | `'http'` | The server answered with a non-2xx status. | The response's status | Handle it by status, or show it | Yes |
354
+ | `'network'` | No response arrived, because you're offline or DNS or CORS failed. Params liaise refuses before sending also land here. | `0` | Show an offline message, or retry | Yes |
355
+ | `'timeout'` | Your [`timeout`](#set-a-deadline-with-timeout) passed. | `0` | Say it's slow | Yes |
356
+ | `'abort'` | The call was cancelled by your signal, or replaced by a newer [`dedupe`](#drop-stale-calls-with-dedupe) call. | `0` | Ignore it | **No** |
357
+ | `'parse'` | A 2xx body didn't parse as its `responseType`, or failed your [schema](#validating-responses). | The response's status | Report it | Yes |
358
+ | `'middleware'` | Your middleware threw. | `0` | Fix your code | Yes |
153
359
 
154
- 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:
360
+ - **Check `error` first.** After `if (error) return`, `data` has your response type, so you never write `data!`.
361
+ - For an endpoint that sends no body, declare `responseType: 'none'`. Widening the type to `| null` doesn't work, because an empty body is a `'parse'` error. See [Reading responses](#reading-responses).
362
+ - **Branch on `error.kind`.** Four kinds share `status: 0`, and each needs different handling.
363
+ - **A non-2xx response is always `'http'`**, even when its body doesn't parse. liaise checks the status before it reads the body, so a 500 with broken JSON is still a 500, and [`retryMiddleware`](#retry-failed-calls) still retries it.
364
+ - **`response` is for the status and headers.** liaise has already read its body to produce `data` or `error.body`, so `response.json()` throws "Body has already been read". A `Response` you build yourself for `successResult()` in tests keeps its body.
365
+ - Every field of `error` is listed under [`ApiError`](#result-and-apierror).
366
+
367
+ #### Trying again with retry()
368
+
369
+ Every `Result` carries `retry()`, which runs the same call again. It goes through all your middleware, so an auth header is set again and logging runs again:
155
370
 
156
371
  ```ts
157
- const searchUsers = new Request<{ q: string }, User[]>({
158
- method: 'GET',
159
- path: '/users/search',
160
- dedupe: true
161
- })
372
+ const { error, retry } = await api.getUser({ id: '42' })
162
373
 
163
- // If a second call starts before the first finishes, the first is aborted
164
- await api.searchUsers({ q: 'hel' })
165
- await api.searchUsers({ q: 'hello' }) // previous call is auto-cancelled
374
+ if (error?.status === 401) {
375
+ await refreshToken()
376
+ const second = await retry() // a fresh call through every middleware
377
+ if (!second.error) show(second.data.name)
378
+ }
166
379
  ```
167
380
 
168
- #### `share`
381
+ `refreshToken` stands for your own token refresh.
169
382
 
170
- 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.
383
+ #### Reporting errors with onError
384
+
385
+ `onError` on `createApi` is one place to send every error to your tracker.
171
386
 
172
387
  ```ts
173
- const getProduct = new Request<{ id: string }, Product>({
174
- method: 'GET',
175
- path: '/products/:id',
176
- share: true
388
+ const api = createApi({
389
+ baseUrl: 'https://api.example.com',
390
+ requests: { getUser },
391
+ onError: (error) => logToTracker(error),
177
392
  })
178
-
179
- // Both calls join the same network request
180
- await Promise.all([
181
- api.getProduct({ id: '42' }),
182
- api.getProduct({ id: '42' })
183
- ])
184
393
  ```
185
394
 
186
- #### `bodyAs`
395
+ `logToTracker` stands for your error tracker, such as Sentry.
396
+
397
+ - It runs once per call, after all your middleware has finished. A call that a retry middleware rescues from a 500 never reaches it.
398
+ - It isn't called for `'abort'`, because a cancellation isn't a failure. A `'timeout'` is reported, because it's a deadline you missed.
399
+ - It only watches. The caller gets the same `Result` either way.
187
400
 
188
- 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:
401
+ ### Sending data
402
+
403
+ You pass one params object, and liaise puts each field where it belongs: in the path, the query string or the body. You never build a URL by hand.
189
404
 
190
405
  ```ts
191
- // DELETE endpoint that expects a JSON body
192
- const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
193
- method: 'DELETE',
194
- path: '/items',
195
- bodyAs: 'body'
196
- })
406
+ import { createApi, defineRequest } from 'liaise'
197
407
 
198
- // POST endpoint that sends params as query string
199
- const triggerJob = new Request<{ priority: number }, Job>({
408
+ type Item = { id: string; name: string }
409
+
410
+ const listItems = defineRequest<Item[], { page: number; tags: string[] }>()({
411
+ method: 'GET',
412
+ path: '/orgs/:org/items',
413
+ })
414
+ const createItem = defineRequest<Item, { name: string }>()({
200
415
  method: 'POST',
201
- path: '/jobs/trigger',
202
- bodyAs: 'query'
416
+ path: '/orgs/:org/items',
203
417
  })
418
+
419
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { listItems, createItem } })
420
+
421
+ await api.listItems({ org: 'acme', page: 2, tags: ['a', 'b'] })
422
+ // GET /orgs/acme/items?page=2&tags=a&tags=b
423
+
424
+ await api.createItem({ org: 'acme', name: 'Lamp' })
425
+ // POST /orgs/acme/items, with the JSON body {"name":"Lamp"}
204
426
  ```
205
427
 
206
- ### `defineRequest`
428
+ Which params go in the query string and which in the body is set per endpoint, under [Defining endpoints](#defining-endpoints).
207
429
 
208
- `new Request<TParams, TResponse>` makes you restate what the path already says,
209
- and nothing checks the two against each other:
430
+ #### Query strings
210
431
 
211
- ```ts
212
- // The params are restated by hand, and nothing checks them against the path:
213
- const getUser = new Request<{ userId: string }, User>({ method: 'GET', path: '/users/:id' })
432
+ | Params | Query string |
433
+ | ------ | ------------ |
434
+ | `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
435
+ | `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
436
+ | `{ filter: null }` | _(left out)_ |
437
+ | `{ filter: undefined }` | _(left out)_ |
438
+ | `{ meta: { nested: true } }` | Refused |
439
+ | `{ since: new Date() }` | Refused |
214
440
 
215
- api.getUser({ userId: '42' }) // compiles — then fails at runtime: buildUrl finds
216
- // no `:userId` to substitute, `:id` survives, and
217
- // the unresolved-token check throws
218
- ```
441
+ - **Arrays** become repeated keys (`tags=a&tags=b`), the format most server frameworks read.
442
+ - **`null` and `undefined`** are left out.
443
+ - **A nested object is refused.** There is no standard way to put one in a query string (brackets, dots and JSON are all in use), so liaise doesn't guess. Flatten it first.
444
+ - **A `Date` is refused too.** APIs expect ISO 8601 or epoch milliseconds, so convert it yourself with `date.toISOString()` or `date.getTime()`.
445
+ - A refused param sends nothing. You get an error `Result` with `kind: 'network'` and a `TypeError` in `error.body` that names the param.
219
446
 
220
- `defineRequest` infers the params from the path literal instead:
447
+ #### Request bodies
221
448
 
222
- ```ts
223
- import { defineRequest } from 'liaise'
449
+ liaise serializes the body from what you pass, and sets `Content-Type` for you unless you set one yourself.
224
450
 
225
- const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
451
+ | You pass | Body sent | Content-Type |
452
+ | -------- | --------- | ------------ |
453
+ | `null`/`undefined` | `null` | _(none)_ |
454
+ | `string` | as-is | `text/plain` |
455
+ | `FormData` | as-is | _(the browser sets the multipart boundary)_ |
456
+ | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
457
+ | `Blob` | as-is | `application/octet-stream` |
458
+ | `ArrayBuffer` | as-is | `application/octet-stream` |
459
+ | Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
460
+ | `ReadableStream` | as-is (a streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
461
+ | Plain object | `JSON.stringify()` | `application/json` |
226
462
 
227
- api.getUser({ id: '42' }) // ✓
228
- api.getUser({ id: 42 }) // ✓ — numbers are encoded
229
- api.getUser({ userId: '42' }) // ✗ Object literal may only specify known properties
230
- ```
463
+ A `ReadableStream` body can be sent only once. A retry, from middleware or from `result.retry()`, returns an error telling you to read the stream into a `Blob` or `ArrayBuffer` first ([details](#stream-bodies)).
464
+
465
+ #### What params can be
231
466
 
232
- Params the path does not name — query or body fields — go in the second type
233
- argument:
467
+ | You pass | What happens |
468
+ | -------- | ------------ |
469
+ | Plain object | Split into path params, query string and body |
470
+ | `Map` with string keys | Same as the object it spells |
471
+ | Class instance with fields | Same as a plain object (split by those fields even if the class also defines `toJSON()`; `toJSON()` is used only when there are no own fields) |
472
+ | Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
473
+ | Typed array, `DataView`, `Buffer`, `ReadableStream` | Sent as the body, as in the table above (refused on a request whose params go in the query string) |
474
+ | `Set`, a bare `Date`, a `Map` with non-string keys, a class with no fields | Refused: an error `Result` (`kind: 'network'`) naming the type. Nothing is sent. |
234
475
 
235
- ```ts
236
- const listRepos = defineRequest<Repo[], { page?: number }>()({
237
- method: 'GET',
238
- path: '/orgs/:org/repos',
239
- })
476
+ A `Map`, `Set` or class with private state nested inside a JSON body is sent as `{}`, because that is what `JSON.stringify` does. Convert it first.
240
477
 
241
- api.listRepos({ org: 'acme' }) // ✓ page is optional
242
- api.listRepos({ org: 'acme', page: 2 }) // ✓
243
- api.listRepos({ page: 2 }) // ✗ org is required
244
- ```
478
+ Headers, including your own `Content-Type`, follow the [three levels of settings](#three-levels-of-settings).
479
+
480
+ ### Reading responses
245
481
 
246
- It also enforces the `responseType: 'none'` convention that `new Request` can
247
- only document:
482
+ A response can be JSON, text or a file. You say which with `responseType`, and liaise reads the body for you.
248
483
 
249
484
  ```ts
250
- defineRequest<undefined>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✓
251
- defineRequest<User>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✗
252
- ```
485
+ import { createApi, defineRequest } from 'liaise'
486
+
487
+ type User = { id: string; name: string }
253
488
 
254
- **Why two calls.** TypeScript has no partial type-argument inference: if the response
255
- type and the config were arguments to one call, supplying the response type explicitly
256
- would stop the path from being inferred, and the checking would quietly do nothing.
257
- Splitting them keeps the response type explicit and the path inferred. Calling it
258
- wrong is a compile error, not a silent one.
489
+ // JSON is the default.
490
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
491
+
492
+ // A file comes back as a Blob.
493
+ const downloadFile = defineRequest<Blob>()({ method: 'GET', path: '/files/:id', responseType: 'blob' })
494
+
495
+ // A 204 No Content has no body, so data is undefined.
496
+ const deleteUser = defineRequest<undefined>()({
497
+ method: 'DELETE',
498
+ path: '/users/:id',
499
+ responseType: 'none',
500
+ })
501
+
502
+ const api = createApi({
503
+ baseUrl: 'https://api.example.com',
504
+ requests: { getUser, downloadFile, deleteUser },
505
+ })
506
+ ```
259
507
 
260
- `new Request(...)` is unchanged and not deprecated — use it when the config is
261
- not a literal, or when you do not want the path checked.
508
+ | `responseType` | How the body is read | `data` |
509
+ | -------------- | -------------------- | ------ |
510
+ | `'json'` (default) | `response.text()`, then `JSON.parse()` | the parsed value |
511
+ | `'text'` | `response.text()` | `string` |
512
+ | `'blob'` | `response.blob()` | `Blob` |
513
+ | `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
514
+ | `'formData'` | `response.formData()` | `FormData` |
515
+ | `'none'` | not read (the stream is cancelled) | `undefined` |
516
+
517
+ - **An empty body under `'json'` is a `'parse'` error.** You declared JSON and the server sent none, so no value could honestly match your type. The error has the response's own status (a 204 reports 204), the `response`, and `''` in `error.body`.
518
+ - **A literal `null` body is not empty.** It is valid JSON, so the call succeeds with `data: null`.
519
+ - **`'none'` is for an endpoint that sends no body on success**, such as a `DELETE` that answers 204, or 200 with an empty body. liaise reads nothing, `data` is `undefined`, and a body the server sends anyway is discarded. Its stream is cancelled, which frees the connection.
520
+ - **Declare `'none'` with the response type `undefined`.** `defineRequest` enforces this ([Defining endpoints](#defining-endpoints)). [Without defineRequest](#without-definerequest) it is only a convention, and `data` is `undefined` at runtime whatever type you wrote.
521
+ - **A non-2xx body is still read into `error.body`**, because an error body usually explains what went wrong. Under `'none'` it is read as JSON, and `error.body` is `null` when it isn't JSON:
522
+
523
+ ```ts
524
+ const { error } = await api.deleteUser({ id: '42' })
525
+ if (error) {
526
+ // A 409 { "error": "already deleted" } lands in error.body,
527
+ // even though deleteUser declares responseType: 'none'.
528
+ console.error(error.status, error.body)
529
+ }
530
+ ```
262
531
 
263
- ### Response validation
532
+ ### Validating responses
264
533
 
265
- Pass any [Standard Schema](https://standardschema.dev) validator — Zod, Valibot,
266
- ArkType — and the response is checked before you see it. liaise takes no
267
- dependency on one; Standard Schema is an interface, not a package.
534
+ TypeScript trusts the type you write, and nothing checks it at runtime. When the backend changes a field, the page crashes three components later. Give the endpoint a schema, and the response is checked before you see it.
268
535
 
269
536
  ```ts
537
+ import { createApi, defineRequest } from 'liaise'
270
538
  import { z } from 'zod'
271
539
 
272
540
  const getUser = defineRequest()({
@@ -275,452 +543,401 @@ const getUser = defineRequest()({
275
543
  schema: z.object({ id: z.string(), name: z.string() }),
276
544
  })
277
545
 
546
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
547
+
278
548
  const { data, error } = await api.getUser({ id: '42' })
279
549
  // ^? { id: string; name: string } | null
280
550
  ```
281
551
 
282
- The schema supplies the response type, so there is no type argument to write —
283
- and no second place for it to drift out of date.
284
-
285
- **`data` is the schema's output.** A schema that transforms changes what you
286
- receive:
552
+ Valibot and ArkType work the same way:
287
553
 
288
554
  ```ts
289
- const getUser = defineRequest()({
555
+ import * as v from 'valibot'
556
+ import { type } from 'arktype'
557
+
558
+ const withValibot = defineRequest()({
290
559
  method: 'GET',
291
560
  path: '/users/:id',
292
- schema: z.object({
293
- id: z.string(),
294
- createdAt: z.coerce.date(), // the wire sends a string
295
- role: z.string().default('user'), // absent on the wire
296
- }),
561
+ schema: v.object({ id: v.string(), name: v.string() }),
297
562
  })
298
563
 
299
- const { data } = await api.getUser({ id: '42' })
300
- data.createdAt // a real Date
301
- data.role // 'user' when the server omitted it
564
+ const withArkType = defineRequest()({
565
+ method: 'GET',
566
+ path: '/users/:id',
567
+ schema: type({ id: 'string', name: 'string' }),
568
+ })
302
569
  ```
303
570
 
304
- That is the point of validating through a schema rather than merely checking
305
- one — but it does mean `data` is no longer byte-identical to the response.
571
+ - **Any [Standard Schema](https://standardschema.dev) validator works.** liaise doesn't depend on any of them. Standard Schema is only an interface, so you bring the validator you already use.
572
+ - **The schema supplies the response type.** You write no type argument, so there's no second type to keep in sync.
573
+ - **`data` is the schema's output.** A schema that transforms changes what you receive, so `data` can differ from the raw response:
306
574
 
307
- A response the schema refuses is an error `Result`, never a throw:
575
+ ```ts
576
+ const getUser = defineRequest()({
577
+ method: 'GET',
578
+ path: '/users/:id',
579
+ schema: z.object({
580
+ id: z.string(),
581
+ createdAt: z.coerce.date(), // the wire sends a string
582
+ role: z.string().default('user'), // absent on the wire
583
+ }),
584
+ })
308
585
 
309
- ```ts
310
- const { error } = await api.getUser({ id: '42' })
311
- if (error?.kind === 'parse') {
312
- console.error(error.body) // the validator's issues
313
- error.status // the response's own status — the server was fine
314
- }
315
- ```
586
+ const { data } = await api.getUser({ id: '42' })
587
+ data.createdAt // a real Date
588
+ data.role // 'user' when the server left it out
589
+ ```
316
590
 
317
- Only the **success** body is validated. A non-2xx body is diagnostic and often a
318
- different shape, so it is left alone.
591
+ - **A response the schema refuses is a `'parse'` error.** Nothing is thrown. `error.body` holds the validator's issues, and `error.status` is the response's own status, since the server answered fine:
319
592
 
320
- Schemas work on the GraphQL client too, validating the response's `data`:
593
+ ```ts
594
+ const { error } = await api.getUser({ id: '42' })
595
+ if (error?.kind === 'parse') {
596
+ console.error(error.body) // the validator's issues
597
+ }
598
+ ```
321
599
 
322
- ```ts
323
- const me = new Operation<{}, User>({
324
- operation: gql`query { me { id name } }`,
325
- schema: UserSchema,
326
- })
327
- ```
600
+ - A validator that throws is a `'parse'` error too, with the thrown value in `error.body`.
601
+ - **Only a 2xx body is validated.** A non-2xx body is diagnostic and often a different shape, so it is left alone.
602
+ - **The GraphQL `Operation` ([GraphQL](#graphql)) takes `schema` too**, and validates the response's `data`. There the response type stays explicit, because only `defineRequest` infers it:
328
603
 
329
- There the response type stays explicit — only `defineRequest` infers it.
604
+ ```ts
605
+ import { Operation, gql } from 'liaise'
330
606
 
331
- ### Pagination
607
+ const UserSchema = z.object({ id: z.string(), name: z.string() })
332
608
 
333
- `paginate` walks a paginated endpoint, yielding one `Result` per page:
609
+ const me = new Operation<Record<string, never>, z.infer<typeof UserSchema>>({
610
+ operation: gql`query { me { id name } }`,
611
+ schema: UserSchema,
612
+ })
613
+ ```
334
614
 
335
- ```ts
336
- import { paginate } from 'liaise'
615
+ ### Cancelling, deadlines and stale requests
337
616
 
338
- for await (const page of paginate(api.listItems, { limit: 50 }, {
339
- next: (p, prev) => p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined,
340
- })) {
341
- if (page.error) break
342
- render(page.data.items)
343
- }
344
- ```
617
+ Sometimes you no longer need a call, because the user left the page or typed a newer search. Sometimes a call takes too long. You can end a call with a signal, a deadline, or `dedupe`, and each one ends it with an error you can tell apart.
618
+
619
+ #### Cancel with a signal
345
620
 
346
- **`next` returns the next params, not a cursor.** That is what keeps this
347
- library out of the business of guessing where a cursor goes — `cursor`?
348
- `page_token`? `after`? The previous params arrive as the second argument, so
349
- the common case is a spread, and the same shape covers every scheme:
621
+ Pass an `AbortSignal` in the call options:
350
622
 
351
623
  ```ts
352
- // offset
353
- next: (p, prev) => p.data.items.length === prev.limit
354
- ? { ...prev, offset: prev.offset + prev.limit }
355
- : undefined
356
-
357
- // page number, driven by a Link header
358
- next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
359
- ? { ...prev, page: prev.page + 1 }
360
- : undefined
361
- ```
624
+ import { createApi, defineRequest } from 'liaise'
625
+
626
+ type User = { id: string; name: string }
362
627
 
363
- Return `undefined` or `null` to stop.
628
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
629
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
364
630
 
365
- **An error page is yielded, then the walk ends.** There is no data to read the
366
- next cursor from, so there is nothing to continue with — and you see what
367
- failed rather than a loop that quietly stopped.
631
+ const controller = new AbortController()
632
+ const pending = api.getUser({ id: '42' }, { signal: controller.signal })
368
633
 
369
- **`maxPages` is optional and has no default.** A ceiling exists if you want one;
370
- the library will not invent a number, because a silent truncation at an
371
- arbitrary limit looks exactly like reaching the last page.
634
+ controller.abort()
372
635
 
373
- ```ts
374
- paginate(api.listItems, { limit: 50 }, { next, maxPages: 100 })
636
+ const { error } = await pending
637
+ // error.kind === 'abort', error.status === 0, error.body is a DOMException named 'AbortError'
375
638
  ```
376
639
 
377
- Any other [`CallOptions`](#calloptions) — `signal`, `timeout`, `headers` — apply
378
- to every request, so one signal cancels the whole crawl.
640
+ A cancellation you asked for isn't reported to [`onError`](#reporting-errors-with-onerror). How liaise tells your cancellation apart from other failures is under [Abort classification](#abort-classification).
379
641
 
380
- `paginate` yields pages, not items. Flattening would mean deciding which field
381
- holds the array, which is the convention-guessing `next` exists to avoid.
642
+ #### Set a deadline with timeout
382
643
 
383
- ### Query strings
644
+ `timeout` is in milliseconds, and you set it on the endpoint or on one call:
384
645
 
385
- 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`.
646
+ ```ts
647
+ import { createApi, defineRequest } from 'liaise'
648
+ import { retryMiddleware } from 'liaise/middleware'
386
649
 
387
- A `baseUrl` may carry its own query string — a fixed API key, say. Its params
388
- are merged ahead of the call's:
650
+ type Report = { total: number }
651
+
652
+ const getReport = defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 })
389
653
 
390
- ```ts
391
654
  const api = createApi({
392
- baseUrl: 'https://api.example.com/v1?key=abc',
393
- requests: { search: new Request<{ q: string }, Hit[]>({ method: 'GET', path: '/search' }) },
655
+ baseUrl: 'https://api.example.com',
656
+ requests: { getReport },
657
+ middleware: [retryMiddleware(3)],
394
658
  })
395
659
 
396
- await api.search({ q: 'hello' })
397
- // GET https://api.example.com/v1/search?key=abc&q=hello
660
+ const { error } = await api.getReport()
661
+ // If no answer arrives within 3 seconds, retries included: error.kind === 'timeout', error.status === 0
398
662
  ```
399
663
 
400
- Merging **accumulates**, it does not override: a call param whose key the base
401
- already used produces both, `?key=abc&key=xyz`, and which one wins is the
402
- server's decision. This differs from headers, where a per-call value replaces a
403
- global one — because array params already serialize as repeated keys
404
- (`tags=a&tags=b`), so collapsing duplicates would break them. If a base-level
405
- param needs to vary per call, set it from middleware rather than the `baseUrl`.
664
+ - **`timeout` is one deadline for the whole call.** It covers every middleware, every retry and every wait between retries. `timeout: 3000` with three retries still answers within three seconds.
665
+ - **A call's `timeout` replaces the endpoint's.** `timeout: 0` on a call turns the endpoint's deadline off. Zero, a negative number or no `timeout` at all means no deadline, which is the default.
666
+ - **`result.retry()` starts a fresh deadline.** The retried call isn't charged for time the first one used.
667
+ - If you want a separate limit for each attempt instead, see [Give each attempt its own timeout](#give-each-attempt-its-own-timeout).
668
+ - Under [`share`](#sharing-identical-requests), the endpoint's `timeout` belongs to the one shared request, and a caller can't extend it.
406
669
 
407
- **A `#fragment` is refused.** A fragment is never sent to the server, so one in
408
- a `path` or `baseUrl` cannot do what it appears to — and before 4.2.1 it
409
- silently discarded the query string. It is now an error naming the offending
410
- value, rather than being stripped, so the dead code does not stay in your
411
- template.
670
+ #### Drop stale calls with dedupe
412
671
 
413
- Since 4.4.0 a fragment in a [`defineRequest`](#definerequest) `path` **literal**
414
- is also a compile error, so the endpoint is rejected where it is declared rather
415
- than on every call:
672
+ `dedupe: true` makes each new call to an endpoint cancel the one still running. Use it for search-as-you-type and fast-changing filters, where only the latest answer matters:
416
673
 
417
674
  ```ts
418
- defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
419
- // ^ Property '__fragmentInPath' is missing:
420
- // a URL fragment is never sent to the server
421
- ```
675
+ type User = { id: string; name: string }
422
676
 
423
- The check reads the literal, so a path assembled at runtime — or a
424
- `RequestConfig`-typed variable — still compiles and is caught by the runtime
425
- error instead. `new Request` takes no path literal, so it has no equivalent
426
- check; this is one of the things `defineRequest` buys you.
677
+ const searchUsers = defineRequest<User[], { q: string }>()({
678
+ method: 'GET',
679
+ path: '/users/search',
680
+ dedupe: true,
681
+ })
427
682
 
428
- **A `#` inside a param *value* is not a fragment** and is never refused — it is
429
- escaped to `%23` and sent as ordinary data:
683
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { searchUsers } })
430
684
 
431
- ```ts
432
- await api.getDoc({ id: 'a#b' }) // → GET /docs/a%23b
433
- await api.search({ tag: 'a#b' }) // → GET /search?tag=a%23b
685
+ // Typing fast: each call cancels the one before it.
686
+ api.searchUsers({ q: 'h' }) // ends with kind 'abort'
687
+ api.searchUsers({ q: 'he' }) // ends with kind 'abort'
688
+ api.searchUsers({ q: 'hel' }) // this one completes
434
689
  ```
435
690
 
436
- Only a `#` written into a `path` or `baseUrl` is refused, because that one was
437
- never going to reach the server.
438
-
439
- | Input | Output |
440
- | ------------------------------ | --------------------------- |
441
- | `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
442
- | `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
443
- | `{ filter: null }` | _(omitted)_ |
444
- | `{ filter: undefined }` | _(omitted)_ |
445
- | `{ meta: { nested: true } }` | **TypeError** (see below) |
446
- | `{ since: new Date() }` | **TypeError**: convert it first (`toISOString()` or `getTime()`) |
447
-
448
- **Arrays** use repeated keys (`tags=a&tags=b`), which is the most widely supported format across server frameworks.
691
+ - **It works per endpoint.** A call to one endpoint never cancels a call to another.
692
+ - **A replaced call ends with `kind: 'abort'`**, so your code can ignore it.
693
+ - **It works together with your own signal and a `timeout`.** Whichever fires first ends the call.
694
+ - It can't be combined with [`share`](#sharing-identical-requests), which does the opposite.
449
695
 
450
- **`null` and `undefined`** values are silently omitted from the query string.
696
+ All three still end the call when a middleware is stuck on work of its own that ignores the signal, such as a token refresh that never settles. [Timeout backstop](#timeout-backstop) explains how.
451
697
 
452
- **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.
698
+ ### Sharing identical requests
453
699
 
454
- **A `Date`** is refused too, with a message that names it. ISO 8601 and epoch milliseconds are both common on real APIs, so convert it yourself: `since: date.toISOString()` or `since: date.getTime()`.
700
+ Several parts of an app often ask for the same thing at the same moment. Five components load the current user, or five calls get a 401 and each one refreshes the token. `share: true` sends one request and gives every caller its answer.
455
701
 
456
- ### Result
702
+ ```ts
703
+ import { createApi, defineRequest } from 'liaise'
457
704
 
458
- Every API call returns a `Result<TResponse>` instead of throwing. It's a discriminated union on `error`, not a plain interface:
705
+ type Product = { id: string; name: string }
459
706
 
460
- ```ts
461
- interface SuccessResult<TResponse> {
462
- data: TResponse // parsed response
463
- error: null
464
- response: Response // always present on success
465
- retry: () => Promise<Result<TResponse>>
466
- }
707
+ const getProduct = defineRequest<Product>()({
708
+ method: 'GET',
709
+ path: '/products/:id',
710
+ share: true,
711
+ })
467
712
 
468
- interface ErrorResult<TResponse> {
469
- data: null
470
- error: ApiError // structured error, see below
471
- response: Response | null // present for HTTP/parse failures, null for network/abort/timeout
472
- retry: () => Promise<Result<TResponse>>
473
- }
713
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getProduct } })
474
714
 
475
- type Result<TResponse> = SuccessResult<TResponse> | ErrorResult<TResponse>
715
+ // One network request. Both callers get the same response.
716
+ const [a, b] = await Promise.all([
717
+ api.getProduct({ id: '42' }),
718
+ api.getProduct({ id: '42' }),
719
+ ])
476
720
  ```
477
721
 
478
- 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
479
- 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:
722
+ - **`share` joins the call already running. [`dedupe`](#drop-stale-calls-with-dedupe) cancels it.** Setting both on one endpoint throws when you create the client, so you find the mistake straight away.
723
+ - **Identical means the same endpoint and the same params, compared by content.** Key order doesn't matter, and two `Date`s with the same time match. Different params or a different endpoint never share. [Share key and refcount](#share-key-and-refcount) has the full rules.
724
+ - **A per-call `headers` or `middleware` turns sharing off for that call.** Either one can change what is requested, so that call gets a request of its own.
725
+ - **Params that can't be compared safely, such as a `Blob` or a circular structure, turn sharing off** ([full list](#share-key-and-refcount)). That call gets its own request.
726
+ - **A `Date`, `Map`, `Set`, typed array or string param shares normally.**
727
+ - **A per-call `signal` or `timeout` only lets that caller leave.** The caller that gives up gets `kind: 'abort'` or `'timeout'`, reported to [`onError`](#reporting-errors-with-onerror) as an unshared call would be. The request keeps running for the others, and is cancelled once every caller has given up.
728
+ - **The endpoint's own `timeout` bounds the shared request for everyone.** It counts from when the request started. A caller that joins late can't extend it, and `timeout: 0` on one call can't turn it off.
480
729
 
481
730
  ```ts
482
- const { data, error, response, retry } = await api.getUser({ id: '42' })
483
-
484
- if (error) {
485
- switch (error.kind) {
486
- case 'network':
487
- // fetch itself failed -- user is probably offline
488
- break
489
- case 'timeout':
490
- // the whole-operation deadline fired; report it
491
- reportTimeout(error)
492
- break
493
- case 'abort':
494
- // this call was cancelled (dedupe supersede, or your own signal) -- usually ignore it
495
- break
496
- case 'parse':
497
- // a 2xx response arrived but its body didn't parse as `responseType`
498
- console.error('unparseable response', error.status, error.body)
499
- break
500
- case 'middleware':
501
- // a middleware threw -- a bug in your own pipeline, not a transient failure
502
- console.error('middleware threw', error.body)
503
- break
504
- case 'http':
505
- if (error.status === 401) redirectToLogin()
506
- else console.error(error.status, error.body)
507
- break
508
- }
509
- return
510
- }
731
+ const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
732
+ const patient = api.getProduct({ id: '42' }) // keeps waiting
511
733
 
512
- // error is null here, so `data` is narrowed to `User` -- no assertion needed
513
- // (this assumes getUser always answers with a body; an endpoint that
514
- // doesn't -- a DELETE returning 204, most commonly -- should use
515
- // responseType: 'none' instead, see the empty-body note above)
516
- console.log(data.name)
734
+ // impatient's timeout doesn't cancel the shared request, so patient still gets the response.
517
735
  ```
518
736
 
519
- `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.)
737
+ Two rarer cases have their own notes: [`retry()` on a shared result](#retry-on-a-shared-result) and [middleware that replaces the signal](#signal-replacing-middleware).
520
738
 
521
- #### `retry()`
739
+ #### On a server
522
740
 
523
- 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:
741
+ Sharing is decided before middleware runs. A header that a middleware adds, such as the current user's token, is not part of the match. With one client serving every user, one user's call can join another user's call and receive that user's response.
524
742
 
525
- ```ts
526
- const { data, error, retry } = await api.getUser({ id: '42' })
743
+ Create one client per incoming request, as in [One /me per page view on the server](#one-me-per-page-view-on-the-server). Otherwise, don't set `share` on an endpoint whose answer depends on who is asking.
527
744
 
528
- if (error?.status === 401) {
529
- await refreshToken()
530
- const retried = await retry()
531
- // retried goes through the full middleware chain again
532
- }
533
- ```
745
+ ### Retries, caching and logging
534
746
 
535
- #### `ApiError`
747
+ Some failures go away when you try again. Some reads repeat often enough to keep. And while you build, you want to see every call. liaise ships a middleware for each, in a separate entry point:
536
748
 
537
- The error object on failed calls. It is not a subclass of `Error` -- it is a structured container for API-level error details.
749
+ ```ts
750
+ import { retryMiddleware, cacheMiddleware, logMiddleware } from 'liaise/middleware'
751
+ ```
538
752
 
539
- | Property | Type | Description |
540
- | ------------ | --------- | ----------------------------------------------------------------- |
541
- | `status` | `number` | HTTP status code (e.g., 404, 500). `0` for network errors, aborts, and timeouts. |
542
- | `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. |
543
- | `statusText` | `string` | HTTP status text (e.g., 'Not Found'). `''` for network errors. |
544
- | `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. |
545
- | `headers` | `Headers` | Response headers. Empty `Headers` for network errors. |
546
- | `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. |
547
- | `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. |
753
+ #### Retry failed calls
548
754
 
549
- `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`.
755
+ `retryMiddleware(2)` retries a failed call up to two more times, three attempts in all. By default it retries only 5xx responses. Any 4xx, including a 429, comes back as it is, and so does a network error. To retry 429s and network errors too, pass `retryOn`:
550
756
 
551
- `'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
552
- parsing](#response-parsing). GraphQL applies the same rule to a 2xx response
553
- 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.
757
+ ```ts
758
+ import { createApi, defineRequest } from 'liaise'
759
+ import { retryMiddleware } from 'liaise/middleware'
554
760
 
555
- `'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).
761
+ type Item = { id: string }
556
762
 
557
- You can use `instanceof` to check if a value is an `ApiError`:
763
+ const getItems = defineRequest<Item[]>()({ method: 'GET', path: '/items' })
558
764
 
559
- ```ts
560
- import { ApiError } from 'liaise'
765
+ const api = createApi({
766
+ baseUrl: 'https://api.example.com',
767
+ requests: { getItems },
768
+ middleware: [retryMiddleware(2)], // 5xx only
769
+ })
561
770
 
562
- if (error instanceof ApiError) {
563
- // ...
564
- }
771
+ // Also retry 429 and network errors. An abort has status 0 too, so check kind.
772
+ const retryMore = retryMiddleware({
773
+ retryOn: (r) =>
774
+ (r.error?.status ?? 0) >= 500 || r.error?.status === 429 || r.error?.kind === 'network',
775
+ })
565
776
  ```
566
777
 
567
- ### Error handling with `onError`
568
-
569
- 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.
778
+ Pass an object to tune the waits between attempts:
570
779
 
571
780
  ```ts
572
- const api = createApi({
573
- baseUrl: '/api',
574
- requests: { getUser, createUser },
575
- onError: (error) => {
576
- if (error.status === 401) redirectToLogin()
577
- Sentry.captureException(error)
578
- }
781
+ const retry = retryMiddleware({
782
+ max: 5, // up to 5 retries after the first attempt
783
+ delay: 'exponential', // 250 ms, 500 ms, 1 s, ... before jitter
784
+ onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
579
785
  })
580
786
  ```
581
787
 
582
- 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.
788
+ - By default the waits grow exponentially with random jitter, and a `Retry-After` header from the server is honoured. Every option is in [RetryOptions](#retryoptions).
789
+ - **A cancel or a deadline during a wait ends the call with that error.** If your `timeout`, your signal or a newer `dedupe` call fires between attempts, you get `kind: 'timeout'` or `'abort'`. The 503 that caused the retry is dropped.
583
790
 
584
- ### Middleware
791
+ #### Cache repeated reads
585
792
 
586
- 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.
793
+ `cacheMiddleware()` keeps successful responses in memory, so a repeated read within the time-to-live skips the network. Each `cacheMiddleware()` call makes its own store. Give it to the endpoints you want cached:
587
794
 
588
- ```
589
- Request → [Global MW → [Per-request MW → [Per-call MW → [fetch]]]]
590
- ```
795
+ ```ts
796
+ import { createApi, defineRequest } from 'liaise'
797
+ import { cacheMiddleware } from 'liaise/middleware'
591
798
 
592
- A middleware function receives a `context` and a `next` function:
799
+ type User = { id: string; name: string }
593
800
 
594
- ```ts
595
- import type { Middleware } from 'liaise'
801
+ const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
596
802
 
597
- const authMiddleware: Middleware = async (ctx, next) => {
598
- // Before: modify the request
599
- ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
803
+ const getUser = defineRequest<User>()({
804
+ method: 'GET',
805
+ path: '/users/:id',
806
+ middleware: [getUserCache],
807
+ })
600
808
 
601
- // Call the next layer
602
- const result = await next()
809
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
603
810
 
604
- // After: inspect or transform the result
605
- return result
606
- }
811
+ // On logout, clear every cached entry:
812
+ getUserCache.clear()
813
+
814
+ // Skip the cache for one call:
815
+ const { data } = await api.getUser({ id: '42' }, { skipMiddleware: [getUserCache] })
607
816
  ```
608
817
 
609
- #### What middleware can do
818
+ - **Only successes are cached.** An error always goes to the network again.
819
+ - **The key includes the URL, the params and the headers.** When your auth header is set before the cache runs, one user never sees another user's entry. [Cache key](#cache-key) has the details.
820
+ - **Put a middleware that adds a unique header to each call after `cacheMiddleware`.** A request ID added before it makes every call look new, and nothing is ever cached. Client middleware always runs before endpoint middleware, so either list the request-ID middleware on the endpoint after the cache, or put the cache on the client before it.
821
+ - The options are `ttl`, `maxSize` and `debug` ([Cache options](#cache-options)).
610
822
 
611
- - **Modify the request** -- set headers, change the body, rewrite the URL.
612
- - **Short-circuit** -- return early without calling `next()` (e.g., serve from cache).
613
- - **Retry** -- call `next()` multiple times in a loop (e.g., retry on 5xx).
614
- - **Inspect the result** -- log, report errors, transform response data.
823
+ #### Log every call
615
824
 
616
- #### Three layers
825
+ `logMiddleware` prints each call's start and end to the console, with its duration:
617
826
 
618
- Middleware is applied at three levels. The execution order is global first, per-request second, per-call third:
827
+ ```text
828
+ [liaise] → GET getItems /api/items
829
+ [liaise] ← getItems OK (142ms)
619
830
 
620
- ```ts
621
- // Global -- applies to every endpoint
622
- const api = createApi({
623
- baseUrl: '/api',
624
- requests: { getUser, createUser },
625
- middleware: [authMiddleware, logMiddleware]
626
- })
831
+ [liaise] → POST createUser /api/users
832
+ [liaise] ← createUser ERROR 422 (89ms)
833
+ ```
627
834
 
628
- // Per-request -- applies only to this endpoint
629
- const getUser = new Request<{ id: string }, User>({
630
- method: 'GET',
631
- path: '/users/:id',
632
- middleware: [cacheMiddleware]
633
- })
835
+ ```ts
836
+ import { createApi, defineRequest } from 'liaise'
837
+ import { logMiddleware } from 'liaise/middleware'
634
838
 
635
- // Per-call -- applies only to this single invocation
636
- await api.getUser({ id: '42' }, {
637
- middleware: [customTraceMiddleware]
638
- })
839
+ const getItems = defineRequest<{ id: string }[]>()({ method: 'GET', path: '/items' })
840
+
841
+ const api = createApi({ baseUrl: '/api', requests: { getItems }, middleware: [logMiddleware] })
639
842
  ```
640
843
 
641
- #### `skipMiddleware`
844
+ It is meant for development. In production, [write a middleware](#writing-middleware) that sends the same facts to your monitoring.
642
845
 
643
- Remove specific middleware for a single call by passing references to `skipMiddleware`:
846
+ ### Writing middleware
847
+
848
+ Some things belong on every call, such as an auth header or tracing, and you don't want to repeat them at each call site. A middleware is a function that wraps a call. It sees the request before it is sent and the `Result` after.
644
849
 
645
850
  ```ts
646
- const retry = retryMiddleware(3)
851
+ import { createApi, defineRequest, type Middleware } from 'liaise'
852
+
853
+ type User = { id: string; name: string }
854
+
855
+ const auth: Middleware = async (ctx, next) => {
856
+ // Before: change the request
857
+ ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
858
+
859
+ // Run the rest of the chain, ending in fetch
860
+ const result = await next()
861
+
862
+ // After: read or change the result
863
+ return result
864
+ }
865
+
866
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
647
867
 
648
868
  const api = createApi({
649
- baseUrl: '/api',
869
+ baseUrl: 'https://api.example.com',
650
870
  requests: { getUser },
651
- middleware: [retry, logMiddleware]
871
+ middleware: [auth],
652
872
  })
873
+ ```
653
874
 
654
- // Skip retry for this one call
655
- await api.getUser({ id: '42' }, {
656
- skipMiddleware: [retry]
657
- })
875
+ `getToken` stands for wherever you keep the token.
876
+
877
+ Each middleware wraps the next, like the layers of an onion. The request passes in through every layer to `fetch`, and the `Result` passes back out through the same layers:
878
+
879
+ ```text
880
+ call → [client middleware → [endpoint middleware → [call middleware → [fetch]]]]
658
881
  ```
659
882
 
660
- 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.
883
+ A middleware can:
661
884
 
662
- #### `MiddlewareContext`
885
+ - **Change the request.** Set headers, change the body, rewrite the URL.
886
+ - **Answer early** without calling `next()`, for example from a cache.
887
+ - **Call `next()` more than once**, for example to retry a 5xx.
888
+ - **Read the result** to log it, report an error or transform `data`.
663
889
 
664
- The context object passed to each middleware:
890
+ Middleware runs at three levels, the client's first and the call's last, as [Three levels of settings](#three-levels-of-settings) shows.
665
891
 
666
- | Property | Type | Description |
667
- | --------------------- | --------- | -------------------------------------------------------- |
668
- | `request.method` | `string` | HTTP method (GET, POST, etc.) |
669
- | `request.url` | `string` | Fully resolved URL with path params and query string |
670
- | `request.path` | `string` | Original path template (e.g., '/users/:id') |
671
- | `request.params` | `unknown` | Original params object from the caller |
672
- | `request.headers` | `Headers` | Merged headers -- middleware can add/remove entries |
673
- | `request.body` | `unknown` | Serialized body, or null for GET/DELETE |
674
- | `request.signal` | `AbortSignal \| undefined` | The signal handed to `fetch` -- replace it to impose your own cancellation policy |
675
- | `requestName` | `string` | Key name in the requests object (e.g., 'getUser') |
892
+ #### Skipping a middleware for one call
676
893
 
677
- `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:
894
+ Pass the middleware itself in `skipMiddleware`:
678
895
 
679
896
  ```ts
680
- const timeout = (ms: number): Middleware => async (ctx, next) => {
681
- ctx.request.signal = AbortSignal.timeout(ms)
682
- return next()
683
- }
897
+ import { createApi, defineRequest } from 'liaise'
898
+ import { retryMiddleware, logMiddleware } from 'liaise/middleware'
899
+
900
+ type User = { id: string; name: string }
901
+
902
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
903
+ const retry = retryMiddleware(3)
684
904
 
685
905
  const api = createApi({
686
- baseUrl: '/api',
906
+ baseUrl: 'https://api.example.com',
687
907
  requests: { getUser },
688
- middleware: [timeout(5000)]
908
+ middleware: [retry, logMiddleware],
689
909
  })
690
- ```
691
910
 
692
- 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.
911
+ // No retries for this one call
912
+ await api.getUser({ id: '42' }, { skipMiddleware: [retry] })
913
+ ```
693
914
 
694
- **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.
915
+ liaise compares by reference (`===`). Store what a factory such as `retryMiddleware(3)` returns in a variable first. Calling the factory again makes a new function, which won't match.
695
916
 
696
- #### Writing custom middleware
917
+ #### What a middleware sees
697
918
 
698
- A cache middleware that short-circuits on cache hits:
919
+ `ctx.requestName` is the endpoint's key, such as `'getUser'`. `ctx.request` holds the request as it will be sent, with the URL filled in and the body serialized. Every field is listed under [MiddlewareContext](#middlewarecontext).
699
920
 
700
- ```ts
701
- const cacheMiddleware: Middleware = async (ctx, next) => {
702
- const cached = cache.get(ctx.request.url)
703
- if (cached) return cached
921
+ #### Signals in middleware
704
922
 
705
- const result = await next()
923
+ `ctx.request.signal` combines the caller's `signal` with the call's `timeout`, and is `undefined` when there is neither. liaise reads it when it calls `fetch`, so a middleware can replace it. [Give each attempt its own timeout](#give-each-attempt-its-own-timeout) does this, and says what happens to a caller's cancel. How a replaced signal works with `share` and `dedupe` is under [Signal-replacing middleware](#signal-replacing-middleware).
706
924
 
707
- if (result.data) {
708
- cache.set(ctx.request.url, result)
709
- }
925
+ **Pass `ctx.request.signal` on to async work your middleware does itself**, such as a token refresh, a lookup or a queue. liaise won't wait for that work past the deadline or the caller's cancel ([Timeout backstop](#timeout-backstop)). A promise can't be stopped from outside, so passing the signal is the only way to end the work. Without it, the work keeps running and its result is thrown away.
710
926
 
711
- return result
712
- }
713
- ```
927
+ #### Example: report server errors
714
928
 
715
- An error reporting middleware:
929
+ This middleware sends every 5xx to Sentry, with the method and URL:
716
930
 
717
931
  ```ts
718
- const sentryMiddleware: Middleware = async (ctx, next) => {
932
+ import type { Middleware } from 'liaise'
933
+ import * as Sentry from '@sentry/browser'
934
+
935
+ const reportServerErrors: Middleware = async (ctx, next) => {
719
936
  const result = await next()
720
937
 
721
938
  if (result.error && result.error.status >= 500) {
722
939
  Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
723
- extra: { status: result.error.status, body: result.error.body }
940
+ extra: { status: result.error.status, body: result.error.body },
724
941
  })
725
942
  }
726
943
 
@@ -728,309 +945,455 @@ const sentryMiddleware: Middleware = async (ctx, next) => {
728
945
  }
729
946
  ```
730
947
 
731
- #### Built-in middleware
948
+ ### Pagination
732
949
 
733
- The library ships three optional middleware functions, importable from a separate entry point:
950
+ Many list endpoints return one page at a time. `paginate` walks through the pages and gives you one `Result` per page:
734
951
 
735
952
  ```ts
736
- import { retryMiddleware, logMiddleware, cacheMiddleware } from 'liaise/middleware'
737
- ```
953
+ import { createApi, defineRequest, paginate } from 'liaise'
738
954
 
739
- **`retryMiddleware(options?: number | RetryOptions)`**
955
+ type Item = { id: string; name: string }
956
+ type Page = { items: Item[]; cursor?: string }
740
957
 
741
- 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.
958
+ const listItems = defineRequest<Page, { limit: number; cursor?: string }>()({
959
+ method: 'GET',
960
+ path: '/items',
961
+ })
742
962
 
743
- The numeric shorthand still works exactly as before — `retryMiddleware(2)` retries up to 2 additional times (3 total attempts) on a 5xx response:
963
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { listItems } })
744
964
 
745
- ```ts
746
- const api = createApi({
747
- baseUrl: '/api',
748
- requests: { getItems },
749
- middleware: [retryMiddleware(2)]
750
- })
965
+ for await (const page of paginate(api.listItems, { limit: 50 }, {
966
+ next: (p, prev) => (p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined),
967
+ })) {
968
+ if (page.error) break
969
+ render(page.data.items)
970
+ }
751
971
  ```
752
972
 
753
- 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.
973
+ `render` stands for your own code.
974
+
975
+ - **`next` returns the params for the next page.** It gets the page just loaded and the params that loaded it, so the usual case is a spread. liaise never has to guess whether your API calls it `cursor`, `page_token` or `after`, and the same shape covers every scheme:
976
+
977
+ ```ts
978
+ // offset
979
+ next: (p, prev) => p.data.items.length === prev.limit
980
+ ? { ...prev, offset: prev.offset + prev.limit }
981
+ : undefined
982
+
983
+ // page number, driven by a Link header
984
+ next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
985
+ ? { ...prev, page: prev.page + 1 }
986
+ : undefined
987
+ ```
754
988
 
755
- **Retry policy**
989
+ - **Return `undefined` or `null` to stop.**
990
+ - **An error page ends the walk.** You get the error page, and then the loop ends, because there is no data to read the next cursor from. You see what failed. The loop never stops quietly.
991
+ - **`maxPages` has no default.** Set it if you want a ceiling, as in `paginate(api.listItems, { limit: 50 }, { next, maxPages: 100 })`. liaise doesn't pick a number, because a silent cut-off at an arbitrary page looks exactly like reaching the last one.
992
+ - **Every other option applies to every page.** Any of the [`CallOptions`](#calloptions), such as `signal`, `timeout` or `headers`, goes with each request, so one signal cancels the whole walk.
993
+ - **`paginate` yields pages.** Read the items from each page yourself. Flattening them would mean guessing which field holds the array.
756
994
 
757
- Pass a `RetryOptions` object instead of a number for full control:
995
+ ### GraphQL
996
+
997
+ `createGraphQL` is the client for a GraphQL backend. Its calls return the same `Result`, run the same middleware and report errors the same way. Every operation is sent as a POST with `{ query, variables }`.
758
998
 
759
999
  ```ts
760
- const api = createApi({
761
- baseUrl: '/api',
762
- requests: { getItems },
763
- middleware: [retryMiddleware({
764
- max: 5,
765
- delay: 'exponential',
766
- baseDelay: 250,
767
- maxDelay: 10_000,
768
- jitter: true,
769
- respectRetryAfter: true,
770
- onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
771
- })],
1000
+ import { createGraphQL, Operation, gql } from 'liaise'
1001
+
1002
+ type Category = { id: string; name: string; status: string }
1003
+
1004
+ const GET_CATEGORY = gql`
1005
+ query GetCategory($id: String!) {
1006
+ category(id: $id) {
1007
+ id
1008
+ name
1009
+ status
1010
+ }
1011
+ }
1012
+ `
1013
+
1014
+ const getCategory = new Operation<{ id: string }, Category>({
1015
+ operation: GET_CATEGORY,
1016
+ })
1017
+
1018
+ const graphql = createGraphQL({
1019
+ endpoint: 'https://api.example.com/graphql',
1020
+ operations: { getCategory },
1021
+ onError: (error) => console.error(error.status, error.body),
772
1022
  })
773
- ```
774
1023
 
775
- | Option | Type | Default | Description |
776
- | -------------------- | -------------------------------------------------- | ----------------- | ----------- |
777
- | `max` | `number` | `3` | Additional attempts after the first. `retryMiddleware({ max: 2 })` means up to 3 total calls. |
778
- | `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. |
779
- | `baseDelay` | `number` | `250` | The first delay, in milliseconds, before jitter and `Retry-After` are applied. |
780
- | `maxDelay` | `number` | `30000` | Hard cap applied to every computed delay, including a `Retry-After` value. |
781
- | `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. |
782
- | `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`). |
783
- | `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. |
784
- | `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. |
1024
+ const { data, error, response, retry } = await graphql.getCategory({ id: '123' })
1025
+ ```
785
1026
 
786
- `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.
1027
+ `Operation<TVariables, TData>` takes the variables type first and the response type second. `gql` marks the string as GraphQL for your editor.
787
1028
 
788
- **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:
1029
+ An operation with no variables is called without arguments. Write `Record<string, never>` as its variables type:
789
1030
 
790
1031
  ```ts
791
- // Retry 429 in addition to 5xx
792
- retryMiddleware({
793
- retryOn: r => r.error?.status === 429 || (r.error?.status ?? 0) >= 500
794
- })
1032
+ type Viewer = { id: string; name: string }
795
1033
 
796
- // Retry network errors (status 0) too — but not aborts, which are also status 0
797
- retryMiddleware({
798
- retryOn: r => (r.error?.status ?? 0) >= 500 || r.error?.kind === 'network'
1034
+ const getViewer = new Operation<Record<string, never>, Viewer>({
1035
+ operation: gql`query { viewer { id name } }`,
799
1036
  })
1037
+
1038
+ const api = createGraphQL({ endpoint: 'https://api.example.com/graphql', operations: { getViewer } })
1039
+
1040
+ const { data } = await api.getViewer() // no arguments
800
1041
  ```
801
1042
 
802
- **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:
1043
+ #### Queries and mutations
1044
+
1045
+ To keep queries and mutations apart, use the `queries` and `mutations` keys instead of `operations`. Each client uses one shape or the other, and TypeScript refuses both together.
803
1046
 
804
1047
  ```ts
805
- const api = createApi({
806
- baseUrl: '/api',
807
- requests: {
808
- getItems: new Request<Record<string, never>, Item[]>({
809
- method: 'GET',
810
- path: '/items',
811
- timeout: 2000, // whole-operation deadline
812
- })
1048
+ const graphql = createGraphQL({
1049
+ endpoint: 'https://api.example.com/graphql',
1050
+ queries: {
1051
+ getCategory: new Operation<{ id: string }, Category>({ operation: GET_CATEGORY }),
1052
+ },
1053
+ mutations: {
1054
+ updateCategory: new Operation<{ id: string; name: string }, Category>({
1055
+ operation: gql`
1056
+ mutation UpdateCategory($id: String!, $name: String!) {
1057
+ updateCategory(id: $id, name: $name) { id name status }
1058
+ }
1059
+ `,
1060
+ }),
813
1061
  },
814
- middleware: [retryMiddleware({ max: 5, baseDelay: 1000 })], // long backoff
815
1062
  })
816
1063
 
817
- const { error } = await api.getItems()
818
- // If the 2s deadline fires while retryMiddleware is asleep between attempts:
819
- // error.status === 0, error.kind === 'timeout' -- not the 503 being retried
1064
+ graphql.query.getCategory({ id: '123' })
1065
+ graphql.mutation.updateCategory({ id: '123', name: 'New Name' })
820
1066
  ```
821
1067
 
822
- **`logMiddleware`**
1068
+ #### GraphQL errors
823
1069
 
824
- Logs request start and completion to the console with timing:
1070
+ A 2xx response with `{ errors: [...] }` is an error. It has `kind: 'http'`, the response's own `status`, and the `GraphQLError[]` in `error.body`. The same `if (error)` check covers GraphQL errors, HTTP errors and network errors.
825
1071
 
826
- ```
827
- [liaise] → GET getItems /api/items
828
- [liaise] ← getItems OK (142ms)
1072
+ GraphQL allows partial success, where one field fails and the rest of the query resolves. That data is kept in `error.partialData`. `result.data` stays `null` whenever `error` is set, so `Result` keeps its two clean branches.
829
1073
 
830
- [liaise] → POST createUser /api/users
831
- [liaise] ← createUser ERROR 422 (89ms)
1074
+ ```ts
1075
+ const { error } = await graphql.getCategory({ id: '123' })
1076
+ if (error) {
1077
+ console.log(error.body) // GraphQLError[]
1078
+ console.log(error.partialData) // the data the server sent with the errors, or undefined
1079
+ }
832
1080
  ```
833
1081
 
834
- Intended for development. In production, write a custom middleware that sends telemetry to your observability platform.
1082
+ - **A 2xx with neither `data` nor `errors` is a `'parse'` error**, the same rule as an empty body on REST. That covers an empty body, `{}`, `{"data": null}` and a JSON root that isn't an object. `error.body` holds the raw response text, such as `''` or `'{}'`.
1083
+ - **`{"data": null, "errors": [...]}` is still `kind: 'http'`**, because the errors are checked first. Any partial result is in `error.partialData`.
1084
+
1085
+ #### Same as REST, and different
1086
+
1087
+ Most of what the guide says about `createApi` holds for `createGraphQL`.
1088
+
1089
+ **The same as REST**
1090
+
1091
+ - **Every call returns a `Result`**, `{ data, error, response, retry }`, and `error.kind` names the failure.
1092
+ - **`middleware`** runs on the client, the `Operation` and the call, in the same order. The context has the same shape, so the [built-in middleware](#retries-caching-and-logging) and yours work unchanged.
1093
+ - **`headers`** go on the client, the `Operation` and the call, and merge by the [three levels](#three-levels-of-settings).
1094
+ - **`onError`** goes on `createGraphQL` and works as in [Reporting errors with onError](#reporting-errors-with-onerror).
1095
+ - **`retry()`** is on every `Result`.
1096
+ - **`dedupe`** goes on the `Operation`, as in [Drop stale calls with dedupe](#drop-stale-calls-with-dedupe).
1097
+ - **`timeout`** goes on the `Operation` or the call. It is one deadline for the whole call, retries included.
1098
+ - **`schema`** goes on the `Operation` and validates the response's `data`. The response type stays explicit ([Validating responses](#validating-responses)).
1099
+ - **`signal` and `skipMiddleware`** go on the call.
1100
+
1101
+ **Different from REST**
1102
+
1103
+ - **`endpoint`** is the full URL of the GraphQL endpoint. It takes the place of `baseUrl`.
1104
+ - **Variables always go in the JSON body.** There are no path params, no query strings and no `bodyAs`.
1105
+ - **Every operation is a `POST`**, with `Content-Type: application/json` unless you set your own.
1106
+ - **There is no `share`.** An `Operation` can't join identical calls.
1107
+ - **There is no `responseType`.** The response is always read as JSON.
1108
+
1109
+ ### Testing your code
1110
+
1111
+ `liaise/testing` gives you a `fetch` stub that matches routes. Your tests then run the real pipeline, from URL building, headers and body to parsing and your own middleware. A stubbed API method that returns a canned `Result` drifts away from what liaise actually does. The stub needs no test runner, so it works in Vitest, Jest or anything else.
835
1112
 
836
1113
  ```ts
837
- const api = createApi({
838
- baseUrl: '/api',
839
- requests: { getItems },
840
- middleware: [logMiddleware]
1114
+ import { mockFetch, jsonResponse } from 'liaise/testing'
1115
+
1116
+ const mock = mockFetch({
1117
+ 'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
1118
+ 'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
841
1119
  })
1120
+
1121
+ mock.install() // replaces globalThis.fetch
1122
+ // ... exercise your code, which calls the real api.getUser(...) ...
1123
+ mock.restore() // puts the original globalThis.fetch back
842
1124
  ```
843
1125
 
844
- **`cacheMiddleware(options?)`**
1126
+ - **Routes are keyed as `"METHOD /path"`.** Each `:token` segment is captured and passed to a route function as `{ params, request }`.
1127
+ - **A route value is a `Response`, a route function, or an array of either.** `jsonResponse` builds a `Response` with a JSON body, or you can build your own.
1128
+ - **An array is a sequence.** Each matching call takes the next entry, and the last entry repeats once the others are used up. That suits "fail twice, then succeed":
1129
+
1130
+ ```ts
1131
+ const mock = mockFetch({
1132
+ 'GET /api/flaky': [jsonResponse(null, { status: 503 }), jsonResponse({ ok: true })],
1133
+ })
1134
+ ```
845
1135
 
846
- Caches successful responses in memory, keyed by request name, method, the full URL, params and every request header except `Content-Type` (which is derived from the params). The URL's query string is part of the key with its pairs sorted by name, so `?a=1&b=2` and `?b=2&a=1` are one entry, while `?key=A` and `?key=B` (or a `?lang=de` appended by a middleware before the cache) are not. Calls that agree on all of those within the TTL window are served from cache without hitting the network. A different `Authorization` or any other header (except `Content-Type`), a different base URL, path or query value gets its own entry, so one user is never served another's response; the same query params in a different order share one. The query is sorted by raw (undecoded) name, keeping the order of repeated names. The trade-off: a middleware that adds a per-call unique header (a request ID, say) must come *after* `cacheMiddleware` in the middleware array; placed before it, every call carries a fresh header and nothing is ever cached. Each `cacheMiddleware()` call creates an isolated store — different endpoints never share entries.
1136
+ - **`mock.calls` records every request** as `{ method, url, headers, body }`. `mock.callCount('GET /api/users/:id')` and `mock.lastCall(...)` take the same `"METHOD /path"` keys as the routes.
847
1137
 
848
- 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).
1138
+ #### Unmatched routes
1139
+
1140
+ A request that matches no route makes the stub throw, so a typo in a path can't pass quietly. liaise catches every `fetch` rejection, so your code sees an ordinary `Result` with `kind: 'network'` and the `Error` in `error.body`. Its message names the method, the URL and every route you defined. Assert on the result, because the call itself never rejects.
849
1141
 
850
1142
  ```ts
851
- const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
1143
+ const r = await api.getUser({ id: '42' }) // routes only define 'GET /api/user/:id'
1144
+ expect(r.error?.kind).toBe('network')
1145
+ expect(String(r.error?.body)).toMatch(/no route matched GET \/api\/users\/42/)
1146
+ ```
852
1147
 
853
- const getUser = new Request<{ id: string }, User>({
854
- method: 'GET',
855
- path: '/users/:id',
856
- middleware: [getUserCache],
857
- })
1148
+ `api` is a client created with `baseUrl: '/api'`.
858
1149
 
859
- // On logout — clear all cached entries:
860
- getUserCache.clear()
1150
+ An empty array for a route behaves the same way, with a descriptive `Error` in `error.body`.
861
1151
 
862
- // Bypass cache for a single call:
863
- const { data } = await api.getUser({ id: '42' }, { skipMiddleware: [getUserCache] })
1152
+ #### Stubbing a Result directly
1153
+
1154
+ To stub at the `Result` level instead of the `fetch` level, `successResult(data)` and `errorResult(status, body)` build a well-formed `Result`. This uses Vitest's `vi.spyOn`, and any runner's equivalent works the same way:
1155
+
1156
+ ```ts
1157
+ import { successResult, errorResult } from 'liaise/testing'
1158
+
1159
+ vi.spyOn(api, 'getUser').mockResolvedValue(successResult({ id: '42', name: 'Ada' }))
1160
+ vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
864
1161
  ```
865
1162
 
866
- 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.
1163
+ More of the stub's behaviour, such as how it handles a signal, is under [liaise/testing](#liaisetesting).
867
1164
 
868
- ### Content types
1165
+ ## Recipes
869
1166
 
870
- Request bodies are automatically serialized based on the input type. The `Content-Type` header is set for you unless you explicitly provide one.
1167
+ Each recipe below is a complete example that runs against a test, so you can paste it as it is.
871
1168
 
872
- | Input type | Body output | Content-Type |
873
- | ----------------- | ------------------ | ------------------------------------- |
874
- | `null`/`undefined` | `null` | _(none)_ |
875
- | `string` | as-is | `text/plain` |
876
- | `FormData` | as-is | _(browser sets multipart boundary)_ |
877
- | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
878
- | `Blob` | as-is | `application/octet-stream` |
879
- | `ArrayBuffer` | as-is | `application/octet-stream` |
880
- | Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
881
- | `ReadableStream` | as-is (streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
882
- | Plain object | `JSON.stringify()` | `application/json` |
1169
+ ### Add an auth header and refresh the token on a 401
883
1170
 
884
- A `ReadableStream` body can be sent once. A retry (`retryMiddleware`, `result.retry()`) returns an error Result telling you to read the stream into a `Blob` or `ArrayBuffer` first. Under `retryMiddleware` that is the Result you end up with: after a 5xx, the final Result is the "cannot resend" `TypeError` (status 0), so the original 503 is not in it.
1171
+ With rotating refresh tokens, five requests that all get a 401 at once must not refresh five times: the first refresh invalidates the token the other four send, and the user is logged out.
885
1172
 
886
- #### What params can be
1173
+ <!-- tested: auth-refresh -->
1174
+ ```ts
1175
+ import { createApi, defineRequest, type Middleware } from 'liaise'
887
1176
 
888
- | You pass | What happens |
889
- | -------- | ------------ |
890
- | Plain object | Decomposed into path tokens, query string and body |
891
- | `Map` with string keys | Same as the object it spells |
892
- | Class instance with fields | Same as a plain object (decomposed by those fields even if the class also defines `toJSON()`; `toJSON()` is used only when there are no own fields) |
893
- | Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
894
- | Typed array, `DataView`, `Buffer`, `ReadableStream` | Sent as the body, as in the table above (refused on a request whose params go in the query string) |
895
- | `Set`, a bare `Date`, a `Map` with non-string keys, a class with no fields | Refused: an error Result (`kind: 'network'`) naming the type. Nothing is sent. |
1177
+ type Tokens = { access: string; refresh: string }
1178
+ type Order = { id: string; total: number }
896
1179
 
897
- A `Map`, `Set` or class with private state nested inside a JSON body is sent as `{}`, because that is what `JSON.stringify` does. Convert it first.
1180
+ let tokens: Tokens = { access: 'expired', refresh: 'r1' }
898
1181
 
899
- Header merge precedence (most specific wins):
1182
+ const auth: Middleware = async (ctx, next) => {
1183
+ if (ctx.requestName === 'refresh') return next()
900
1184
 
901
- 1. **Global headers** (from `createApi` config) -- lowest priority
902
- 2. **Per-request headers** (from `Request` config) -- overrides global
903
- 3. **Per-call headers** (from `CallOptions`) -- highest priority
1185
+ const sentWith = tokens.access
1186
+ ctx.request.headers.set('Authorization', `Bearer ${sentWith}`)
1187
+ const result = await next()
1188
+ if (result.error?.status !== 401) return result
1189
+
1190
+ // Refresh only if nobody has done it since this call was sent. Every call
1191
+ // that gets here at the same moment joins one refresh request (share: true).
1192
+ if (tokens.access === sentWith) {
1193
+ const refreshed = await api.refresh({ token: tokens.refresh })
1194
+ if (refreshed.error) return result // refresh failed: keep the 401
1195
+ tokens = refreshed.data
1196
+ }
1197
+ ctx.request.headers.set('Authorization', `Bearer ${tokens.access}`)
1198
+ return next() // send the call again with the new token
1199
+ }
904
1200
 
905
- Explicitly set `Content-Type` headers at any level override the auto-detected value.
1201
+ const api = createApi({
1202
+ baseUrl: '/api',
1203
+ middleware: [auth],
1204
+ requests: {
1205
+ refresh: defineRequest<Tokens, { token: string }>()({
1206
+ method: 'POST',
1207
+ path: '/auth/refresh',
1208
+ share: true,
1209
+ }),
1210
+ getOrders: defineRequest<Order[]>()({ method: 'GET', path: '/orders' }),
1211
+ },
1212
+ })
1213
+ ```
906
1214
 
907
- ### Response parsing
1215
+ `share: true` on `refresh` is what makes the five calls wait for one refresh. The `requestName` check stops the refresh call from passing through its own middleware.
908
1216
 
909
- The `responseType` option on a `Request` determines how the response body is parsed:
1217
+ ### Search as you type
910
1218
 
911
- | `responseType` | Method called | Return type |
912
- | --------------- | ---------------------- | -------------- |
913
- | `'json'` | `response.text()` then `JSON.parse()` | parsed object |
914
- | `'text'` | `response.text()` | `string` |
915
- | `'blob'` | `response.blob()` | `Blob` |
916
- | `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
917
- | `'formData'` | `response.formData()` | `FormData` |
918
- | `'none'` | *(not read -- stream cancelled)* | `undefined` |
1219
+ Each keystroke starts a request, and a slow answer to an early one can arrive after a fast answer to a later one. `dedupe` makes sure only the latest search is shown.
919
1220
 
920
- The default is `'json'`. An **empty body under `'json'` is an error**, not a
921
- `null`: you declared JSON and the server sent none, so there is no value that
922
- could honestly satisfy `TResponse`. You get `kind: 'parse'` with the
923
- response's own status (a `204` reports `204`), a non-null `response`, and the
924
- raw body text -- always `''` for this case -- in `error.body`:
1221
+ `Repo` is your result type; `render` and `showError` stand for your UI code.
925
1222
 
1223
+ <!-- tested: search-as-you-type -->
926
1224
  ```ts
927
- const { data, error } = await api.deleteUser({ id: '42' })
928
- // 204 No Content, responseType left at the 'json' default:
929
- // error.kind === 'parse', error.status === 204, data === null
1225
+ import { createApi, defineRequest } from 'liaise'
1226
+
1227
+ const search = defineRequest<Repo[], { q: string }>()({
1228
+ method: 'GET',
1229
+ path: '/search',
1230
+ dedupe: true, // a new call cancels the one still in flight
1231
+ })
1232
+ const api = createApi({ baseUrl: '/api', requests: { search } })
1233
+
1234
+ async function onInput(q: string) {
1235
+ const { data, error } = await api.search({ q })
1236
+ if (error?.kind === 'abort') return // a newer search replaced this one
1237
+ if (error) return showError(error)
1238
+ render(data)
1239
+ }
930
1240
  ```
931
1241
 
932
- A literal `null` body is **not** empty -- `JSON.parse("null")` is valid JSON,
933
- and that response still succeeds with `data: null`.
1242
+ The older call settles with an `abort` error, which you skip. Only the newest search reaches `render`.
1243
+
1244
+ ### Use with TanStack Query
934
1245
 
935
- **`responseType: 'none'`** is the declaration for an endpoint that returns no
936
- body on success -- a `204`, or a `200` with an empty body, most commonly a
937
- `DELETE`:
1246
+ liaise never throws. TanStack Query expects a failing query function to throw, so `unwrap` converts at that one boundary. `api` is the client from [Quick start](#quick-start).
938
1247
 
1248
+ <!-- tested: tanstack-query -->
939
1249
  ```ts
940
- const deleteUser = new Request<{ id: string }, undefined>({
941
- method: 'DELETE',
942
- path: '/users/:id',
943
- responseType: 'none',
1250
+ import type { Result } from 'liaise'
1251
+
1252
+ // TanStack Query expects a failed query to throw. Do it here, at your edge.
1253
+ async function unwrap<T>(call: Promise<Result<T>>): Promise<T> {
1254
+ const { data, error } = await call
1255
+ if (error) throw error
1256
+ return data
1257
+ }
1258
+
1259
+ export const userQuery = (id: string) => ({
1260
+ queryKey: ['user', id],
1261
+ // TanStack's signal cancels the request when the query is no longer needed.
1262
+ queryFn: ({ signal }: { signal: AbortSignal }) => unwrap(api.getUser({ id }, { signal })),
944
1263
  })
1264
+
1265
+ // React: useQuery(userQuery(id))
1266
+ // Vue: useQuery(computed(() => userQuery(id.value)))
1267
+ // Svelte: createQuery(() => userQuery(id))
1268
+ // Solid: useQuery(() => userQuery(id()))
945
1269
  ```
946
1270
 
947
- 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.
1271
+ The same options object works with every TanStack adapter; the comments show the call in each. These comment lines aren't executed by the test.
948
1272
 
949
- `'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:
1273
+ `error` is the `ApiError` liaise returned, which isn't an `Error` subclass, so it has no `message`. Read `error.kind` and `error.status`. To type it, register `ApiError` as TanStack's `defaultError`.
950
1274
 
1275
+ ### Use with React
1276
+
1277
+ This hook uses React's `useEffect` and `useState`, imported from `react`. `api` and `User` are from [Quick start](#quick-start).
1278
+
1279
+ <!-- tested: react-effect -->
951
1280
  ```ts
952
- const { error } = await api.deleteUser({ id: '42' })
953
- if (error) {
954
- // A 409 { "error": "already deleted" } still lands in error.body here,
955
- // even though deleteUser declares responseType: 'none'.
956
- console.error(error.status, error.body)
1281
+ function useUser(id: string) {
1282
+ const [state, setState] = useState<{ user?: User; failed?: boolean }>({})
1283
+
1284
+ useEffect(() => {
1285
+ const controller = new AbortController()
1286
+ api.getUser({ id }, { signal: controller.signal }).then(({ data, error }) => {
1287
+ if (error?.kind === 'abort') return // unmounted, or id changed
1288
+ setState(error ? { failed: true } : { user: data })
1289
+ })
1290
+ return () => controller.abort() // cancel when the component goes away
1291
+ }, [id])
1292
+
1293
+ return state
957
1294
  }
958
1295
  ```
959
1296
 
960
- (3.0.0's advice for this case was to widen the endpoint's `TResponse` to
961
- `| null`. That advice is superseded: `responseType: 'none'` declares "no body"
962
- rather than "body or null", and since 4.0.0 the `| null` workaround no longer
963
- works at all -- the empty body is an error before `TResponse` is ever
964
- consulted. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400).)
965
-
966
- ### Cancellation
1297
+ Aborting on cleanup means a fast navigation never writes stale data into a component that has moved on.
967
1298
 
968
- #### Manual abort via `AbortSignal`
1299
+ ### Load the current user into a store
969
1300
 
970
- Pass an `AbortSignal` through `CallOptions` to cancel a request:
1301
+ The header, sidebar and avatar each check the store on first render, find it empty, and each ask for the user. `User` is your type.
971
1302
 
1303
+ <!-- tested: store-me -->
972
1304
  ```ts
973
- const controller = new AbortController()
1305
+ import { createApi, defineRequest } from 'liaise'
974
1306
 
975
- const promise = api.getItems({ page: 1 }, {
976
- signal: controller.signal
977
- })
1307
+ const me = defineRequest<User>()({ method: 'GET', path: '/me', share: true })
1308
+ const api = createApi({ baseUrl: '/api', requests: { me } })
978
1309
 
979
- // Cancel the request
980
- controller.abort()
1310
+ // Any store works the same way: Zustand, Pinia, Redux or a plain object.
1311
+ const store = { user: null as User | null }
981
1312
 
982
- const { error } = await promise
983
- // error.status === 0, error.kind === 'abort', error.body is a DOMException with name 'AbortError'
1313
+ async function loadUser() {
1314
+ if (store.user) return // empty on first render, for every component
1315
+ const { data } = await api.me() // callers at the same moment join one request
1316
+ if (data) store.user = data
1317
+ }
984
1318
  ```
985
1319
 
986
- 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'`.
987
-
988
- 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).
1320
+ [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) dedupes this case too; this recipe is for apps using a plain store.
989
1321
 
990
- #### Auto-cancel via `dedupe`
1322
+ ### One /me per page view on the server
991
1323
 
992
- 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.
1324
+ On the server there's no store. A page's loaders run in parallel and each one needs the current user. `User` and `IncomingRequest` stand for your types; `cookie` stands for however you pass the user's identity.
993
1325
 
1326
+ <!-- tested: server-loaders -->
994
1327
  ```ts
995
- const searchUsers = new Request<{ q: string }, User[]>({
996
- method: 'GET',
997
- path: '/users/search',
998
- dedupe: true
999
- })
1000
-
1001
- const api = createApi({
1002
- baseUrl: '/api',
1003
- requests: { searchUsers }
1004
- })
1328
+ import { createApi, defineRequest } from 'liaise'
1329
+
1330
+ const me = defineRequest<User>()({ method: 'GET', path: '/me', share: true })
1331
+
1332
+ // One client per incoming request: that page's loaders share one /me call,
1333
+ // and one user's call can never join another user's.
1334
+ function apiFor(req: IncomingRequest) {
1335
+ return createApi({
1336
+ baseUrl: 'https://users.internal',
1337
+ headers: { cookie: req.headers.cookie ?? '' },
1338
+ requests: { me },
1339
+ })
1340
+ }
1005
1341
 
1006
- // Rapid calls -- only the last one completes
1007
- api.searchUsers({ q: 'h' }) // aborted by next call
1008
- api.searchUsers({ q: 'he' }) // aborted by next call
1009
- api.searchUsers({ q: 'hel' }) // this one completes
1342
+ async function renderPage(req: IncomingRequest) {
1343
+ const api = apiFor(req)
1344
+ const [header, cart] = await Promise.all([
1345
+ api.me().then(r => r.data?.name), // header loader
1346
+ api.me().then(r => r.data?.cartId), // cart loader
1347
+ ])
1348
+ return { header, cart }
1349
+ }
1010
1350
  ```
1011
1351
 
1012
- Dedupe and manual abort signals work together. If both are active, the request is cancelled if either fires.
1352
+ The client is created per request so that one user's call can never join another's; see [On a server](#on-a-server). Inside React Server Components, React's [`cache()`](https://react.dev/reference/react/cache) does this too.
1013
1353
 
1014
- ### Timeout
1354
+ ### Retry a flaky backend within one deadline
1015
1355
 
1016
- Set `timeout` (milliseconds) on a `Request` or per-call to abort a request that takes too long:
1356
+ Use this when a backend sometimes fails with a 5xx, a rate limit or a dropped connection, and you still want an answer within a fixed time.
1017
1357
 
1358
+ <!-- tested: flaky-backend -->
1018
1359
  ```ts
1019
- const getUser = new Request<{ id: string }, User>({
1020
- method: 'GET',
1021
- path: '/users/:id',
1022
- timeout: 5000
1360
+ import { createApi, defineRequest } from 'liaise'
1361
+ import { retryMiddleware } from 'liaise/middleware'
1362
+
1363
+ type Report = { rows: number }
1364
+
1365
+ const retry = retryMiddleware({
1366
+ max: 3,
1367
+ // Retry server errors, rate limits and dropped connections. Not 4xx.
1368
+ retryOn: r => {
1369
+ const e = r.error
1370
+ return !!e && (e.status >= 500 || e.status === 429 || e.kind === 'network')
1371
+ },
1023
1372
  })
1024
1373
 
1025
- const { error } = await api.getUser({ id: '42' })
1026
- // error.status === 0, error.kind === 'timeout' if it fired
1374
+ const api = createApi({
1375
+ baseUrl: '/api',
1376
+ middleware: [retry],
1377
+ requests: {
1378
+ // timeout covers every attempt and every wait between them.
1379
+ getReport: defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 }),
1380
+ },
1381
+ })
1027
1382
  ```
1028
1383
 
1029
- **`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.
1384
+ With `timeout: 3000`, the caller gets an answer within three seconds however many retries are left. See [Set a deadline with timeout](#set-a-deadline-with-timeout).
1030
1385
 
1031
- 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:
1386
+ ### Give each attempt its own timeout
1032
1387
 
1388
+ liaise's `timeout` is one deadline for the whole call. If you want a limit per attempt instead, put a middleware *inside* the retry. `getUser` is the endpoint from [Quick start](#quick-start).
1389
+
1390
+ <!-- tested: per-attempt-timeout -->
1033
1391
  ```ts
1392
+ import { createApi } from 'liaise'
1393
+ import type { Middleware } from 'liaise'
1394
+ import { retryMiddleware } from 'liaise/middleware'
1395
+
1396
+ // A fresh time limit for each attempt, instead of one for the whole call.
1034
1397
  const perAttempt = (ms: number): Middleware => async (ctx, next) => {
1035
1398
  ctx.request.signal = AbortSignal.timeout(ms)
1036
1399
  return next()
@@ -1039,385 +1402,539 @@ const perAttempt = (ms: number): Middleware => async (ctx, next) => {
1039
1402
  const api = createApi({
1040
1403
  baseUrl: '/api',
1041
1404
  requests: { getUser },
1042
- middleware: [retryMiddleware(3), perAttempt(5000)]
1405
+ // Order matters: retry wraps perAttempt, so every attempt gets its own 5 s.
1406
+ middleware: [
1407
+ retryMiddleware({ retryOn: r => r.error?.kind === 'timeout' || (r.error?.status ?? 0) >= 500 }),
1408
+ perAttempt(5_000),
1409
+ ],
1043
1410
  })
1044
1411
  ```
1045
1412
 
1046
- 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.
1413
+ Timeouts aren't retried by default; the `retryOn` above opts in. Because the middleware replaces `ctx.request.signal`, a caller's own cancel still ends the call as `'abort'`, but the request itself keeps running until the per-attempt signal fires.
1047
1414
 
1048
- A few more details:
1415
+ ### Report errors to Sentry
1049
1416
 
1050
- - `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.
1051
- - 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'`).
1052
- - `result.retry()` always starts a fresh deadline. A retried call is not charged against the original budget.
1053
- - Non-positive or omitted `timeout` disables it entirely (the default).
1054
- - `timeout` composes with `dedupe: true` — the deadline is merged with the dedupe signal rather than discarded by it.
1055
- - 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).
1056
-
1057
- **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:
1417
+ Send every unexpected failure to your error tracker from one place. `Sentry` stands for your error tracker; `getUser` is the [Quick start](#quick-start) endpoint.
1058
1418
 
1419
+ <!-- tested: report-errors -->
1059
1420
  ```ts
1060
- const auth: Middleware = async (ctx, next) => {
1061
- const token = await user.getIdToken() // stalls on a bad network
1062
- ctx.request.headers.set('Authorization', `Bearer ${token}`)
1063
- return next()
1064
- }
1065
- // With timeout: 45_000, the call still settles at ~45s: kind 'timeout', status 0.
1421
+ import { createApi } from 'liaise'
1422
+
1423
+ const api = createApi({
1424
+ baseUrl: '/api',
1425
+ requests: { getUser },
1426
+ // Called once per failed call, after retries. Never for 'abort': you cancelled it.
1427
+ onError: error => {
1428
+ if (error.kind === 'http' && error.status < 500) return // expected 4xx, not a bug
1429
+ Sentry.captureException(error, {
1430
+ extra: { kind: error.kind, url: error.request.url, status: error.status },
1431
+ })
1432
+ },
1433
+ })
1066
1434
  ```
1067
1435
 
1068
- 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.
1436
+ `ApiError` isn't an `Error`, so it carries no stack trace or `message` of its own. Pass the fields you want to see in `extra`, as above.
1069
1437
 
1070
- 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)).
1438
+ Use a middleware instead when you need timing, or the request before it's sent, or want to report for some endpoints only. [Example: report server errors](#example-report-server-errors) has the middleware version.
1071
1439
 
1072
- ### Sharing
1440
+ ### Upload and download files
1073
1441
 
1074
- Set `share: true` on a `Request` to coalesce identical concurrent calls onto a single in-flight request, instead of each caller firing its own:
1442
+ Send a file with `FormData` and read one back as a `Blob`.
1075
1443
 
1444
+ <!-- tested: files -->
1076
1445
  ```ts
1077
- const getProduct = new Request<{ id: string }, Product>({
1446
+ import { createApi, defineRequest } from 'liaise'
1447
+
1448
+ // Upload: pass FormData as the params. liaise sends it as-is, and the
1449
+ // runtime sets the multipart Content-Type with its boundary.
1450
+ const uploadAvatar = defineRequest<{ url: string }, FormData>()({
1451
+ method: 'POST',
1452
+ path: '/avatar',
1453
+ })
1454
+
1455
+ // Download: ask for a Blob instead of JSON.
1456
+ const downloadFile = defineRequest<Blob>()({
1078
1457
  method: 'GET',
1079
- path: '/products/:id',
1080
- share: true
1458
+ path: '/files/:id',
1459
+ responseType: 'blob',
1081
1460
  })
1082
1461
 
1083
- // Only one network request is made; both callers get the same response
1084
- const [a, b] = await Promise.all([
1085
- api.getProduct({ id: '42' }),
1086
- api.getProduct({ id: '42' })
1087
- ])
1462
+ const api = createApi({ baseUrl: '/api', requests: { uploadAvatar, downloadFile } })
1088
1463
  ```
1089
1464
 
1090
- `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.
1465
+ Call it with `api.uploadAvatar(form)` and `api.downloadFile({ id })`. Other body types (a `Blob`, a `ReadableStream`) are listed under [Sending data](#sending-data).
1091
1466
 
1092
- **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.
1467
+ ## Choosing liaise
1093
1468
 
1094
- **What disables sharing for a single call:**
1469
+ This section helps you decide whether liaise fits your project. It covers when to pick something else, how liaise compares with other fetch clients, and where it runs.
1095
1470
 
1096
- - 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.
1097
- - 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.
1471
+ ### When it fits, and when it doesn't
1098
1472
 
1099
- **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)).
1473
+ Need a normalized cache (update one user, and every screen showing that user updates), optimistic updates or subscriptions? Use Apollo or urql. Apollo's [caching overview](https://www.apollographql.com/docs/react/caching/overview) and urql's [Graphcache docs](https://nearform.com/open-source/urql/docs/graphcache/) explain how each one caches. [Why another API client?](#why-another-api-client) covers the trade-off.
1100
1474
 
1101
- **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.
1475
+ For UI caching and refetching, use TanStack Query *with* liaise; see the [recipe](#use-with-tanstack-query).
1102
1476
 
1103
- ```ts
1104
- const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
1105
- const patient = api.getProduct({ id: '42' }) // keeps waiting
1477
+ For two or three calls, plain fetch is fine.
1106
1478
 
1107
- // impatient's early timeout does not cancel the shared request —
1108
- // patient still gets a real response.
1109
- ```
1479
+ liaise is a good fit when you have:
1110
1480
 
1111
- **`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.
1481
+ - **Many endpoints with one auth setup.** Each endpoint is one [`defineRequest`](#defining-endpoints), and one [auth middleware](#add-an-auth-header-and-refresh-the-token-on-a-401) covers them all.
1482
+ - **Failure handling that matters**, such as a checkout or a form. Every failure comes back as a value with a [kind you can switch on](#handling-errors).
1483
+ - **One API layer shared across frameworks, servers and scripts.** See [Where it runs](#where-it-runs).
1112
1484
 
1113
- **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.
1485
+ ### How it compares
1114
1486
 
1115
- ### TypeScript
1487
+ [`compare/`](https://github.com/iremlopsum/liaise/blob/main/compare) runs fetch, axios, ky, ofetch and liaise through ten failure scenarios against a local server, and records what the calling code gets back. [compare/README.md](https://github.com/iremlopsum/liaise/blob/main/compare/README.md) explains the fairness rules. If you maintain one of these libraries and think its setup is unfair, a pull request is welcome.
1116
1488
 
1117
- Type inference flows automatically from `Request` generics through `createApi` to the call site. You never annotate the API methods manually.
1489
+ <!-- compare:start -->
1118
1490
 
1119
- ```ts
1120
- // 1. Types are declared on the Request
1121
- const getUser = new Request<{ id: string }, User>({
1122
- method: 'GET',
1123
- path: '/users/:id'
1124
- })
1491
+ Measured on 4 October 2026 against axios 1.20.0, ky 2.1.0 and ofetch 1.5.1. Other libraries change. Rerun it with `npm run build` in the repo root, then `npm install && npm run compare` in `compare/`. Run in Node 22.18.0 against a local server. In browsers axios uses XHR, so its results there can differ.
1125
1492
 
1126
- // 2. createApi infers method signatures from the requests record
1127
- const api = createApi({
1128
- baseUrl: '/api',
1129
- requests: { getUser }
1130
- })
1493
+ | Scenario | fetch | axios | ky | ofetch | liaise |
1494
+ | --- | :-- | :-- | :-- | :-- | :-- |
1495
+ | Server answers 500 | throws Error* | throws AxiosError | throws HTTPError | throws FetchError | error result (http) |
1496
+ | Server unreachable | throws TypeError* | throws AxiosError (name: Error) | throws NetworkError | throws FetchError | error result (network) |
1497
+ | Server never answers | throws TimeoutError, 3002 ms* | throws AxiosError, 3005 ms | throws TimeoutError, 3005 ms | throws FetchError, 3003 ms | error result (timeout), 3003 ms |
1498
+ | 200 with broken JSON | throws SyntaxError* | throws AxiosError (name: SyntaxError) | throws SyntaxError | throws SyntaxError | error result (parse) |
1499
+ | 204 with no body, on a JSON call | resolves with undefined* | resolves with "" | resolves with undefined | resolves with undefined | resolves with undefined |
1500
+ | Search as you type: which results stay on screen | shows "rea"* | shows "rea"* | shows "rea"* | shows "rea"* | shows "rea" |
1501
+ | Five requests get a 401 at once | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* | 1 refresh call, 5/5 succeed* |
1502
+ | Slow 503s, 3 s deadline, 3 retries: when does the caller hear back | after 3.0s: throws TimeoutError, 3 attempts* | after 3.0s: throws CanceledError, 3 attempts* | after 3.0s: throws TimeoutError, 3 attempts | after 3.0s: throws FetchError, 3 attempts* | after 3.0s: error result (timeout), 3 attempts |
1503
+ | Path param is undefined | requests /s/users/undefined | requests /s/users/undefined | requests /s/users/undefined | requests /s/users/undefined | refused before sending: error result (network) |
1504
+ | Response is missing a field the type promises | — | — | throws SchemaValidationError | — | error result (parse) |
1131
1505
 
1132
- // 3. Call site is fully typed -- no annotations needed
1133
- const { data, error } = await api.getUser({ id: '42' })
1134
- // ^? User | null
1135
- ```
1506
+ \* needed hand-written code, described in the [notes](https://github.com/iremlopsum/liaise/blob/main/compare/results.md#notes). — means the library has no built-in option.
1136
1507
 
1137
- The inference chain works like this:
1508
+ Out of the box, liaise has no timeout (only ky has one by default) and treats a 204 on a JSON call as a parse error. See Table B in [compare/results.md](https://github.com/iremlopsum/liaise/blob/main/compare/results.md).
1138
1509
 
1139
- - `Request<TParams, TResponse>` carries the type info.
1140
- - `createApi` uses internal conditional types to pull the `TParams` and `TResponse` generics from each `Request` instance.
1141
- - A mapped type transforms the requests record into callable methods: each key becomes `(params: TParams, options?: CallOptions) => Promise<Result<TResponse>>`.
1142
- - When `TParams` is `Record<string, never>` (no params), the params argument becomes optional.
1510
+ | Library | gzip (kB) | brotli (kB) |
1511
+ | --- | :-- | :-- |
1512
+ | fetch | 0.1 | 0.1 |
1513
+ | axios | 19.1 | 17.3 |
1514
+ | ky | 9.6 | 8.5 |
1515
+ | ofetch | 4.0 | 3.6 |
1516
+ | liaise | 5.8 | 5.2 |
1517
+ | liaise + retryMiddleware | 6.3 | 5.7 |
1143
1518
 
1144
- All exported types are available for annotation when needed:
1519
+ fetch is built into the runtime; its row is the call site only, the floor rather than a library.
1145
1520
 
1146
- ```ts
1147
- import type {
1148
- Result,
1149
- CallOptions,
1150
- Middleware,
1151
- MiddlewareContext,
1152
- MiddlewareNext,
1153
- RequestConfig,
1154
- ApiConfig
1155
- } from 'liaise'
1156
- ```
1521
+ Request overhead on localhost, sequential (median requests per second): fetch 16,797, axios 13,691, ky 13,742, ofetch 16,230, liaise 16,414.
1157
1522
 
1158
- ## GraphQL Client
1523
+ Out-of-the-box results, request overhead in full and the notes: [compare/results.md](https://github.com/iremlopsum/liaise/blob/main/compare/results.md).
1159
1524
 
1160
- 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 }`.
1525
+ <!-- compare:end -->
1161
1526
 
1162
- Both clients return the same `Result<T>` shape — `result.data`, `result.error`, `result.response`, and `result.retry` work identically.
1527
+ With enough of your own code, every library gets the right result in almost every row. The difference is how much you write. Counting the cells marked `*`, fetch needs 8, axios 3, ofetch 3, ky 2 and liaise 1. liaise needs code only for the token refresh, and returns each failure as a value instead of throwing. It is the only one that refuses an undefined path param before sending the request.
1163
1528
 
1164
- ```ts
1165
- import { createGraphQL, Operation, gql } from 'liaise'
1529
+ ky is the closest alternative. Apart from throwing instead of returning errors, it differs from liaise in two rows of the table. Its search as you type needs code, and it sends the undefined path param. Out of the box, ky is the only one that times out, and it retries, as ofetch does. In size, liaise is larger than ofetch and smaller than ky and axios.
1166
1530
 
1167
- interface Category {
1168
- id: string
1169
- name: string
1170
- status: string
1171
- }
1531
+ In request overhead, liaise ties fetch and ofetch. axios and ky handle about 16% fewer requests per second than liaise. Overhead is measured in microseconds; on a real network each request takes milliseconds.
1172
1532
 
1173
- const GET_CATEGORY = gql`
1174
- query GetCategory($id: String!) {
1175
- category(id: $id) {
1176
- id
1177
- name
1178
- status
1179
- }
1180
- }
1181
- `
1533
+ ### Where it runs
1182
1534
 
1183
- const getCategory = new Operation<{ id: string }, Category>({
1184
- operation: GET_CATEGORY,
1185
- })
1535
+ | Runtime | Status |
1536
+ | ------- | ------ |
1537
+ | Node 20, 22, 24 | Tested in CI |
1538
+ | Browsers, Bun, Deno, Cloudflare Workers | Should work (standard `fetch`), not tested in CI |
1539
+ | React Native | Uses its built-in `fetch`, not tested in CI |
1186
1540
 
1187
- const graphql = createGraphQL({
1188
- endpoint: 'https://api.example.com/graphql',
1189
- operations: { getCategory },
1190
- onError: (error) => console.error(error.status, error.body),
1191
- })
1541
+ On React Native, where `AbortSignal.timeout` is missing, `timeout` falls back to a timer. If the runtime drops abort reasons, a timeout may report as `'abort'` instead of `'timeout'`.
1192
1542
 
1193
- const { data, error, response, retry } = await graphql.getCategory({ id: '123' })
1194
- ```
1543
+ No hooks, no framework code: a client is a plain object of functions returning promises. It works in React, Vue, Svelte, Solid, Angular, server loaders, workers and scripts. The [recipes](#recipes) show it with TanStack Query, React and a store.
1195
1544
 
1196
- Operations with no variables can be called without arguments. Use `Record<string, never>` as `TVariables` to mark an operation as variable-free:
1545
+ ## Design principles
1197
1546
 
1198
- ```ts
1199
- const getViewer = new Operation<Record<string, never>, ViewerData>({ operation: GET_VIEWER })
1200
- const graphql = createGraphQL({ endpoint, operations: { getViewer } })
1547
+ ### Never throws
1201
1548
 
1202
- const { data } = await graphql.getViewer() // params argument is optional
1203
- ```
1549
+ A thrown error doesn't show up in a function's type, so nothing reminds you to catch it, and one missed `try` breaks the page. A returned error is part of the type. TypeScript makes you check `error` before you can read `data`, and every failure arrives in the same shape.
1550
+
1551
+ ### Zero dependencies
1552
+
1553
+ Every dependency of liaise would also be a dependency of your app, with more to download, audit and update. liaise uses only what the runtime already has: `fetch`, `Headers`, `AbortController` and the other web types. Validators plug in through Standard Schema, which is only an interface, so schema support adds no package either. The size of each entry point is under [Exports](#exports).
1554
+
1555
+ ### Middleware over interceptors
1556
+
1557
+ Separate request and response interceptors split one job in two. State both halves need rides on the request config, and resending means calling the client again from inside a hook. A middleware wraps the whole call, so one function can set a header, read the result, retry, or answer from a cache. The built-in retry, cache and log are ordinary middleware, so anything they do, yours can do too.
1558
+
1559
+ ### Types by inference
1560
+
1561
+ Types you write at each call site drift away from the endpoint they describe. In liaise you write the types once, on the endpoint, and the path supplies the path params. `createApi` carries them to every call, so a renamed param or a changed response is a compile error where it is used.
1562
+
1563
+ ### Any runtime
1564
+
1565
+ liaise needs only `fetch` and the standard web types, so one client works in a browser, on a server, in a worker or in a script. Code that moves between them needs no second client and no adapter. [Where it runs](#where-it-runs) lists what CI tests.
1566
+
1567
+ ### Any framework
1568
+
1569
+ A client is a plain object of functions that return promises. That fits every UI framework, and code with no framework at all, and there is nothing to rewrite when you switch. Query libraries expect a failed call to throw, so you convert at that one edge, as the [TanStack Query recipe](#use-with-tanstack-query) does.
1204
1570
 
1205
- ### Queries and mutations
1571
+ ## Reference
1206
1572
 
1207
- 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.
1573
+ Every option, type and export, read from the source. The guide explains when to use each one.
1574
+
1575
+ ### createApi options
1576
+
1577
+ `createApi(config)` takes an `ApiConfig`.
1578
+
1579
+ | Option | Type | Default | What it does |
1580
+ | ------ | ---- | ------- | ------------ |
1581
+ | `baseUrl` | `string` | required | Goes in front of every endpoint's path. It may carry a query string ([details](#baseurl-query-merging)). |
1582
+ | `requests` | an object of endpoints | required | Each key becomes a method on the client, such as `api.getUser`. |
1583
+ | `middleware` | `Middleware[]` | — | Runs on every call, before endpoint and call middleware. |
1584
+ | `headers` | `HeadersInit` | — | Sent with every call. An endpoint or a call can replace a header ([three levels](#three-levels-of-settings)). |
1585
+ | `onError` | `(error: ApiError) => void` | — | Called once per failed call, after all middleware. Never for `'abort'` ([details](#reporting-errors-with-onerror)). |
1586
+
1587
+ ### createGraphQL options
1588
+
1589
+ `createGraphQL(config)` takes a `GraphQLBaseConfig`, plus either `operations` or `queries` and `mutations` ([Queries and mutations](#queries-and-mutations)).
1590
+
1591
+ | Option | Type | Default | What it does |
1592
+ | ------ | ---- | ------- | ------------ |
1593
+ | `endpoint` | `string` | required | The full URL of the GraphQL endpoint. |
1594
+ | `middleware` | `Middleware[]` | — | Runs on every operation, before operation and call middleware. |
1595
+ | `headers` | `HeadersInit` | — | Sent with every operation. An operation or a call can replace a header. |
1596
+ | `onError` | `(error: ApiError) => void` | — | Called once per failed call, GraphQL errors included. Never for `'abort'`. |
1597
+
1598
+ ### Endpoint options
1599
+
1600
+ `defineRequest<T>()(config)` and `new Request(config)` take a `RequestConfig`.
1601
+
1602
+ | Option | Type | Default | What it does |
1603
+ | ------ | ---- | ------- | ------------ |
1604
+ | `method` | `'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'` | required | The HTTP method. |
1605
+ | `path` | `string` | required | The path after `baseUrl`. Each `:name` is filled from the params ([Defining endpoints](#defining-endpoints)). |
1606
+ | `middleware` | `Middleware[]` | — | Runs on every call to this endpoint, after client middleware. |
1607
+ | `headers` | `HeadersInit` | — | Sent with every call to this endpoint. Replaces the client's value for the same header. |
1608
+ | `responseType` | `'json' \| 'text' \| 'blob' \| 'arrayBuffer' \| 'formData' \| 'none'` | `'json'` | How the response body is read ([Reading responses](#reading-responses)). |
1609
+ | `schema` | `StandardSchemaV1` | — | Checks a 2xx body, and `data` becomes the schema's output ([Validating responses](#validating-responses)). |
1610
+ | `dedupe` | `boolean` | `false` | A new call cancels the one still running ([details](#drop-stale-calls-with-dedupe)). |
1611
+ | `share` | `boolean` | `false` | Identical calls join one request ([details](#sharing-identical-requests)). Can't be combined with `dedupe`. |
1612
+ | `bodyAs` | `'query' \| 'body'` | `'query'` for `GET` and `DELETE`, `'body'` for the rest | Where the params that aren't in the path go. |
1613
+ | `timeout` | `number` (ms) | no deadline | One deadline for the whole call, retries included ([details](#set-a-deadline-with-timeout)). |
1614
+
1615
+ ### Operation options
1616
+
1617
+ `new Operation(config)` takes an `OperationConfig`.
1618
+
1619
+ | Option | Type | Default | What it does |
1620
+ | ------ | ---- | ------- | ------------ |
1621
+ | `operation` | `string` | required | The GraphQL document, sent as `query` in the body. |
1622
+ | `middleware` | `Middleware[]` | — | Runs on every call to this operation, after client middleware. |
1623
+ | `headers` | `HeadersInit` | — | Sent with every call to this operation. Replaces the client's value for the same header. |
1624
+ | `dedupe` | `boolean` | `false` | A new call cancels the one still running. |
1625
+ | `schema` | `StandardSchemaV1` | — | Checks the response's `data`. The response type stays explicit ([Validating responses](#validating-responses)). |
1626
+ | `timeout` | `number` (ms) | no deadline | One deadline for the whole call, retries included. |
1627
+
1628
+ ### CallOptions
1629
+
1630
+ The second argument of every call, as in `api.getUser(params, options)`. [`paginate`](#pagination) takes the same options and applies them to every page.
1631
+
1632
+ | Option | Type | Default | What it does |
1633
+ | ------ | ---- | ------- | ------------ |
1634
+ | `middleware` | `Middleware[]` | — | Runs after the client's and the endpoint's middleware. Turns off `share` for this call. |
1635
+ | `skipMiddleware` | `Middleware[]` | — | Middleware to leave out of this call, matched by reference ([details](#skipping-a-middleware-for-one-call)). |
1636
+ | `headers` | `HeadersInit` | — | Replaces the client's and the endpoint's value for the same header. Turns off `share` for this call. |
1637
+ | `signal` | `AbortSignal` | — | Cancels the call, which ends with `kind: 'abort'` ([details](#cancel-with-a-signal)). |
1638
+ | `timeout` | `number` (ms) | the endpoint's `timeout` | Replaces the endpoint's deadline for this call. `0` turns it off. Under `share`, it bounds only this caller's wait ([details](#sharing-identical-requests)). |
1639
+
1640
+ A fractional `timeout` is rounded down to whole milliseconds, with a minimum of 1 ms. A value above the timer limit of 2³¹ − 1 ms is capped there. Zero or a negative number means no deadline.
1641
+
1642
+ ### Result and ApiError
1643
+
1644
+ Every call returns a `Result`. `data` and `error` are never both set, so checking `error` narrows `data`.
1208
1645
 
1209
1646
  ```ts
1210
- const graphql = createGraphQL({
1211
- endpoint: 'https://api.example.com/graphql',
1212
- queries: {
1213
- getCategory: new Operation<{ id: string }, Category>({ operation: GET_CATEGORY }),
1214
- },
1215
- mutations: {
1216
- updateCategory: new Operation<{ id: string; name: string }, Category>({
1217
- operation: gql`
1218
- mutation UpdateCategory($id: String!, $name: String!) {
1219
- updateCategory(id: $id, name: $name) { id name status }
1220
- }
1221
- `,
1222
- }),
1223
- },
1224
- })
1647
+ interface SuccessResult<TResponse> {
1648
+ data: TResponse
1649
+ error: null
1650
+ response: Response // always present on success
1651
+ retry: () => Promise<Result<TResponse>>
1652
+ }
1225
1653
 
1226
- graphql.query.getCategory({ id: '123' })
1227
- graphql.mutation.updateCategory({ id: '123', name: 'New Name' })
1654
+ interface ErrorResult<TResponse> {
1655
+ data: null
1656
+ error: ApiError
1657
+ response: Response | null // null when no response arrived
1658
+ retry: () => Promise<Result<TResponse>>
1659
+ }
1660
+
1661
+ type Result<TResponse> = SuccessResult<TResponse> | ErrorResult<TResponse>
1228
1662
  ```
1229
1663
 
1230
- ### GraphQL errors
1664
+ `response` is set for `'http'` and `'parse'` errors, where the server answered. It is `null` for `'network'`, `'abort'`, `'timeout'` and `'middleware'`.
1231
1665
 
1232
- 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.
1666
+ `ApiError` is the `error` of a failed call. It is a plain class and doesn't extend `Error`.
1233
1667
 
1234
- 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):
1668
+ | Property | Type | What it holds |
1669
+ | -------- | ---- | ------------- |
1670
+ | `kind` | `ApiErrorKind` | What went wrong. The [kinds table](#handling-errors) says what each one means. If you build an `ApiError` yourself, for example in a middleware, `kind` is required. |
1671
+ | `status` | `number` | The response's status for `'http'` and `'parse'`. `0` for `'network'`, `'abort'`, `'timeout'` and `'middleware'`. |
1672
+ | `statusText` | `string` | The response's status text, `'GraphQL Error'` for a GraphQL error, and `''` when no response arrived. |
1673
+ | `body` | `unknown` | For `'http'`, the error body, or `null` when it is empty or doesn't parse. For `'parse'`, what the [Validating responses](#validating-responses) and [Reading responses](#reading-responses) rules say. Otherwise, the thrown value. |
1674
+ | `headers` | `Headers` | The response headers. Empty when no response arrived. |
1675
+ | `request` | `{ method, url, params }` | The failed request. `url` is the address with the params filled in. It is the path template only when the URL couldn't be built. |
1676
+ | `partialData` | `unknown` (optional) | For a GraphQL error, the data the server sent with the errors. `undefined` for every REST error. |
1235
1677
 
1236
1678
  ```ts
1237
- const { error } = await graphql.getCategory({ id: '123' })
1238
- if (error) {
1239
- console.log(error.body) // GraphQLError[]
1240
- console.log(error.partialData) // whatever `data` the server sent alongside the errors, or undefined
1241
- }
1679
+ type ApiErrorKind = 'http' | 'network' | 'abort' | 'timeout' | 'parse' | 'middleware'
1242
1680
  ```
1243
1681
 
1244
- 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:
1682
+ You can check for an `ApiError` with `instanceof`:
1245
1683
 
1246
1684
  ```ts
1247
- const { error } = await graphql.getCategory({ id: '123' })
1248
- if (error) {
1249
- console.log(error.kind) // 'parse'
1250
- console.log(error.body) // raw response text, e.g. '' or '{}'
1685
+ import { ApiError } from 'liaise'
1686
+
1687
+ if (error instanceof ApiError) {
1688
+ console.error(error.kind, error.status)
1251
1689
  }
1252
1690
  ```
1253
1691
 
1254
- 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).
1692
+ ### MiddlewareContext
1255
1693
 
1256
- Operations support `dedupe: true` in the same way `Request` does — see [Auto-cancel via `dedupe`](#auto-cancel-via-dedupe).
1694
+ The first argument of every middleware, `ctx`:
1257
1695
 
1258
- ### Middleware
1696
+ | Property | Type | What it holds |
1697
+ | -------- | ---- | ------------- |
1698
+ | `request.method` | `string` | The HTTP method, such as `'GET'`. |
1699
+ | `request.url` | `string` | The full URL, with path params and query string filled in. |
1700
+ | `request.path` | `string` | The path template, such as `'/users/:id'`. |
1701
+ | `request.params` | `unknown` | The params as the caller passed them. |
1702
+ | `request.headers` | `Headers` | The merged headers. A middleware can add, change or remove them. |
1703
+ | `request.body` | `unknown` | The serialized body, or `null` when there is none. |
1704
+ | `request.signal` | `AbortSignal \| undefined` | The signal `fetch` receives. A middleware can replace it ([Signals in middleware](#signals-in-middleware)). |
1705
+ | `requestName` | `string` | The endpoint's key, such as `'getUser'`. |
1259
1706
 
1260
- `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.
1707
+ How a replaced signal works with `share` and `dedupe` is under [Signal-replacing middleware](#signal-replacing-middleware).
1261
1708
 
1262
- ```ts
1263
- const graphql = createGraphQL({
1264
- endpoint: 'https://api.example.com/graphql',
1265
- operations: { getCategory },
1266
- middleware: [authMiddleware],
1267
- })
1268
- ```
1709
+ ### Built-in middleware options
1269
1710
 
1270
- ### API Reference additions
1711
+ #### RetryOptions
1271
1712
 
1272
- | Export | Kind | Description |
1273
- | ------------------- | -------- | ---------------------------------------------------------------------- |
1274
- | `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
1275
- | `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
1276
- | `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
1277
- | `OperationConfig` | type | Config object for the `Operation` constructor |
1278
- | `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
1279
- | `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
1713
+ `retryMiddleware(n)` is short for `retryMiddleware({ max: n })`, and `retryMiddleware()` means `{ max: 3 }`.
1280
1714
 
1281
- ## Testing
1715
+ | Option | Type | Default | What it does |
1716
+ | ------ | ---- | ------- | ------------ |
1717
+ | `max` | `number` | `3` | Retries after the first attempt. `max: 2` means up to 3 calls in all. |
1718
+ | `delay` | `'exponential' \| 'linear' \| (attempt: number) => number` | `'exponential'` | The wait before each retry. Exponential is `baseDelay * 2^(attempt-1)` and linear is `baseDelay * attempt`. A function gets the 1-based attempt and returns milliseconds. |
1719
+ | `baseDelay` | `number` (ms) | `250` | The first wait, before jitter and `Retry-After`. |
1720
+ | `maxDelay` | `number` (ms) | `30000` | The longest any wait can be, a `Retry-After` value included. |
1721
+ | `jitter` | `boolean` | `true` | Waits a random time between 0 and the computed delay, so many clients don't retry at the same moment. Never applied to a `Retry-After` value. |
1722
+ | `respectRetryAfter` | `boolean` | `true` | Uses the server's `Retry-After` header, in seconds or as a date, in place of the computed wait. `maxDelay` still caps it. |
1723
+ | `retryOn` | `(result: Result<unknown>, attempt: number) => boolean` | `r => (r.error?.status ?? 0) >= 500` | Whether to retry. It gets the 1-based number of the attempt it would start, and is called even once `max` is reached. |
1724
+ | `onRetry` | `(info: RetryInfo) => void` | — | Called before each wait. Its return value is ignored, and if it throws, the call goes on. |
1282
1725
 
1283
- `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.
1726
+ `RetryInfo`, the argument to `onRetry`:
1284
1727
 
1285
- ```ts
1286
- import { mockFetch, jsonResponse } from 'liaise/testing'
1728
+ | Field | What it holds |
1729
+ | ----- | ------------- |
1730
+ | `attempt` | The retry about to run, counting from 1. |
1731
+ | `max` | The configured `max`. |
1732
+ | `delay` | The wait about to start, in milliseconds, after jitter and `Retry-After`. |
1733
+ | `result` | The `Result` that caused this retry. |
1287
1734
 
1288
- const mock = mockFetch({
1289
- 'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
1290
- 'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
1291
- })
1735
+ #### Cache options
1292
1736
 
1293
- mock.install() // replaces globalThis.fetch
1294
- // ... exercise your code, which calls the real api.getUser(...) ...
1295
- mock.restore() // puts the original globalThis.fetch back
1296
- ```
1737
+ `cacheMiddleware(options)` returns a middleware with a `clear()` method, typed `CacheMiddleware`.
1297
1738
 
1298
- 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").
1739
+ | Option | Type | Default | What it does |
1740
+ | ------ | ---- | ------- | ------------ |
1741
+ | `ttl` | `number` (ms) | `300000` (5 minutes) | How long an entry is served. An expired entry is removed when it is next read. |
1742
+ | `maxSize` | `number` | `50` | How many entries the store keeps. When it is full, the oldest entry is dropped. |
1743
+ | `debug` | `boolean` | `false` | Logs `[liaise cache] HIT` or `MISS`, with the endpoint and its params, to the console. |
1299
1744
 
1300
- ```ts
1301
- const mock = mockFetch({
1302
- 'GET /api/flaky': [jsonResponse(null, { status: 503 }), jsonResponse({ ok: true })],
1303
- })
1745
+ What goes into the key is under [Cache key](#cache-key).
1746
+
1747
+ #### Log output
1748
+
1749
+ `logMiddleware` logs one line when a call starts and one when it ends:
1750
+
1751
+ ```text
1752
+ [liaise] → <method> <endpoint> <url>
1753
+ [liaise] ← <endpoint> OK (<ms>ms)
1754
+ [liaise] ← <endpoint> ERROR <status> (<ms>ms)
1304
1755
  ```
1305
1756
 
1306
- `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.
1757
+ The time covers everything that runs inside it: the middleware after it and the request. An error with no response logs status `0`.
1758
+
1759
+ ### liaise/testing
1760
+
1761
+ | Export | Kind | What it is |
1762
+ | ------ | ---- | ---------- |
1763
+ | `mockFetch` | function | Builds a `fetch` stub that matches routes, with `install()`, `restore()`, call recording and response sequences. |
1764
+ | `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header. |
1765
+ | `successResult` | function | Builds a success `Result`, for stubbing at the `Result` level. |
1766
+ | `errorResult` | function | Builds an error `Result` with a given HTTP status, for stubbing at the `Result` level. |
1767
+ | `RouteContext` | type | `{ params, request }`, passed to a route function. |
1768
+ | `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>`, a route value that builds its response. |
1769
+ | `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>`, anything a route key can map to. |
1770
+ | `RecordedCall` | type | `{ method, url, headers, body }`, one entry in `mock.calls`. |
1771
+
1772
+ How the stub behaves, beyond [Testing your code](#testing-your-code):
1773
+
1774
+ - **It honours `init.signal`, like real `fetch`.** A signal that is already aborted, or aborts while a route function is still pending, rejects with its `reason`. So a route that never answers, `() => new Promise(() => {})`, lets you test your own `timeout` and cancel handling.
1775
+ - **An aborted call is still recorded** in `calls` and counted by `callCount`, but it doesn't use up a response from a sequence.
1776
+ - **`restore()` puts back whatever `globalThis.fetch` was when `install()` ran.** If it was `undefined`, `restore()` puts back `undefined`.
1777
+ - **A route key without a space**, such as `'/users'`, throws when you call `mockFetch`, and the message names the key.
1778
+ - **Routes are tried in the order you wrote them**, and the first match wins. Put the more specific route first when two could match the same path.
1779
+ - **Trailing and doubled slashes are ignored on both sides.** `/a/b/`, `/a//b` and `/a/b` all match the same route.
1780
+
1781
+ ### Exports
1782
+
1783
+ Each entry point is a separate import, and your bundler leaves out what you don't import. Gzipped, as measured by `npm run size`: about 5.8 kB for a REST-only import, 6.9 kB for the whole core entry, and 8.0 kB with all the middleware.
1784
+
1785
+ **`liaise`**
1307
1786
 
1308
- 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.
1787
+ | Export | Kind | What it is |
1788
+ | ------ | ---- | ---------- |
1789
+ | `createApi` | function | Creates a REST client from your endpoints. |
1790
+ | `defineRequest` | function | Defines an endpoint, with params checked against the path. |
1791
+ | `Request` | class | The endpoint class that `defineRequest` builds ([Without defineRequest](#without-definerequest)). |
1792
+ | `paginate` | function | Walks a paginated endpoint, one `Result` per page. |
1793
+ | `ApiError` | class | The `error` of a failed call. |
1794
+ | `createGraphQL` | function | Creates a GraphQL client from your operations. |
1795
+ | `Operation` | class | Defines one GraphQL operation. |
1796
+ | `gql` | function | Marks a template string as GraphQL for your editor, and returns it as a plain string. |
1797
+ | `Result`, `SuccessResult`, `ErrorResult` | type | What every call returns, and its two branches. |
1798
+ | `ApiErrorKind` | type | The union of `error.kind` values. |
1799
+ | `CallOptions` | type | The second argument of a call. |
1800
+ | `PaginateOptions` | type | `next`, `maxPages`, and any `CallOptions`. |
1801
+ | `ApiConfig`, `RequestConfig` | type | The configs of `createApi` and an endpoint. |
1802
+ | `GraphQLBaseConfig`, `OperationConfig` | type | The configs of `createGraphQL` and an `Operation`. |
1803
+ | `GraphQLError` | type | One entry of a GraphQL `errors` array. |
1804
+ | `Middleware`, `MiddlewareContext`, `MiddlewareNext` | type | A middleware, its `ctx`, and its `next`. |
1805
+ | `StandardSchemaV1`, `InferOutput`, `StandardIssue` | type | The Standard Schema interface, the type a schema produces, and one validation issue. |
1806
+
1807
+ **`liaise/middleware`**
1808
+
1809
+ | Export | Kind | What it is |
1810
+ | ------ | ---- | ---------- |
1811
+ | `retryMiddleware` | function | Returns a middleware that retries failed calls ([RetryOptions](#retryoptions)). |
1812
+ | `cacheMiddleware` | function | Returns a middleware that caches successes in memory ([Cache options](#cache-options)). |
1813
+ | `logMiddleware` | middleware | Logs each call to the console ([Log output](#log-output)). |
1814
+ | `RetryOptions`, `RetryInfo` | type | The options of `retryMiddleware`, and the argument to `onRetry`. |
1815
+ | `CacheMiddleware` | type | What `cacheMiddleware()` returns, a `Middleware` with `clear()`. |
1816
+
1817
+ **`liaise/testing`** exports are listed under [liaise/testing](#liaisetesting).
1818
+
1819
+ ### Behaviour in detail
1820
+
1821
+ Edge cases the guide links to.
1822
+
1823
+ #### Cache key
1824
+
1825
+ `cacheMiddleware` keys each entry on the endpoint name, the method, the full URL, the params and every request header except `Content-Type`. A different `Authorization`, base URL or query value gets its own entry.
1826
+
1827
+ The query string is sorted by name before it goes in the key, so `?a=1&b=2` and `?b=2&a=1` are one entry. It is sorted by the raw, undecoded name, and repeated names keep their order. A query param that a middleware adds before the cache, such as `?lang=de`, is part of the key.
1828
+
1829
+ Params are keyed by content, by the same rules as [`share`](#share-key-and-refcount). Params that `share` can't compare are never cached and never served from the cache. Each `cacheMiddleware()` call makes its own store, so two stores never share entries.
1830
+
1831
+ #### Share key and refcount
1832
+
1833
+ Two calls share when they go to the same endpoint and their params have the same key. The key is built from content. Object keys are sorted, and an `undefined` member is dropped, so `{ a: undefined }` and `{}` match. A value with a `toJSON` method is keyed by what it returns, so a `Date` is its ISO string. A `Map`, `Set` or typed array is keyed by its entries.
1834
+
1835
+ A call can't share when its params hold any of these, at any depth:
1836
+
1837
+ - a BigInt
1838
+ - a function or a symbol
1839
+ - an `ArrayBuffer`, `Blob`, `FormData` or `URLSearchParams`
1840
+ - a boxed primitive, such as `new String('a')`
1841
+ - a circular structure
1842
+ - an object with no enumerable keys that isn't a plain object, such as an `Error`, a `Promise`, or a class instance that keeps its state in private fields
1843
+
1844
+ Per-call `headers: {}` and `middleware: []` are empty, so they don't turn sharing off. Only a header or a middleware that is actually there does.
1845
+
1846
+ Each caller holds a place in the shared request, counted by a refcount. A caller whose own `signal` or `timeout` fires gives up its place. What happens when every caller has given up, and what the caller who leaves receives, is under [Sharing identical requests](#sharing-identical-requests).
1847
+
1848
+ The endpoint's `timeout` counts from when the shared request started. If each new caller restarted it, a steady stream of callers could keep one request open forever.
1849
+
1850
+ #### `retry()` on a shared result
1851
+
1852
+ Every caller that waits to the end gets the same `Result` object. Its `retry()` uses the per-call options of the caller that started the shared request (its headers, signal and timeout), whoever calls it. A retry is never shared. It sends a request of its own.
1853
+
1854
+ #### Signal-replacing middleware
1855
+
1856
+ A middleware can replace `ctx.request.signal`, as [Give each attempt its own timeout](#give-each-attempt-its-own-timeout) shows. Under `share`, liaise merges the signal that tracks the callers back in before `fetch`. The shared request is still cancelled once every caller has given up.
1857
+
1858
+ Under `dedupe`, liaise merges the dedupe signal into yours the same way, so the request ends when either one fires. The dedupe signal is added when `fetch` is called. A middleware that reads `ctx.request.signal` before `next()` sees only the caller's signal and the deadline.
1859
+
1860
+ #### Timeout backstop
1861
+
1862
+ A middleware may wait on work of its own before it calls `next()`, such as a token refresh. If that work never settles, the call still ends at its deadline. Here `user` stands for your auth client:
1309
1863
 
1310
1864
  ```ts
1311
- const r = await api.getUser({ id: '42' }) // routes only define 'GET /api/user/:id'
1312
- expect(r.error?.kind).toBe('network')
1313
- expect(String(r.error?.body)).toMatch(/no route matched GET \/api\/users\/42/)
1865
+ const auth: Middleware = async (ctx, next) => {
1866
+ const token = await user.getIdToken() // stalls on a bad network
1867
+ ctx.request.headers.set('Authorization', `Bearer ${token}`)
1868
+ return next()
1869
+ }
1870
+ // With timeout: 45_000, the call still settles after about 45 s: kind 'timeout', status 0.
1314
1871
  ```
1315
1872
 
1316
- An empty response array for a route behaves the same way — a descriptive `Error` reaching you as `error.body`, not a bare `TypeError`.
1873
+ When the deadline passes, the chain gets one macrotask to answer on its own. That is enough for anything that already reacts to the abort: `fetch` rejecting, a middleware rethrowing the reason, or a fallback that turns a timeout into a cached response. Each of those keeps its own `Result`.
1317
1874
 
1318
- 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):
1875
+ A chain still waiting after that is stuck on something the signal doesn't reach. The call then settles with `kind: 'timeout'` and `status: 0`, and `onError` is called once. A caller's `signal` works the same way, with `kind: 'abort'`, which `onError` never sees.
1319
1876
 
1320
- ```ts
1321
- import { successResult, errorResult } from 'liaise/testing'
1877
+ The stuck middleware keeps running, because a promise can't be cancelled. Whatever it returns or throws later is discarded, with no second `Result` and no second `onError`. If it calls `next()` after the call has settled, nothing is sent, and `next()` returns the `Result` the caller already has.
1322
1878
 
1323
- vi.spyOn(api, 'getUser').mockResolvedValue(successResult({ id: '42', name: 'Ada' }))
1324
- vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
1325
- ```
1879
+ #### Abort classification
1326
1880
 
1327
- A few behaviors worth knowing:
1881
+ liaise decides whether a failure is a cancellation by checking whether this call's own signal aborted. The thrown value's name and type don't matter. A custom reason, such as `controller.abort(new Error('unmounted'))` or a plain string, still gives `kind: 'abort'`, and a deadline gives `'timeout'`.
1328
1882
 
1329
- - **`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.
1330
- - **`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`.
1331
- - **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.
1332
- - **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.
1333
- - **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.
1883
+ A middleware that rethrows liaise's own abort or timeout reason, as it is or wrapped once as the `cause` of another error, gives `'abort'` or `'timeout'` too. Anything else a middleware throws is `'middleware'`.
1334
1884
 
1335
- ## Philosophy
1885
+ #### baseUrl query merging
1336
1886
 
1337
- ### Never throws
1887
+ A `baseUrl` can carry its own query string, such as a fixed `api-version`. Its params go in front of the call's:
1338
1888
 
1339
- 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.
1889
+ ```ts
1890
+ import { createApi, defineRequest } from 'liaise'
1340
1891
 
1341
- ### Zero dependencies
1892
+ type Hit = { id: string }
1342
1893
 
1343
- 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.
1894
+ const search = defineRequest<Hit[], { q: string }>()({ method: 'GET', path: '/search' })
1895
+ const api = createApi({ baseUrl: 'https://api.example.com/v1?api-version=2', requests: { search } })
1344
1896
 
1345
- ### Middleware over interceptors
1897
+ await api.search({ q: 'hello' })
1898
+ // GET https://api.example.com/v1/search?api-version=2&q=hello
1899
+ ```
1346
1900
 
1347
- 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.
1901
+ A key that appears in both is sent twice. A call param named `api-version` gives `?api-version=2&api-version=3`, and the server decides which one counts. Headers merge by name, but query params can't, because an array param is already sent as repeated keys (`tags=a&tags=b`). To change a base param on each call, set it in a middleware.
1348
1902
 
1349
- ### Typed dot-access
1903
+ The full URL, base query included, appears in `ctx.request.url`, `error.request.url` and `logMiddleware`'s output, so anything that logs or reports it sends that query too. Put a secret in a header instead.
1350
1904
 
1351
- 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.
1905
+ #### URL fragments
1352
1906
 
1353
- ### Runtime-agnostic
1907
+ A `#` written into a `path` or a `baseUrl` is refused, because a fragment is never sent to the server. The call returns an error with `kind: 'network'` and a `TypeError` in `error.body` that names the value. Nothing is sent. liaise refuses the fragment instead of stripping it, so the dead part doesn't stay in your code.
1354
1908
 
1355
- 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 (its built-in `fetch`; not tested in CI), Cloudflare Workers, edge runtimes. Where `AbortSignal.timeout` is missing (React Native's Hermes), `timeout` falls back to `AbortController` plus `setTimeout`; the failure is `kind: 'timeout'` where the runtime's `AbortController` carries abort reasons, and possibly `'abort'` where it does not. Not tested on a device.
1909
+ `defineRequest` also refuses a fragment in a `path` literal, at compile time:
1356
1910
 
1357
- ### Framework-agnostic
1911
+ ```ts
1912
+ defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
1913
+ // ^ Property '__fragmentInPath' is missing:
1914
+ // a URL fragment is never sent to the server
1915
+ ```
1358
1916
 
1359
- 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.
1917
+ The check reads the literal. A path built at runtime, or a variable typed as `RequestConfig`, still compiles and is refused when it is called. `new Request` has no compile-time check.
1360
1918
 
1361
- ## API Reference
1919
+ A `#` inside a param value is data. It is escaped to `%23` and sent:
1362
1920
 
1363
- ### Core (`liaise`)
1921
+ ```ts
1922
+ await api.getDoc({ id: 'a#b' }) // GET /docs/a%23b
1923
+ await api.search({ tag: 'a#b' }) // GET /search?tag=a%23b
1924
+ ```
1364
1925
 
1365
- | Export | Kind | Description |
1366
- | --------------- | -------- | ------------------------------------------------------------------ |
1367
- | `createApi` | function | Creates a typed API client from a config of Request definitions |
1368
- | `Request` | class | Typed endpoint definition -- one instance per endpoint |
1369
- | `defineRequest` | function | Typed factory — infers path params from the `path` literal, and enforces `responseType: 'none'`. |
1370
- | `paginate` | function | Walks a paginated endpoint, yielding one `Result` per page. |
1371
- | `PaginateOptions` | type | `next`, `maxPages`, and any `CallOptions`. |
1372
- | `StandardSchemaV1` | type | The Standard Schema contract — for typing a helper that takes a validator. |
1373
- | `InferOutput` | type | The type a schema produces on success. |
1374
- | `StandardIssue` | type | One validation failure -- the shape of each entry in `error.body` when a schema refuses. |
1375
- | `ApiError` | class | Structured error with status, kind, body, headers, and request metadata |
1376
- | `ApiErrorKind` | type | `'http' \| 'network' \| 'abort' \| 'timeout' \| 'parse' \| 'middleware'` -- discriminates `ApiError.kind` |
1377
- | `RequestConfig` | type | Config object for the `Request` constructor |
1378
- | `ApiConfig` | type | Config object for `createApi` |
1379
- | `CallOptions` | type | Per-call overrides (`middleware`, `skipMiddleware`, `headers`, `signal`, `timeout`) |
1380
- | `Result` | type | Discriminated union of every API call's outcome: `SuccessResult<T> \| ErrorResult<T>` |
1381
- | `SuccessResult` | type | The success branch of `Result`: `{ data: T, error: null, response: Response, retry }` |
1382
- | `ErrorResult` | type | The error branch of `Result`: `{ data: null, error: ApiError, response: Response \| null, retry }` |
1383
- | `Middleware` | type | Middleware function signature: `(ctx, next) => Promise<Result>` |
1384
- | `MiddlewareContext` | type | Request context passed to middleware |
1385
- | `MiddlewareNext` | type | The `next` function passed to middleware |
1386
- | `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
1387
- | `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
1388
- | `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
1389
- | `OperationConfig` | type | Config object for the `Operation` constructor |
1390
- | `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
1391
- | `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
1392
-
1393
- ### Built-in middleware (`liaise/middleware`)
1394
-
1395
- | Export | Kind | Description |
1396
- | ----------------- | -------- | ------------------------------------------------------------- |
1397
- | `retryMiddleware` | function | Factory that returns middleware retrying on 5xx by default, with a configurable backoff policy |
1398
- | `RetryOptions` | type | Options object accepted by `retryMiddleware` (`max`, `delay`, `baseDelay`, `maxDelay`, `jitter`, `respectRetryAfter`, `retryOn`, `onRetry`) |
1399
- | `RetryInfo` | type | Shape of the argument passed to `RetryOptions.onRetry` |
1400
- | `logMiddleware` | const | Middleware that logs request lifecycle to the console |
1401
- | `cacheMiddleware` | function | Factory that returns a per-request in-memory cache with `clear()` |
1402
- | `CacheMiddleware` | type | Return type of `cacheMiddleware()` -- a `Middleware` with an attached `clear()` |
1403
-
1404
- ### Testing (`liaise/testing`)
1405
-
1406
- | Export | Kind | Description |
1407
- | --------------- | -------- | ------------------------------------------------------------------ |
1408
- | `mockFetch` | function | Builds a route-matching `fetch` stub, with `install()`/`restore()`, call recording, and response sequencing |
1409
- | `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header, for use as a route value |
1410
- | `successResult` | function | Builds a well-formed success `Result<T>` directly, for stubbing at the `Result` level |
1411
- | `errorResult` | function | Builds a well-formed error `Result<T>` with a given HTTP status, for stubbing at the `Result` level |
1412
- | `RouteContext` | type | `{ params, request }` passed to a route handler function |
1413
- | `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>` -- a route value that computes its response |
1414
- | `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
1415
- | `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
1416
-
1417
- ## Contributing
1418
-
1419
- Bug reports, fixes and ideas are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for how to report a bug, run the tests, and the few rules a pull request is checked against.
1420
-
1421
- ## License
1422
-
1423
- MIT
1926
+ #### Stream bodies
1927
+
1928
+ A `ReadableStream` body is used up as it is sent, so liaise can't send it again. A second attempt, from `retryMiddleware` or `result.retry()`, returns `kind: 'network'` and `status: 0`, with a `TypeError` that tells you to read the stream into a `Blob` or `ArrayBuffer` first. Under `retryMiddleware` that error is the final `Result`, so the 5xx that caused the retry isn't in it.
1929
+
1930
+ #### Empty bodies
1931
+
1932
+ - **A 2xx with an empty body under `'json'`** is a `'parse'` error ([Reading responses](#reading-responses)), and its `Result` still has `retry()`.
1933
+ - **A non-2xx with an empty body** is an ordinary `'http'` error with `error.body` set to `null`. So is a non-2xx whose JSON body doesn't parse, such as an HTML page from a gateway. Nobody promised a body on failure, so neither is a `'parse'` error.
1934
+ - **On GraphQL**, a 2xx with neither `data` nor `errors` is a `'parse'` error with the raw text in `error.body` ([GraphQL errors](#graphql-errors)). That includes a root of `null`, a number or an array. Each is valid JSON, but GraphQL requires an object.
1935
+
1936
+ ## Upgrading, contributing, licence
1937
+
1938
+ - **Upgrading.** [MIGRATION.md](./MIGRATION.md) says what to change when an upgrade needs it. [CHANGELOG.md](./CHANGELOG.md) lists every release.
1939
+ - **Contributing.** Bug reports, fixes and ideas are welcome. [CONTRIBUTING.md](./CONTRIBUTING.md) explains how to report a bug, run the tests, and the few rules a pull request is checked against.
1940
+ - **Licence.** MIT, in [LICENSE](./LICENSE).