liaise 5.0.2 → 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,273 +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.8 kB gzipped** for a REST-only import, 6.9 kB for the core entry, 8.0 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
- A path parameter must be a non-empty string, a finite number, a bigint or a boolean. Anything else (`undefined`, `null`, `''`, an object, an array, a `Date`, `NaN`) is refused before the request is sent, with an error Result naming the parameter. This catches the common front-end mistake of calling before an id has loaded: `getItem({ orgId: 'acme', id: undefined })` returns an error instead of fetching `/orgs/acme/items/undefined`.
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
- #### `responseType`
308
+ ### Handling errors
141
309
 
142
- Controls how the response body is parsed. Defaults to `'json'`.
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.
143
311
 
144
312
  ```ts
145
- const downloadFile = new Request<{ id: string }, Blob>({
146
- method: 'GET',
147
- path: '/files/:id',
148
- responseType: 'blob'
149
- })
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
+ }
150
347
  ```
151
348
 
152
- See [Response parsing](#response-parsing) for all options.
349
+ `show`, `redirectToLogin` and `report` stand for your own code.
350
+
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
- #### `dedupe`
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).
155
366
 
156
- 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:
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:
157
370
 
158
371
  ```ts
159
- const searchUsers = new Request<{ q: string }, User[]>({
160
- method: 'GET',
161
- path: '/users/search',
162
- dedupe: true
163
- })
372
+ const { error, retry } = await api.getUser({ id: '42' })
164
373
 
165
- // If a second call starts before the first finishes, the first is aborted
166
- await api.searchUsers({ q: 'hel' })
167
- 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
+ }
168
379
  ```
169
380
 
170
- #### `share`
381
+ `refreshToken` stands for your own token refresh.
382
+
383
+ #### Reporting errors with onError
171
384
 
172
- 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.
385
+ `onError` on `createApi` is one place to send every error to your tracker.
173
386
 
174
387
  ```ts
175
- const getProduct = new Request<{ id: string }, Product>({
176
- method: 'GET',
177
- path: '/products/:id',
178
- share: true
388
+ const api = createApi({
389
+ baseUrl: 'https://api.example.com',
390
+ requests: { getUser },
391
+ onError: (error) => logToTracker(error),
179
392
  })
180
-
181
- // Both calls join the same network request
182
- await Promise.all([
183
- api.getProduct({ id: '42' }),
184
- api.getProduct({ id: '42' })
185
- ])
186
393
  ```
187
394
 
188
- #### `bodyAs`
395
+ `logToTracker` stands for your error tracker, such as Sentry.
189
396
 
190
- 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:
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.
400
+
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.
191
404
 
192
405
  ```ts
193
- // DELETE endpoint that expects a JSON body
194
- const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
195
- method: 'DELETE',
196
- path: '/items',
197
- bodyAs: 'body'
198
- })
406
+ import { createApi, defineRequest } from 'liaise'
407
+
408
+ type Item = { id: string; name: string }
199
409
 
200
- // POST endpoint that sends params as query string
201
- const triggerJob = new Request<{ priority: number }, Job>({
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 }>()({
202
415
  method: 'POST',
203
- path: '/jobs/trigger',
204
- bodyAs: 'query'
416
+ path: '/orgs/:org/items',
205
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"}
206
426
  ```
207
427
 
208
- ### `defineRequest`
428
+ Which params go in the query string and which in the body is set per endpoint, under [Defining endpoints](#defining-endpoints).
209
429
 
210
- `new Request<TParams, TResponse>` makes you restate what the path already says,
211
- and nothing checks the two against each other:
430
+ #### Query strings
212
431
 
213
- ```ts
214
- // The params are restated by hand, and nothing checks them against the path:
215
- 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 |
216
440
 
217
- api.getUser({ userId: '42' }) // compiles — then fails at runtime: buildUrl finds
218
- // no `:userId` to substitute, `:id` survives, and
219
- // the unresolved-token check throws
220
- ```
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.
221
446
 
222
- `defineRequest` infers the params from the path literal instead:
447
+ #### Request bodies
223
448
 
224
- ```ts
225
- import { defineRequest } from 'liaise'
449
+ liaise serializes the body from what you pass, and sets `Content-Type` for you unless you set one yourself.
226
450
 
227
- 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` |
228
462
 
229
- api.getUser({ id: '42' }) // ✓
230
- api.getUser({ id: 42 }) // ✓ — numbers are encoded
231
- api.getUser({ userId: '42' }) // ✗ Object literal may only specify known properties
232
- ```
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)).
233
464
 
234
- Params the path does not name — query or body fields — go in the second type
235
- argument:
465
+ #### What params can be
236
466
 
237
- ```ts
238
- const listRepos = defineRequest<Repo[], { page?: number }>()({
239
- method: 'GET',
240
- path: '/orgs/:org/repos',
241
- })
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. |
242
475
 
243
- api.listRepos({ org: 'acme' }) // ✓ page is optional
244
- api.listRepos({ org: 'acme', page: 2 }) // ✓
245
- api.listRepos({ page: 2 }) // ✗ org is required
246
- ```
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.
477
+
478
+ Headers, including your own `Content-Type`, follow the [three levels of settings](#three-levels-of-settings).
247
479
 
248
- It also enforces the `responseType: 'none'` convention that `new Request` can
249
- only document:
480
+ ### Reading responses
481
+
482
+ A response can be JSON, text or a file. You say which with `responseType`, and liaise reads the body for you.
250
483
 
251
484
  ```ts
252
- defineRequest<undefined>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✓
253
- defineRequest<User>()({ method: 'POST', path: '/ping', responseType: 'none' }) // ✗
254
- ```
485
+ import { createApi, defineRequest } from 'liaise'
486
+
487
+ type User = { id: string; name: string }
488
+
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
+ })
255
501
 
256
- **Why two calls.** TypeScript has no partial type-argument inference: if the response
257
- type and the config were arguments to one call, supplying the response type explicitly
258
- would stop the path from being inferred, and the checking would quietly do nothing.
259
- Splitting them keeps the response type explicit and the path inferred. Calling it
260
- wrong is a compile error, not a silent one.
502
+ const api = createApi({
503
+ baseUrl: 'https://api.example.com',
504
+ requests: { getUser, downloadFile, deleteUser },
505
+ })
506
+ ```
261
507
 
262
- `new Request(...)` is unchanged and not deprecated — use it when the config is
263
- 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
+ ```
264
531
 
265
- ### Response validation
532
+ ### Validating responses
266
533
 
267
- Pass any [Standard Schema](https://standardschema.dev) validator — Zod, Valibot,
268
- ArkType — and the response is checked before you see it. liaise takes no
269
- 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.
270
535
 
271
536
  ```ts
537
+ import { createApi, defineRequest } from 'liaise'
272
538
  import { z } from 'zod'
273
539
 
274
540
  const getUser = defineRequest()({
@@ -277,452 +543,401 @@ const getUser = defineRequest()({
277
543
  schema: z.object({ id: z.string(), name: z.string() }),
278
544
  })
279
545
 
546
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
547
+
280
548
  const { data, error } = await api.getUser({ id: '42' })
281
549
  // ^? { id: string; name: string } | null
282
550
  ```
283
551
 
284
- The schema supplies the response type, so there is no type argument to write —
285
- and no second place for it to drift out of date.
286
-
287
- **`data` is the schema's output.** A schema that transforms changes what you
288
- receive:
552
+ Valibot and ArkType work the same way:
289
553
 
290
554
  ```ts
291
- const getUser = defineRequest()({
555
+ import * as v from 'valibot'
556
+ import { type } from 'arktype'
557
+
558
+ const withValibot = defineRequest()({
292
559
  method: 'GET',
293
560
  path: '/users/:id',
294
- schema: z.object({
295
- id: z.string(),
296
- createdAt: z.coerce.date(), // the wire sends a string
297
- role: z.string().default('user'), // absent on the wire
298
- }),
561
+ schema: v.object({ id: v.string(), name: v.string() }),
299
562
  })
300
563
 
301
- const { data } = await api.getUser({ id: '42' })
302
- data.createdAt // a real Date
303
- 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
+ })
304
569
  ```
305
570
 
306
- That is the point of validating through a schema rather than merely checking
307
- 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:
308
574
 
309
- 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
+ })
310
585
 
311
- ```ts
312
- const { error } = await api.getUser({ id: '42' })
313
- if (error?.kind === 'parse') {
314
- console.error(error.body) // the validator's issues
315
- error.status // the response's own status — the server was fine
316
- }
317
- ```
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
+ ```
318
590
 
319
- Only the **success** body is validated. A non-2xx body is diagnostic and often a
320
- 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:
321
592
 
322
- 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
+ ```
323
599
 
324
- ```ts
325
- const me = new Operation<{}, User>({
326
- operation: gql`query { me { id name } }`,
327
- schema: UserSchema,
328
- })
329
- ```
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:
330
603
 
331
- There the response type stays explicit — only `defineRequest` infers it.
604
+ ```ts
605
+ import { Operation, gql } from 'liaise'
332
606
 
333
- ### Pagination
607
+ const UserSchema = z.object({ id: z.string(), name: z.string() })
334
608
 
335
- `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
+ ```
336
614
 
337
- ```ts
338
- import { paginate } from 'liaise'
615
+ ### Cancelling, deadlines and stale requests
339
616
 
340
- for await (const page of paginate(api.listItems, { limit: 50 }, {
341
- next: (p, prev) => p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined,
342
- })) {
343
- if (page.error) break
344
- render(page.data.items)
345
- }
346
- ```
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.
347
618
 
348
- **`next` returns the next params, not a cursor.** That is what keeps this
349
- library out of the business of guessing where a cursor goes — `cursor`?
350
- `page_token`? `after`? The previous params arrive as the second argument, so
351
- the common case is a spread, and the same shape covers every scheme:
619
+ #### Cancel with a signal
620
+
621
+ Pass an `AbortSignal` in the call options:
352
622
 
353
623
  ```ts
354
- // offset
355
- next: (p, prev) => p.data.items.length === prev.limit
356
- ? { ...prev, offset: prev.offset + prev.limit }
357
- : undefined
358
-
359
- // page number, driven by a Link header
360
- next: (p, prev) => p.response.headers.get('link')?.includes('rel="next"')
361
- ? { ...prev, page: prev.page + 1 }
362
- : undefined
363
- ```
624
+ import { createApi, defineRequest } from 'liaise'
364
625
 
365
- Return `undefined` or `null` to stop.
626
+ type User = { id: string; name: string }
627
+
628
+ const getUser = defineRequest<User>()({ method: 'GET', path: '/users/:id' })
629
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
366
630
 
367
- **An error page is yielded, then the walk ends.** There is no data to read the
368
- next cursor from, so there is nothing to continue with — and you see what
369
- failed rather than a loop that quietly stopped.
631
+ const controller = new AbortController()
632
+ const pending = api.getUser({ id: '42' }, { signal: controller.signal })
370
633
 
371
- **`maxPages` is optional and has no default.** A ceiling exists if you want one;
372
- the library will not invent a number, because a silent truncation at an
373
- arbitrary limit looks exactly like reaching the last page.
634
+ controller.abort()
374
635
 
375
- ```ts
376
- 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'
377
638
  ```
378
639
 
379
- Any other [`CallOptions`](#calloptions) — `signal`, `timeout`, `headers` — apply
380
- 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).
641
+
642
+ #### Set a deadline with timeout
381
643
 
382
- `paginate` yields pages, not items. Flattening would mean deciding which field
383
- holds the array, which is the convention-guessing `next` exists to avoid.
644
+ `timeout` is in milliseconds, and you set it on the endpoint or on one call:
384
645
 
385
- ### Query strings
646
+ ```ts
647
+ import { createApi, defineRequest } from 'liaise'
648
+ import { retryMiddleware } from 'liaise/middleware'
386
649
 
387
- 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`.
650
+ type Report = { total: number }
388
651
 
389
- A `baseUrl` may carry its own query string — a fixed API key, say. Its params
390
- are merged ahead of the call's:
652
+ const getReport = defineRequest<Report>()({ method: 'GET', path: '/report', timeout: 3000 })
391
653
 
392
- ```ts
393
654
  const api = createApi({
394
- baseUrl: 'https://api.example.com/v1?key=abc',
395
- requests: { search: new Request<{ q: string }, Hit[]>({ method: 'GET', path: '/search' }) },
655
+ baseUrl: 'https://api.example.com',
656
+ requests: { getReport },
657
+ middleware: [retryMiddleware(3)],
396
658
  })
397
659
 
398
- await api.search({ q: 'hello' })
399
- // 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
400
662
  ```
401
663
 
402
- Merging **accumulates**, it does not override: a call param whose key the base
403
- already used produces both, `?key=abc&key=xyz`, and which one wins is the
404
- server's decision. This differs from headers, where a per-call value replaces a
405
- global one — because array params already serialize as repeated keys
406
- (`tags=a&tags=b`), so collapsing duplicates would break them. If a base-level
407
- 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.
408
669
 
409
- **A `#fragment` is refused.** A fragment is never sent to the server, so one in
410
- a `path` or `baseUrl` cannot do what it appears to — and before 4.2.1 it
411
- silently discarded the query string. It is now an error naming the offending
412
- value, rather than being stripped, so the dead code does not stay in your
413
- template.
670
+ #### Drop stale calls with dedupe
414
671
 
415
- Since 4.4.0 a fragment in a [`defineRequest`](#definerequest) `path` **literal**
416
- is also a compile error, so the endpoint is rejected where it is declared rather
417
- 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:
418
673
 
419
674
  ```ts
420
- defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
421
- // ^ Property '__fragmentInPath' is missing:
422
- // a URL fragment is never sent to the server
423
- ```
675
+ type User = { id: string; name: string }
424
676
 
425
- The check reads the literal, so a path assembled at runtime — or a
426
- `RequestConfig`-typed variable — still compiles and is caught by the runtime
427
- error instead. `new Request` takes no path literal, so it has no equivalent
428
- 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
+ })
429
682
 
430
- **A `#` inside a param *value* is not a fragment** and is never refused — it is
431
- escaped to `%23` and sent as ordinary data:
683
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { searchUsers } })
432
684
 
433
- ```ts
434
- await api.getDoc({ id: 'a#b' }) // → GET /docs/a%23b
435
- 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
436
689
  ```
437
690
 
438
- Only a `#` written into a `path` or `baseUrl` is refused, because that one was
439
- never going to reach the server.
440
-
441
- | Input | Output |
442
- | ------------------------------ | --------------------------- |
443
- | `{ page: 1, limit: 20 }` | `?page=1&limit=20` |
444
- | `{ tags: ['a', 'b'] }` | `?tags=a&tags=b` |
445
- | `{ filter: null }` | _(omitted)_ |
446
- | `{ filter: undefined }` | _(omitted)_ |
447
- | `{ meta: { nested: true } }` | **TypeError** (see below) |
448
- | `{ since: new Date() }` | **TypeError**: convert it first (`toISOString()` or `getTime()`) |
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
- **Arrays** use repeated keys (`tags=a&tags=b`), which is the most widely supported format across server frameworks.
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
- **`null` and `undefined`** values are silently omitted from the query string.
698
+ ### Sharing identical requests
453
699
 
454
- **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.
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
- **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()`.
457
-
458
- ### Result
702
+ ```ts
703
+ import { createApi, defineRequest } from 'liaise'
459
704
 
460
- 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 }
461
706
 
462
- ```ts
463
- interface SuccessResult<TResponse> {
464
- data: TResponse // parsed response
465
- error: null
466
- response: Response // always present on success
467
- retry: () => Promise<Result<TResponse>>
468
- }
707
+ const getProduct = defineRequest<Product>()({
708
+ method: 'GET',
709
+ path: '/products/:id',
710
+ share: true,
711
+ })
469
712
 
470
- interface ErrorResult<TResponse> {
471
- data: null
472
- error: ApiError // structured error, see below
473
- response: Response | null // present for HTTP/parse failures, null for network/abort/timeout
474
- retry: () => Promise<Result<TResponse>>
475
- }
713
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getProduct } })
476
714
 
477
- 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
+ ])
478
720
  ```
479
721
 
480
- 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
481
- 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.
482
729
 
483
730
  ```ts
484
- const { data, error, response, retry } = await api.getUser({ id: '42' })
485
-
486
- if (error) {
487
- switch (error.kind) {
488
- case 'network':
489
- // fetch itself failed -- user is probably offline
490
- break
491
- case 'timeout':
492
- // the whole-operation deadline fired; report it
493
- reportTimeout(error)
494
- break
495
- case 'abort':
496
- // this call was cancelled (dedupe supersede, or your own signal) -- usually ignore it
497
- break
498
- case 'parse':
499
- // a 2xx response arrived but its body didn't parse as `responseType`
500
- console.error('unparseable response', error.status, error.body)
501
- break
502
- case 'middleware':
503
- // a middleware threw -- a bug in your own pipeline, not a transient failure
504
- console.error('middleware threw', error.body)
505
- break
506
- case 'http':
507
- if (error.status === 401) redirectToLogin()
508
- else console.error(error.status, error.body)
509
- break
510
- }
511
- return
512
- }
731
+ const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
732
+ const patient = api.getProduct({ id: '42' }) // keeps waiting
513
733
 
514
- // error is null here, so `data` is narrowed to `User` -- no assertion needed
515
- // (this assumes getUser always answers with a body; an endpoint that
516
- // doesn't -- a DELETE returning 204, most commonly -- should use
517
- // responseType: 'none' instead, see the empty-body note above)
518
- console.log(data.name)
734
+ // impatient's timeout doesn't cancel the shared request, so patient still gets the response.
519
735
  ```
520
736
 
521
- `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).
522
738
 
523
- #### `retry()`
739
+ #### On a server
524
740
 
525
- 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.
526
742
 
527
- ```ts
528
- 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.
529
744
 
530
- if (error?.status === 401) {
531
- await refreshToken()
532
- const retried = await retry()
533
- // retried goes through the full middleware chain again
534
- }
535
- ```
745
+ ### Retries, caching and logging
536
746
 
537
- #### `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:
538
748
 
539
- 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
+ ```
540
752
 
541
- | Property | Type | Description |
542
- | ------------ | --------- | ----------------------------------------------------------------- |
543
- | `status` | `number` | HTTP status code (e.g., 404, 500). `0` for network errors, aborts, and timeouts. |
544
- | `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. |
545
- | `statusText` | `string` | HTTP status text (e.g., 'Not Found'). `''` for network errors. |
546
- | `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. |
547
- | `headers` | `Headers` | Response headers. Empty `Headers` for network errors. |
548
- | `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. |
549
- | `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
550
754
 
551
- `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`:
552
756
 
553
- `'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
554
- parsing](#response-parsing). GraphQL applies the same rule to a 2xx response
555
- 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'
556
760
 
557
- `'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 }
558
762
 
559
- You can use `instanceof` to check if a value is an `ApiError`:
763
+ const getItems = defineRequest<Item[]>()({ method: 'GET', path: '/items' })
560
764
 
561
- ```ts
562
- import { ApiError } from 'liaise'
765
+ const api = createApi({
766
+ baseUrl: 'https://api.example.com',
767
+ requests: { getItems },
768
+ middleware: [retryMiddleware(2)], // 5xx only
769
+ })
563
770
 
564
- if (error instanceof ApiError) {
565
- // ...
566
- }
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
+ })
567
776
  ```
568
777
 
569
- ### Error handling with `onError`
570
-
571
- 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:
572
779
 
573
780
  ```ts
574
- const api = createApi({
575
- baseUrl: '/api',
576
- requests: { getUser, createUser },
577
- onError: (error) => {
578
- if (error.status === 401) redirectToLogin()
579
- Sentry.captureException(error)
580
- }
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`),
581
785
  })
582
786
  ```
583
787
 
584
- 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.
585
790
 
586
- ### Middleware
791
+ #### Cache repeated reads
587
792
 
588
- 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:
589
794
 
590
- ```
591
- Request → [Global MW → [Per-request MW → [Per-call MW → [fetch]]]]
592
- ```
795
+ ```ts
796
+ import { createApi, defineRequest } from 'liaise'
797
+ import { cacheMiddleware } from 'liaise/middleware'
593
798
 
594
- A middleware function receives a `context` and a `next` function:
799
+ type User = { id: string; name: string }
595
800
 
596
- ```ts
597
- import type { Middleware } from 'liaise'
801
+ const getUserCache = cacheMiddleware({ ttl: 5 * 60_000, maxSize: 100 })
598
802
 
599
- const authMiddleware: Middleware = async (ctx, next) => {
600
- // Before: modify the request
601
- ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
803
+ const getUser = defineRequest<User>()({
804
+ method: 'GET',
805
+ path: '/users/:id',
806
+ middleware: [getUserCache],
807
+ })
602
808
 
603
- // Call the next layer
604
- const result = await next()
809
+ const api = createApi({ baseUrl: 'https://api.example.com', requests: { getUser } })
605
810
 
606
- // After: inspect or transform the result
607
- return result
608
- }
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] })
609
816
  ```
610
817
 
611
- #### 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)).
612
822
 
613
- - **Modify the request** -- set headers, change the body, rewrite the URL.
614
- - **Short-circuit** -- return early without calling `next()` (e.g., serve from cache).
615
- - **Retry** -- call `next()` multiple times in a loop (e.g., retry on 5xx).
616
- - **Inspect the result** -- log, report errors, transform response data.
823
+ #### Log every call
617
824
 
618
- #### Three layers
825
+ `logMiddleware` prints each call's start and end to the console, with its duration:
826
+
827
+ ```text
828
+ [liaise] → GET getItems /api/items
829
+ [liaise] ← getItems OK (142ms)
619
830
 
620
- Middleware is applied at three levels. The execution order is global first, per-request second, per-call third:
831
+ [liaise] → POST createUser /api/users
832
+ [liaise] ← createUser ERROR 422 (89ms)
833
+ ```
621
834
 
622
835
  ```ts
623
- // Global -- applies to every endpoint
624
- const api = createApi({
625
- baseUrl: '/api',
626
- requests: { getUser, createUser },
627
- middleware: [authMiddleware, logMiddleware]
628
- })
836
+ import { createApi, defineRequest } from 'liaise'
837
+ import { logMiddleware } from 'liaise/middleware'
629
838
 
630
- // Per-request -- applies only to this endpoint
631
- const getUser = new Request<{ id: string }, User>({
632
- method: 'GET',
633
- path: '/users/:id',
634
- middleware: [cacheMiddleware]
635
- })
839
+ const getItems = defineRequest<{ id: string }[]>()({ method: 'GET', path: '/items' })
636
840
 
637
- // Per-call -- applies only to this single invocation
638
- await api.getUser({ id: '42' }, {
639
- middleware: [customTraceMiddleware]
640
- })
841
+ const api = createApi({ baseUrl: '/api', requests: { getItems }, middleware: [logMiddleware] })
641
842
  ```
642
843
 
643
- #### `skipMiddleware`
844
+ It is meant for development. In production, [write a middleware](#writing-middleware) that sends the same facts to your monitoring.
845
+
846
+ ### Writing middleware
644
847
 
645
- Remove specific middleware for a single call by passing references to `skipMiddleware`:
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.
646
849
 
647
850
  ```ts
648
- 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' })
649
867
 
650
868
  const api = createApi({
651
- baseUrl: '/api',
869
+ baseUrl: 'https://api.example.com',
652
870
  requests: { getUser },
653
- middleware: [retry, logMiddleware]
871
+ middleware: [auth],
654
872
  })
873
+ ```
655
874
 
656
- // Skip retry for this one call
657
- await api.getUser({ id: '42' }, {
658
- skipMiddleware: [retry]
659
- })
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]]]]
660
881
  ```
661
882
 
662
- 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:
663
884
 
664
- #### `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`.
665
889
 
666
- 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.
667
891
 
668
- | Property | Type | Description |
669
- | --------------------- | --------- | -------------------------------------------------------- |
670
- | `request.method` | `string` | HTTP method (GET, POST, etc.) |
671
- | `request.url` | `string` | Fully resolved URL with path params and query string |
672
- | `request.path` | `string` | Original path template (e.g., '/users/:id') |
673
- | `request.params` | `unknown` | Original params object from the caller |
674
- | `request.headers` | `Headers` | Merged headers -- middleware can add/remove entries |
675
- | `request.body` | `unknown` | Serialized body, or null for GET/DELETE |
676
- | `request.signal` | `AbortSignal \| undefined` | The signal handed to `fetch` -- replace it to impose your own cancellation policy |
677
- | `requestName` | `string` | Key name in the requests object (e.g., 'getUser') |
892
+ #### Skipping a middleware for one call
678
893
 
679
- `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`:
680
895
 
681
896
  ```ts
682
- const timeout = (ms: number): Middleware => async (ctx, next) => {
683
- ctx.request.signal = AbortSignal.timeout(ms)
684
- return next()
685
- }
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)
686
904
 
687
905
  const api = createApi({
688
- baseUrl: '/api',
906
+ baseUrl: 'https://api.example.com',
689
907
  requests: { getUser },
690
- middleware: [timeout(5000)]
908
+ middleware: [retry, logMiddleware],
691
909
  })
910
+
911
+ // No retries for this one call
912
+ await api.getUser({ id: '42' }, { skipMiddleware: [retry] })
692
913
  ```
693
914
 
694
- 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.
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
- **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.
917
+ #### What a middleware sees
697
918
 
698
- #### Writing custom middleware
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
- A cache middleware that short-circuits on cache hits:
921
+ #### Signals in middleware
701
922
 
702
- ```ts
703
- const cacheMiddleware: Middleware = async (ctx, next) => {
704
- const cached = cache.get(ctx.request.url)
705
- if (cached) return cached
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
- const result = await next()
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.
708
926
 
709
- if (result.data) {
710
- cache.set(ctx.request.url, result)
711
- }
927
+ #### Example: report server errors
712
928
 
713
- return result
714
- }
715
- ```
716
-
717
- An error reporting middleware:
929
+ This middleware sends every 5xx to Sentry, with the method and URL:
718
930
 
719
931
  ```ts
720
- 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) => {
721
936
  const result = await next()
722
937
 
723
938
  if (result.error && result.error.status >= 500) {
724
939
  Sentry.captureMessage(`API error: ${ctx.request.method} ${ctx.request.url}`, {
725
- extra: { status: result.error.status, body: result.error.body }
940
+ extra: { status: result.error.status, body: result.error.body },
726
941
  })
727
942
  }
728
943
 
@@ -730,309 +945,455 @@ const sentryMiddleware: Middleware = async (ctx, next) => {
730
945
  }
731
946
  ```
732
947
 
733
- #### Built-in middleware
948
+ ### Pagination
734
949
 
735
- 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:
736
951
 
737
952
  ```ts
738
- import { retryMiddleware, logMiddleware, cacheMiddleware } from 'liaise/middleware'
739
- ```
953
+ import { createApi, defineRequest, paginate } from 'liaise'
740
954
 
741
- **`retryMiddleware(options?: number | RetryOptions)`**
955
+ type Item = { id: string; name: string }
956
+ type Page = { items: Item[]; cursor?: string }
742
957
 
743
- 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
+ })
744
962
 
745
- 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 } })
746
964
 
747
- ```ts
748
- const api = createApi({
749
- baseUrl: '/api',
750
- requests: { getItems },
751
- middleware: [retryMiddleware(2)]
752
- })
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
+ }
753
971
  ```
754
972
 
755
- 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:
756
976
 
757
- **Retry policy**
977
+ ```ts
978
+ // offset
979
+ next: (p, prev) => p.data.items.length === prev.limit
980
+ ? { ...prev, offset: prev.offset + prev.limit }
981
+ : undefined
758
982
 
759
- Pass a `RetryOptions` object instead of a number for full control:
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
+ ```
988
+
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.
994
+
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 }`.
760
998
 
761
999
  ```ts
762
- const api = createApi({
763
- baseUrl: '/api',
764
- requests: { getItems },
765
- middleware: [retryMiddleware({
766
- max: 5,
767
- delay: 'exponential',
768
- baseDelay: 250,
769
- maxDelay: 10_000,
770
- jitter: true,
771
- respectRetryAfter: true,
772
- onRetry: ({ attempt, max, delay }) => console.log(`retry ${attempt}/${max} in ${delay}ms`),
773
- })],
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),
774
1022
  })
775
- ```
776
1023
 
777
- | Option | Type | Default | Description |
778
- | -------------------- | -------------------------------------------------- | ----------------- | ----------- |
779
- | `max` | `number` | `3` | Additional attempts after the first. `retryMiddleware({ max: 2 })` means up to 3 total calls. |
780
- | `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. |
781
- | `baseDelay` | `number` | `250` | The first delay, in milliseconds, before jitter and `Retry-After` are applied. |
782
- | `maxDelay` | `number` | `30000` | Hard cap applied to every computed delay, including a `Retry-After` value. |
783
- | `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. |
784
- | `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`). |
785
- | `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. |
786
- | `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
+ ```
787
1026
 
788
- `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.
789
1028
 
790
- **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:
791
1030
 
792
1031
  ```ts
793
- // Retry 429 in addition to 5xx
794
- retryMiddleware({
795
- retryOn: r => r.error?.status === 429 || (r.error?.status ?? 0) >= 500
796
- })
1032
+ type Viewer = { id: string; name: string }
797
1033
 
798
- // Retry network errors (status 0) too — but not aborts, which are also status 0
799
- retryMiddleware({
800
- 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 } }`,
801
1036
  })
1037
+
1038
+ const api = createGraphQL({ endpoint: 'https://api.example.com/graphql', operations: { getViewer } })
1039
+
1040
+ const { data } = await api.getViewer() // no arguments
802
1041
  ```
803
1042
 
804
- **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.
805
1046
 
806
1047
  ```ts
807
- const api = createApi({
808
- baseUrl: '/api',
809
- requests: {
810
- getItems: new Request<Record<string, never>, Item[]>({
811
- method: 'GET',
812
- path: '/items',
813
- timeout: 2000, // whole-operation deadline
814
- })
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
+ }),
815
1061
  },
816
- middleware: [retryMiddleware({ max: 5, baseDelay: 1000 })], // long backoff
817
1062
  })
818
1063
 
819
- const { error } = await api.getItems()
820
- // If the 2s deadline fires while retryMiddleware is asleep between attempts:
821
- // 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' })
822
1066
  ```
823
1067
 
824
- **`logMiddleware`**
1068
+ #### GraphQL errors
825
1069
 
826
- 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.
827
1071
 
828
- ```
829
- [liaise] → GET getItems /api/items
830
- [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.
831
1073
 
832
- [liaise] → POST createUser /api/users
833
- [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
+ }
834
1080
  ```
835
1081
 
836
- 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.
837
1112
 
838
1113
  ```ts
839
- const api = createApi({
840
- baseUrl: '/api',
841
- requests: { getItems },
842
- 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 }),
843
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
844
1124
  ```
845
1125
 
846
- **`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
+ ```
847
1135
 
848
- 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.
849
1137
 
850
- 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.
851
1141
 
852
1142
  ```ts
853
- 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
+ ```
854
1147
 
855
- const getUser = new Request<{ id: string }, User>({
856
- method: 'GET',
857
- path: '/users/:id',
858
- middleware: [getUserCache],
859
- })
1148
+ `api` is a client created with `baseUrl: '/api'`.
860
1149
 
861
- // On logout — clear all cached entries:
862
- getUserCache.clear()
1150
+ An empty array for a route behaves the same way, with a descriptive `Error` in `error.body`.
863
1151
 
864
- // Bypass cache for a single call:
865
- 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' }))
866
1161
  ```
867
1162
 
868
- 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).
869
1164
 
870
- ### Content types
1165
+ ## Recipes
871
1166
 
872
- 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.
873
1168
 
874
- | Input type | Body output | Content-Type |
875
- | ----------------- | ------------------ | ------------------------------------- |
876
- | `null`/`undefined` | `null` | _(none)_ |
877
- | `string` | as-is | `text/plain` |
878
- | `FormData` | as-is | _(browser sets multipart boundary)_ |
879
- | `URLSearchParams` | as-is | `application/x-www-form-urlencoded` |
880
- | `Blob` | as-is | `application/octet-stream` |
881
- | `ArrayBuffer` | as-is | `application/octet-stream` |
882
- | Typed array, `DataView`, `Buffer` | as-is (sent as binary) | `application/octet-stream` |
883
- | `ReadableStream` | as-is (streaming upload; `duplex: 'half'` is set for you) | `application/octet-stream` |
884
- | Plain object | `JSON.stringify()` | `application/json` |
1169
+ ### Add an auth header and refresh the token on a 401
885
1170
 
886
- 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.
887
1172
 
888
- #### What params can be
1173
+ <!-- tested: auth-refresh -->
1174
+ ```ts
1175
+ import { createApi, defineRequest, type Middleware } from 'liaise'
889
1176
 
890
- | You pass | What happens |
891
- | -------- | ------------ |
892
- | Plain object | Decomposed into path tokens, query string and body |
893
- | `Map` with string keys | Same as the object it spells |
894
- | 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) |
895
- | Class instance with only `toJSON()` | Sent as its JSON (body only; refused on a request whose params go in the query string) |
896
- | 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) |
897
- | `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 }
898
1179
 
899
- 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' }
900
1181
 
901
- Header merge precedence (most specific wins):
1182
+ const auth: Middleware = async (ctx, next) => {
1183
+ if (ctx.requestName === 'refresh') return next()
902
1184
 
903
- 1. **Global headers** (from `createApi` config) -- lowest priority
904
- 2. **Per-request headers** (from `Request` config) -- overrides global
905
- 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
+ }
906
1200
 
907
- 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
+ ```
908
1214
 
909
- ### 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.
910
1216
 
911
- The `responseType` option on a `Request` determines how the response body is parsed:
1217
+ ### Search as you type
912
1218
 
913
- | `responseType` | Method called | Return type |
914
- | --------------- | ---------------------- | -------------- |
915
- | `'json'` | `response.text()` then `JSON.parse()` | parsed object |
916
- | `'text'` | `response.text()` | `string` |
917
- | `'blob'` | `response.blob()` | `Blob` |
918
- | `'arrayBuffer'` | `response.arrayBuffer()` | `ArrayBuffer` |
919
- | `'formData'` | `response.formData()` | `FormData` |
920
- | `'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.
921
1220
 
922
- The default is `'json'`. An **empty body under `'json'` is an error**, not a
923
- `null`: you declared JSON and the server sent none, so there is no value that
924
- could honestly satisfy `TResponse`. You get `kind: 'parse'` with the
925
- response's own status (a `204` reports `204`), a non-null `response`, and the
926
- raw body text -- always `''` for this case -- in `error.body`:
1221
+ `Repo` is your result type; `render` and `showError` stand for your UI code.
927
1222
 
1223
+ <!-- tested: search-as-you-type -->
928
1224
  ```ts
929
- const { data, error } = await api.deleteUser({ id: '42' })
930
- // 204 No Content, responseType left at the 'json' default:
931
- // 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
+ }
932
1240
  ```
933
1241
 
934
- A literal `null` body is **not** empty -- `JSON.parse("null")` is valid JSON,
935
- 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`.
936
1243
 
937
- **`responseType: 'none'`** is the declaration for an endpoint that returns no
938
- body on success -- a `204`, or a `200` with an empty body, most commonly a
939
- `DELETE`:
1244
+ ### Use with TanStack Query
940
1245
 
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).
1247
+
1248
+ <!-- tested: tanstack-query -->
941
1249
  ```ts
942
- const deleteUser = new Request<{ id: string }, undefined>({
943
- method: 'DELETE',
944
- path: '/users/:id',
945
- 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 })),
946
1263
  })
1264
+
1265
+ // React: useQuery(userQuery(id))
1266
+ // Vue: useQuery(computed(() => userQuery(id.value)))
1267
+ // Svelte: createQuery(() => userQuery(id))
1268
+ // Solid: useQuery(() => userQuery(id()))
947
1269
  ```
948
1270
 
949
- 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.
1272
+
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`.
1274
+
1275
+ ### Use with React
950
1276
 
951
- `'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:
1277
+ This hook uses React's `useEffect` and `useState`, imported from `react`. `api` and `User` are from [Quick start](#quick-start).
952
1278
 
1279
+ <!-- tested: react-effect -->
953
1280
  ```ts
954
- const { error } = await api.deleteUser({ id: '42' })
955
- if (error) {
956
- // A 409 { "error": "already deleted" } still lands in error.body here,
957
- // even though deleteUser declares responseType: 'none'.
958
- 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
959
1294
  }
960
1295
  ```
961
1296
 
962
- (3.0.0's advice for this case was to widen the endpoint's `TResponse` to
963
- `| null`. That advice is superseded: `responseType: 'none'` declares "no body"
964
- rather than "body or null", and since 4.0.0 the `| null` workaround no longer
965
- works at all -- the empty body is an error before `TResponse` is ever
966
- consulted. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400).)
967
-
968
- ### Cancellation
1297
+ Aborting on cleanup means a fast navigation never writes stale data into a component that has moved on.
969
1298
 
970
- #### Manual abort via `AbortSignal`
1299
+ ### Load the current user into a store
971
1300
 
972
- 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.
973
1302
 
1303
+ <!-- tested: store-me -->
974
1304
  ```ts
975
- const controller = new AbortController()
1305
+ import { createApi, defineRequest } from 'liaise'
976
1306
 
977
- const promise = api.getItems({ page: 1 }, {
978
- signal: controller.signal
979
- })
1307
+ const me = defineRequest<User>()({ method: 'GET', path: '/me', share: true })
1308
+ const api = createApi({ baseUrl: '/api', requests: { me } })
980
1309
 
981
- // Cancel the request
982
- controller.abort()
1310
+ // Any store works the same way: Zustand, Pinia, Redux or a plain object.
1311
+ const store = { user: null as User | null }
983
1312
 
984
- const { error } = await promise
985
- // 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
+ }
986
1318
  ```
987
1319
 
988
- 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'`.
989
-
990
- 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.
991
1321
 
992
- #### Auto-cancel via `dedupe`
1322
+ ### One /me per page view on the server
993
1323
 
994
- 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.
995
1325
 
1326
+ <!-- tested: server-loaders -->
996
1327
  ```ts
997
- const searchUsers = new Request<{ q: string }, User[]>({
998
- method: 'GET',
999
- path: '/users/search',
1000
- dedupe: true
1001
- })
1002
-
1003
- const api = createApi({
1004
- baseUrl: '/api',
1005
- requests: { searchUsers }
1006
- })
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
+ }
1007
1341
 
1008
- // Rapid calls -- only the last one completes
1009
- api.searchUsers({ q: 'h' }) // aborted by next call
1010
- api.searchUsers({ q: 'he' }) // aborted by next call
1011
- 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
+ }
1012
1350
  ```
1013
1351
 
1014
- 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.
1015
1353
 
1016
- ### Timeout
1354
+ ### Retry a flaky backend within one deadline
1017
1355
 
1018
- 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.
1019
1357
 
1358
+ <!-- tested: flaky-backend -->
1020
1359
  ```ts
1021
- const getUser = new Request<{ id: string }, User>({
1022
- method: 'GET',
1023
- path: '/users/:id',
1024
- 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
+ },
1025
1372
  })
1026
1373
 
1027
- const { error } = await api.getUser({ id: '42' })
1028
- // 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
+ })
1029
1382
  ```
1030
1383
 
1031
- **`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).
1032
1385
 
1033
- 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
1034
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 -->
1035
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.
1036
1397
  const perAttempt = (ms: number): Middleware => async (ctx, next) => {
1037
1398
  ctx.request.signal = AbortSignal.timeout(ms)
1038
1399
  return next()
@@ -1041,385 +1402,539 @@ const perAttempt = (ms: number): Middleware => async (ctx, next) => {
1041
1402
  const api = createApi({
1042
1403
  baseUrl: '/api',
1043
1404
  requests: { getUser },
1044
- 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
+ ],
1045
1410
  })
1046
1411
  ```
1047
1412
 
1048
- 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.
1049
-
1050
- A few more details:
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.
1051
1414
 
1052
- - `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.
1053
- - 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'`).
1054
- - `result.retry()` always starts a fresh deadline. A retried call is not charged against the original budget.
1055
- - Non-positive or omitted `timeout` disables it entirely (the default).
1056
- - `timeout` composes with `dedupe: true` — the deadline is merged with the dedupe signal rather than discarded by it.
1057
- - 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).
1415
+ ### Report errors to Sentry
1058
1416
 
1059
- **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.
1060
1418
 
1419
+ <!-- tested: report-errors -->
1061
1420
  ```ts
1062
- const auth: Middleware = async (ctx, next) => {
1063
- const token = await user.getIdToken() // stalls on a bad network
1064
- ctx.request.headers.set('Authorization', `Bearer ${token}`)
1065
- return next()
1066
- }
1067
- // 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
+ })
1068
1434
  ```
1069
1435
 
1070
- 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.
1071
1437
 
1072
- 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.
1073
1439
 
1074
- ### Sharing
1440
+ ### Upload and download files
1075
1441
 
1076
- 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`.
1077
1443
 
1444
+ <!-- tested: files -->
1078
1445
  ```ts
1079
- 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>()({
1080
1457
  method: 'GET',
1081
- path: '/products/:id',
1082
- share: true
1458
+ path: '/files/:id',
1459
+ responseType: 'blob',
1083
1460
  })
1084
1461
 
1085
- // Only one network request is made; both callers get the same response
1086
- const [a, b] = await Promise.all([
1087
- api.getProduct({ id: '42' }),
1088
- api.getProduct({ id: '42' })
1089
- ])
1462
+ const api = createApi({ baseUrl: '/api', requests: { uploadAvatar, downloadFile } })
1090
1463
  ```
1091
1464
 
1092
- `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).
1093
1466
 
1094
- **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
1095
1468
 
1096
- **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.
1097
1470
 
1098
- - 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.
1099
- - 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
1100
1472
 
1101
- **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.
1102
1474
 
1103
- **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).
1104
1476
 
1105
- ```ts
1106
- const impatient = api.getProduct({ id: '42' }, { timeout: 20 }) // gives up quickly
1107
- const patient = api.getProduct({ id: '42' }) // keeps waiting
1477
+ For two or three calls, plain fetch is fine.
1108
1478
 
1109
- // impatient's early timeout does not cancel the shared request —
1110
- // patient still gets a real response.
1111
- ```
1479
+ liaise is a good fit when you have:
1112
1480
 
1113
- **`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).
1114
1484
 
1115
- **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
1116
1486
 
1117
- ### 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.
1118
1488
 
1119
- Type inference flows automatically from `Request` generics through `createApi` to the call site. You never annotate the API methods manually.
1489
+ <!-- compare:start -->
1120
1490
 
1121
- ```ts
1122
- // 1. Types are declared on the Request
1123
- const getUser = new Request<{ id: string }, User>({
1124
- method: 'GET',
1125
- path: '/users/:id'
1126
- })
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.
1127
1492
 
1128
- // 2. createApi infers method signatures from the requests record
1129
- const api = createApi({
1130
- baseUrl: '/api',
1131
- requests: { getUser }
1132
- })
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) |
1133
1505
 
1134
- // 3. Call site is fully typed -- no annotations needed
1135
- const { data, error } = await api.getUser({ id: '42' })
1136
- // ^? User | null
1137
- ```
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.
1138
1507
 
1139
- 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).
1140
1509
 
1141
- - `Request<TParams, TResponse>` carries the type info.
1142
- - `createApi` uses internal conditional types to pull the `TParams` and `TResponse` generics from each `Request` instance.
1143
- - A mapped type transforms the requests record into callable methods: each key becomes `(params: TParams, options?: CallOptions) => Promise<Result<TResponse>>`.
1144
- - 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 |
1145
1518
 
1146
- 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.
1147
1520
 
1148
- ```ts
1149
- import type {
1150
- Result,
1151
- CallOptions,
1152
- Middleware,
1153
- MiddlewareContext,
1154
- MiddlewareNext,
1155
- RequestConfig,
1156
- ApiConfig
1157
- } from 'liaise'
1158
- ```
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.
1159
1522
 
1160
- ## 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).
1161
1524
 
1162
- 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 -->
1163
1526
 
1164
- 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.
1165
1528
 
1166
- ```ts
1167
- 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.
1168
1530
 
1169
- interface Category {
1170
- id: string
1171
- name: string
1172
- status: string
1173
- }
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.
1174
1532
 
1175
- const GET_CATEGORY = gql`
1176
- query GetCategory($id: String!) {
1177
- category(id: $id) {
1178
- id
1179
- name
1180
- status
1181
- }
1182
- }
1183
- `
1533
+ ### Where it runs
1184
1534
 
1185
- const getCategory = new Operation<{ id: string }, Category>({
1186
- operation: GET_CATEGORY,
1187
- })
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 |
1188
1540
 
1189
- const graphql = createGraphQL({
1190
- endpoint: 'https://api.example.com/graphql',
1191
- operations: { getCategory },
1192
- onError: (error) => console.error(error.status, error.body),
1193
- })
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'`.
1194
1542
 
1195
- const { data, error, response, retry } = await graphql.getCategory({ id: '123' })
1196
- ```
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.
1197
1544
 
1198
- 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
1199
1546
 
1200
- ```ts
1201
- const getViewer = new Operation<Record<string, never>, ViewerData>({ operation: GET_VIEWER })
1202
- const graphql = createGraphQL({ endpoint, operations: { getViewer } })
1547
+ ### Never throws
1203
1548
 
1204
- const { data } = await graphql.getViewer() // params argument is optional
1205
- ```
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.
1570
+
1571
+ ## Reference
1206
1572
 
1207
- ### Queries and mutations
1573
+ Every option, type and export, read from the source. The guide explains when to use each one.
1208
1574
 
1209
- 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.
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`.
1210
1645
 
1211
1646
  ```ts
1212
- const graphql = createGraphQL({
1213
- endpoint: 'https://api.example.com/graphql',
1214
- queries: {
1215
- getCategory: new Operation<{ id: string }, Category>({ operation: GET_CATEGORY }),
1216
- },
1217
- mutations: {
1218
- updateCategory: new Operation<{ id: string; name: string }, Category>({
1219
- operation: gql`
1220
- mutation UpdateCategory($id: String!, $name: String!) {
1221
- updateCategory(id: $id, name: $name) { id name status }
1222
- }
1223
- `,
1224
- }),
1225
- },
1226
- })
1647
+ interface SuccessResult<TResponse> {
1648
+ data: TResponse
1649
+ error: null
1650
+ response: Response // always present on success
1651
+ retry: () => Promise<Result<TResponse>>
1652
+ }
1227
1653
 
1228
- graphql.query.getCategory({ id: '123' })
1229
- 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>
1230
1662
  ```
1231
1663
 
1232
- ### GraphQL errors
1664
+ `response` is set for `'http'` and `'parse'` errors, where the server answered. It is `null` for `'network'`, `'abort'`, `'timeout'` and `'middleware'`.
1233
1665
 
1234
- 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`.
1235
1667
 
1236
- 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. |
1237
1677
 
1238
1678
  ```ts
1239
- const { error } = await graphql.getCategory({ id: '123' })
1240
- if (error) {
1241
- console.log(error.body) // GraphQLError[]
1242
- console.log(error.partialData) // whatever `data` the server sent alongside the errors, or undefined
1243
- }
1679
+ type ApiErrorKind = 'http' | 'network' | 'abort' | 'timeout' | 'parse' | 'middleware'
1244
1680
  ```
1245
1681
 
1246
- 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`:
1247
1683
 
1248
1684
  ```ts
1249
- const { error } = await graphql.getCategory({ id: '123' })
1250
- if (error) {
1251
- console.log(error.kind) // 'parse'
1252
- 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)
1253
1689
  }
1254
1690
  ```
1255
1691
 
1256
- 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
1257
1693
 
1258
- 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`:
1259
1695
 
1260
- ### 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'`. |
1261
1706
 
1262
- `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).
1263
1708
 
1264
- ```ts
1265
- const graphql = createGraphQL({
1266
- endpoint: 'https://api.example.com/graphql',
1267
- operations: { getCategory },
1268
- middleware: [authMiddleware],
1269
- })
1270
- ```
1709
+ ### Built-in middleware options
1271
1710
 
1272
- ### API Reference additions
1711
+ #### RetryOptions
1273
1712
 
1274
- | Export | Kind | Description |
1275
- | ------------------- | -------- | ---------------------------------------------------------------------- |
1276
- | `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
1277
- | `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
1278
- | `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
1279
- | `OperationConfig` | type | Config object for the `Operation` constructor |
1280
- | `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
1281
- | `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
1713
+ `retryMiddleware(n)` is short for `retryMiddleware({ max: n })`, and `retryMiddleware()` means `{ max: 3 }`.
1282
1714
 
1283
- ## 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. |
1284
1725
 
1285
- `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`:
1286
1727
 
1287
- ```ts
1288
- 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. |
1289
1734
 
1290
- const mock = mockFetch({
1291
- 'GET /api/users/:id': ({ params }) => jsonResponse({ id: params.id, name: 'Ada' }),
1292
- 'POST /api/users': jsonResponse({ id: 'new-user' }, { status: 201 }),
1293
- })
1735
+ #### Cache options
1294
1736
 
1295
- mock.install() // replaces globalThis.fetch
1296
- // ... exercise your code, which calls the real api.getUser(...) ...
1297
- mock.restore() // puts the original globalThis.fetch back
1298
- ```
1737
+ `cacheMiddleware(options)` returns a middleware with a `clear()` method, typed `CacheMiddleware`.
1299
1738
 
1300
- 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. |
1301
1744
 
1302
- ```ts
1303
- const mock = mockFetch({
1304
- 'GET /api/flaky': [jsonResponse(null, { status: 503 }), jsonResponse({ ok: true })],
1305
- })
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)
1306
1755
  ```
1307
1756
 
1308
- `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`. |
1309
1771
 
1310
- 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.
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`**
1786
+
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:
1311
1863
 
1312
1864
  ```ts
1313
- const r = await api.getUser({ id: '42' }) // routes only define 'GET /api/user/:id'
1314
- expect(r.error?.kind).toBe('network')
1315
- 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.
1316
1871
  ```
1317
1872
 
1318
- 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`.
1319
1874
 
1320
- 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.
1321
1876
 
1322
- ```ts
1323
- 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.
1324
1878
 
1325
- vi.spyOn(api, 'getUser').mockResolvedValue(successResult({ id: '42', name: 'Ada' }))
1326
- vi.spyOn(api, 'getUser').mockResolvedValue(errorResult(404, { message: 'not found' }))
1327
- ```
1879
+ #### Abort classification
1328
1880
 
1329
- 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'`.
1330
1882
 
1331
- - **`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.
1332
- - **`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`.
1333
- - **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.
1334
- - **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.
1335
- - **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'`.
1336
1884
 
1337
- ## Philosophy
1885
+ #### baseUrl query merging
1338
1886
 
1339
- ### 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:
1340
1888
 
1341
- 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'
1342
1891
 
1343
- ### Zero dependencies
1892
+ type Hit = { id: string }
1344
1893
 
1345
- 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 } })
1346
1896
 
1347
- ### Middleware over interceptors
1897
+ await api.search({ q: 'hello' })
1898
+ // GET https://api.example.com/v1/search?api-version=2&q=hello
1899
+ ```
1348
1900
 
1349
- 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.
1350
1902
 
1351
- ### 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.
1352
1904
 
1353
- 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
1354
1906
 
1355
- ### 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.
1356
1908
 
1357
- 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:
1358
1910
 
1359
- ### 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
+ ```
1360
1916
 
1361
- 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.
1362
1918
 
1363
- ## API Reference
1919
+ A `#` inside a param value is data. It is escaped to `%23` and sent:
1364
1920
 
1365
- ### 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
+ ```
1366
1925
 
1367
- | Export | Kind | Description |
1368
- | --------------- | -------- | ------------------------------------------------------------------ |
1369
- | `createApi` | function | Creates a typed API client from a config of Request definitions |
1370
- | `Request` | class | Typed endpoint definition -- one instance per endpoint |
1371
- | `defineRequest` | function | Typed factory — infers path params from the `path` literal, and enforces `responseType: 'none'`. |
1372
- | `paginate` | function | Walks a paginated endpoint, yielding one `Result` per page. |
1373
- | `PaginateOptions` | type | `next`, `maxPages`, and any `CallOptions`. |
1374
- | `StandardSchemaV1` | type | The Standard Schema contract — for typing a helper that takes a validator. |
1375
- | `InferOutput` | type | The type a schema produces on success. |
1376
- | `StandardIssue` | type | One validation failure -- the shape of each entry in `error.body` when a schema refuses. |
1377
- | `ApiError` | class | Structured error with status, kind, body, headers, and request metadata |
1378
- | `ApiErrorKind` | type | `'http' \| 'network' \| 'abort' \| 'timeout' \| 'parse' \| 'middleware'` -- discriminates `ApiError.kind` |
1379
- | `RequestConfig` | type | Config object for the `Request` constructor |
1380
- | `ApiConfig` | type | Config object for `createApi` |
1381
- | `CallOptions` | type | Per-call overrides (`middleware`, `skipMiddleware`, `headers`, `signal`, `timeout`) |
1382
- | `Result` | type | Discriminated union of every API call's outcome: `SuccessResult<T> \| ErrorResult<T>` |
1383
- | `SuccessResult` | type | The success branch of `Result`: `{ data: T, error: null, response: Response, retry }` |
1384
- | `ErrorResult` | type | The error branch of `Result`: `{ data: null, error: ApiError, response: Response \| null, retry }` |
1385
- | `Middleware` | type | Middleware function signature: `(ctx, next) => Promise<Result>` |
1386
- | `MiddlewareContext` | type | Request context passed to middleware |
1387
- | `MiddlewareNext` | type | The `next` function passed to middleware |
1388
- | `createGraphQL` | function | Creates a typed GraphQL client from a config of Operation definitions |
1389
- | `Operation` | class | Typed operation definition -- one instance per GraphQL operation |
1390
- | `gql` | const | Tagged template literal for GraphQL documents (editor tooling support) |
1391
- | `OperationConfig` | type | Config object for the `Operation` constructor |
1392
- | `GraphQLBaseConfig` | type | Config object for `createGraphQL` |
1393
- | `GraphQLError` | type | Shape of a single GraphQL error from `{ errors: [...] }` |
1394
-
1395
- ### Built-in middleware (`liaise/middleware`)
1396
-
1397
- | Export | Kind | Description |
1398
- | ----------------- | -------- | ------------------------------------------------------------- |
1399
- | `retryMiddleware` | function | Factory that returns middleware retrying on 5xx by default, with a configurable backoff policy |
1400
- | `RetryOptions` | type | Options object accepted by `retryMiddleware` (`max`, `delay`, `baseDelay`, `maxDelay`, `jitter`, `respectRetryAfter`, `retryOn`, `onRetry`) |
1401
- | `RetryInfo` | type | Shape of the argument passed to `RetryOptions.onRetry` |
1402
- | `logMiddleware` | const | Middleware that logs request lifecycle to the console |
1403
- | `cacheMiddleware` | function | Factory that returns a per-request in-memory cache with `clear()` |
1404
- | `CacheMiddleware` | type | Return type of `cacheMiddleware()` -- a `Middleware` with an attached `clear()` |
1405
-
1406
- ### Testing (`liaise/testing`)
1407
-
1408
- | Export | Kind | Description |
1409
- | --------------- | -------- | ------------------------------------------------------------------ |
1410
- | `mockFetch` | function | Builds a route-matching `fetch` stub, with `install()`/`restore()`, call recording, and response sequencing |
1411
- | `jsonResponse` | function | Builds a `Response` with a JSON body and a `content-type` header, for use as a route value |
1412
- | `successResult` | function | Builds a well-formed success `Result<T>` directly, for stubbing at the `Result` level |
1413
- | `errorResult` | function | Builds a well-formed error `Result<T>` with a given HTTP status, for stubbing at the `Result` level |
1414
- | `RouteContext` | type | `{ params, request }` passed to a route handler function |
1415
- | `RouteHandler` | type | `(ctx: RouteContext) => Response \| Promise<Response>` -- a route value that computes its response |
1416
- | `RouteValue` | type | `Response \| RouteHandler \| Array<Response \| RouteHandler>` -- anything a route key can map to |
1417
- | `RecordedCall` | type | `{ method, url, headers, body }` -- shape of each entry in `mock.calls` |
1418
-
1419
- ## Contributing
1420
-
1421
- 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.
1422
-
1423
- ## License
1424
-
1425
- 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).