liaise 0.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +961 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +925 -0
- package/README.md +1392 -4
- package/dist/built-in-middleware.d.ts +232 -0
- package/dist/built-in-middleware.js +127 -0
- package/dist/create-api.d.ts +120 -0
- package/dist/create-api.js +370 -0
- package/dist/define-request.d.ts +251 -0
- package/dist/define-request.js +4 -0
- package/dist/graphql.d.ts +30 -0
- package/dist/graphql.js +272 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/middleware.d.ts +77 -0
- package/dist/middleware.js +12 -0
- package/dist/paginate.d.ts +71 -0
- package/dist/paginate.js +15 -0
- package/dist/request.d.ts +136 -0
- package/dist/request.js +12 -0
- package/dist/result.d.ts +178 -0
- package/dist/result.js +20 -0
- package/dist/testing.d.ts +51 -0
- package/dist/testing.js +135 -0
- package/dist/types.d.ts +751 -0
- package/dist/types.js +1 -0
- package/dist/utils/abort-kind.d.ts +58 -0
- package/dist/utils/abort-kind.js +28 -0
- package/dist/utils/any-signal.d.ts +18 -0
- package/dist/utils/any-signal.js +29 -0
- package/dist/utils/backstop.d.ts +49 -0
- package/dist/utils/backstop.js +80 -0
- package/dist/utils/budget.d.ts +53 -0
- package/dist/utils/budget.js +20 -0
- package/dist/utils/cache.d.ts +54 -0
- package/dist/utils/cache.js +37 -0
- package/dist/utils/dedupe.d.ts +90 -0
- package/dist/utils/dedupe.js +20 -0
- package/dist/utils/headers.d.ts +1 -0
- package/dist/utils/headers.js +19 -0
- package/dist/utils/path-params.d.ts +89 -0
- package/dist/utils/path-params.js +80 -0
- package/dist/utils/serialize.d.ts +48 -0
- package/dist/utils/serialize.js +21 -0
- package/dist/utils/share.d.ts +49 -0
- package/dist/utils/share.js +48 -0
- package/dist/utils/special-body.d.ts +18 -0
- package/dist/utils/special-body.js +7 -0
- package/dist/utils/stable-key.d.ts +55 -0
- package/dist/utils/stable-key.js +111 -0
- package/dist/utils/timeout.d.ts +27 -0
- package/dist/utils/timeout.js +7 -0
- package/dist/utils/validate.d.ts +32 -0
- package/dist/utils/validate.js +6 -0
- package/package.json +67 -5
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import type { ApiError, ApiErrorKind } from './result.js';
|
|
2
|
+
export type { ApiError, ApiErrorKind };
|
|
3
|
+
/**
|
|
4
|
+
* HTTP methods supported by the library.
|
|
5
|
+
*
|
|
6
|
+
* These are the standard REST methods. Each method has default serialization
|
|
7
|
+
* behavior in the library:
|
|
8
|
+
* - `GET` / `DELETE` → params are serialized as query string (+ path params)
|
|
9
|
+
* - `POST` / `PUT` / `PATCH` → params are serialized as request body
|
|
10
|
+
*
|
|
11
|
+
* This default can be overridden per-request with the `bodyAs` option in
|
|
12
|
+
* {@link RequestConfig}.
|
|
13
|
+
*/
|
|
14
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
15
|
+
/**
|
|
16
|
+
* Determines how the library parses the HTTP response body.
|
|
17
|
+
*
|
|
18
|
+
* - `'json'` (default) — calls `response.json()`, returns parsed object
|
|
19
|
+
* - `'text'` — calls `response.text()`, returns raw string
|
|
20
|
+
* - `'blob'` — calls `response.blob()`, returns a Blob (useful for file downloads)
|
|
21
|
+
* - `'arrayBuffer'` — calls `response.arrayBuffer()`, returns raw binary data
|
|
22
|
+
* - `'formData'` — calls `response.formData()`, returns FormData (rare)
|
|
23
|
+
* - `'none'` — the endpoint returns no body on **success**; `data` is
|
|
24
|
+
* `undefined`. Any body a *successful* (2xx) response sends anyway is
|
|
25
|
+
* discarded (and its stream cancelled). This is the accurate declaration
|
|
26
|
+
* for a 204 endpoint — declare `TResponse` as `undefined` when using it.
|
|
27
|
+
* This only describes the success shape: a **non-2xx** response is still
|
|
28
|
+
* read and parsed as JSON for `error.body`, since an error body is
|
|
29
|
+
* diagnostic (a message, a code) and worth reading even when the caller
|
|
30
|
+
* wants nothing back on success.
|
|
31
|
+
*
|
|
32
|
+
* **This is a convention, not a compile-time guarantee.** `new Request<P,
|
|
33
|
+
* User>({ responseType: 'none' })` is not a type error — a type-level
|
|
34
|
+
* guard for this was attempted (an overload pair pairing `TResponse` with
|
|
35
|
+
* a literal `responseType: 'none'`) and dropped: TypeScript's overload
|
|
36
|
+
* resolution falls through to the general `RequestConfig` overload for
|
|
37
|
+
* any call the specific one rejects, since that overload has to stay
|
|
38
|
+
* general to keep accepting a `RequestConfig`-typed variable, a spread of
|
|
39
|
+
* one, or a factory return (all of which previously — and must still —
|
|
40
|
+
* compile). A "reject the mismatch" overload with a permissive fallback
|
|
41
|
+
* sitting right behind it never actually rejects anything: TS just moves
|
|
42
|
+
* on to the fallback and reports no error, so the guard was pure
|
|
43
|
+
* ceremony with no effect. Mismatch it and you get a wrong `TResponse`
|
|
44
|
+
* silently, same as always — declare it as `undefined`.
|
|
45
|
+
*
|
|
46
|
+
* This describes `new Request`'s behaviour specifically. As of 4.1.0,
|
|
47
|
+
* `defineRequest` DOES enforce this pairing at compile time — see
|
|
48
|
+
* `EmptyBodyGuard` in `define-request.ts` — because a curried function,
|
|
49
|
+
* unlike a constructor, has no permissive overload for the guard to fall
|
|
50
|
+
* through to.
|
|
51
|
+
*
|
|
52
|
+
* Set this on the {@link RequestConfig} for a specific endpoint. If omitted,
|
|
53
|
+
* the library defaults to `'json'`.
|
|
54
|
+
*/
|
|
55
|
+
export type ResponseType = 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'none';
|
|
56
|
+
/**
|
|
57
|
+
* The [Standard Schema](https://standardschema.dev) v1 contract, inlined.
|
|
58
|
+
*
|
|
59
|
+
* Standard Schema is an interface, not a package: Zod, Valibot and ArkType all
|
|
60
|
+
* implement it. Declaring the shape here means a consumer brings their own
|
|
61
|
+
* validator and this library takes no dependency — the zero-dependency pillar
|
|
62
|
+
* holds with nothing added to `package.json`.
|
|
63
|
+
*
|
|
64
|
+
* Only the structural contract is copied, not the published namespace, which
|
|
65
|
+
* has grown a `StandardTypedV1` base since it was written. `validate` plus the
|
|
66
|
+
* phantom `types` is all this library reads, and copying more would mean
|
|
67
|
+
* tracking a moving document for no gain.
|
|
68
|
+
*/
|
|
69
|
+
export interface StandardSchemaV1<Output = unknown> {
|
|
70
|
+
readonly '~standard': {
|
|
71
|
+
readonly version: 1;
|
|
72
|
+
readonly vendor: string;
|
|
73
|
+
readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
|
|
74
|
+
readonly types?: {
|
|
75
|
+
readonly input: unknown;
|
|
76
|
+
readonly output: Output;
|
|
77
|
+
} | undefined;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** One validation failure. `path` is absent for a failure at the root. */
|
|
81
|
+
export interface StandardIssue {
|
|
82
|
+
readonly message: string;
|
|
83
|
+
readonly path?: ReadonlyArray<PropertyKey | {
|
|
84
|
+
readonly key: PropertyKey;
|
|
85
|
+
}> | undefined;
|
|
86
|
+
}
|
|
87
|
+
/** What a validator hands back: a value, or the reasons it refused. */
|
|
88
|
+
export type StandardResult<Output> = {
|
|
89
|
+
readonly value: Output;
|
|
90
|
+
readonly issues?: undefined;
|
|
91
|
+
} | {
|
|
92
|
+
readonly issues: ReadonlyArray<StandardIssue>;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* The type a schema produces on success — what `data` will be.
|
|
96
|
+
*
|
|
97
|
+
* Infers structurally against the whole `StandardSchemaV1<Output>` interface —
|
|
98
|
+
* conditional-type inference matches `O` wherever it appears in the shape, and
|
|
99
|
+
* here that is `validate`'s return type, `StandardResult<Output> |
|
|
100
|
+
* Promise<StandardResult<Output>>`. THAT is the operative inference site, not
|
|
101
|
+
* the optional `types` phantom: a schema whose `validate` is concretely typed
|
|
102
|
+
* infers correctly even with no `types` property at all, while a schema whose
|
|
103
|
+
* `validate` is widened (e.g. to `unknown`) infers `unknown` even when `types`
|
|
104
|
+
* is present and correctly typed. Do not "simplify" this to
|
|
105
|
+
* `S['~standard']['types']['output']` — that reads the phantom directly and
|
|
106
|
+
* would silently yield `never` or `unknown` for every validator that omits it.
|
|
107
|
+
*
|
|
108
|
+
* Note this is the OUTPUT type: a schema that transforms (a coerced date, a
|
|
109
|
+
* defaulted field) describes what the caller receives, not what the server
|
|
110
|
+
* sent. See `schema` on `RequestConfig`.
|
|
111
|
+
*/
|
|
112
|
+
export type InferOutput<S> = S extends StandardSchemaV1<infer O> ? O : never;
|
|
113
|
+
/**
|
|
114
|
+
* Configuration object passed to the `Request` class constructor.
|
|
115
|
+
*
|
|
116
|
+
* Each API endpoint is defined as a `new Request<TParams, TResponse>(config)`.
|
|
117
|
+
* This interface describes the shape of that config.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* ```ts
|
|
121
|
+
* const getUser = new Request<{ id: string }, User>({
|
|
122
|
+
* method: 'GET',
|
|
123
|
+
* path: '/users/:id', // :id is substituted from params
|
|
124
|
+
* middleware: [cacheMiddleware], // runs only for this endpoint
|
|
125
|
+
* responseType: 'json', // default, could be omitted
|
|
126
|
+
* })
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export interface RequestConfig {
|
|
130
|
+
/** The HTTP method to use for this endpoint. */
|
|
131
|
+
method: HttpMethod;
|
|
132
|
+
/**
|
|
133
|
+
* URL path template, relative to the `baseUrl` set in `createApi`.
|
|
134
|
+
*
|
|
135
|
+
* Supports `:param` syntax for path parameters. When the request is made,
|
|
136
|
+
* matching keys from the params object are substituted into the path and
|
|
137
|
+
* excluded from the query string or body.
|
|
138
|
+
*
|
|
139
|
+
* @example '/users/:id' → params { id: '42' } → '/users/42'
|
|
140
|
+
* @example '/orgs/:org/repos' → params { org: 'acme' } → '/orgs/acme/repos'
|
|
141
|
+
*/
|
|
142
|
+
path: string;
|
|
143
|
+
/**
|
|
144
|
+
* Middleware that runs only for this specific endpoint.
|
|
145
|
+
*
|
|
146
|
+
* Execution order: global middleware → per-request middleware → per-call middleware.
|
|
147
|
+
* Each layer wraps the next in an "onion" pattern.
|
|
148
|
+
*/
|
|
149
|
+
middleware?: Middleware[];
|
|
150
|
+
/**
|
|
151
|
+
* Default headers sent with every call to this endpoint.
|
|
152
|
+
*
|
|
153
|
+
* Merge precedence (most specific wins):
|
|
154
|
+
* 1. Global headers (from `createApi` config) — lowest priority
|
|
155
|
+
* 2. Per-request headers (this field) — overrides global
|
|
156
|
+
* 3. Per-call headers (from `CallOptions`) — highest priority
|
|
157
|
+
*/
|
|
158
|
+
headers?: HeadersInit;
|
|
159
|
+
/**
|
|
160
|
+
* How to parse the response body. Defaults to `'json'`.
|
|
161
|
+
* @see {@link ResponseType} for available options.
|
|
162
|
+
*/
|
|
163
|
+
responseType?: ResponseType;
|
|
164
|
+
/**
|
|
165
|
+
* Optional runtime validation of the successful response body.
|
|
166
|
+
*
|
|
167
|
+
* Bring any [Standard Schema](https://standardschema.dev) validator — Zod,
|
|
168
|
+
* Valibot, ArkType. This library takes no dependency on one.
|
|
169
|
+
*
|
|
170
|
+
* **`data` becomes the schema's output, not the raw body.** A schema that
|
|
171
|
+
* coerces or defaults changes what the caller receives; that is the point of
|
|
172
|
+
* validating through a schema rather than merely checking one. A failure is a
|
|
173
|
+
* `kind: 'parse'` error carrying the issues in `error.body`.
|
|
174
|
+
*
|
|
175
|
+
* Only the **success** body is validated. A non-2xx body is diagnostic and
|
|
176
|
+
* frequently a different shape, so it is left alone.
|
|
177
|
+
*
|
|
178
|
+
* Declaring `responseType: 'none'` alongside a schema is a contradiction —
|
|
179
|
+
* there is no body to validate, and every call will fail validation. It is
|
|
180
|
+
* not rejected at compile time because the runtime failure is immediate and
|
|
181
|
+
* loud; see the spec's non-goals.
|
|
182
|
+
*
|
|
183
|
+
* On this class path, `schema` and `TResponse` are also not tied together at
|
|
184
|
+
* compile time: `new Request<P, User>({ ..., schema: numberSchema })`
|
|
185
|
+
* compiles and hands back a `number` typed as `User`, the same class of
|
|
186
|
+
* mismatch as the `responseType: 'none'` case above. `defineRequest` is the
|
|
187
|
+
* path that checks this — see `SchemaConflictGuard` in `define-request.ts`.
|
|
188
|
+
*/
|
|
189
|
+
schema?: StandardSchemaV1<unknown>;
|
|
190
|
+
/**
|
|
191
|
+
* When `true`, enables auto-cancellation of duplicate in-flight requests.
|
|
192
|
+
*
|
|
193
|
+
* If a new call to this endpoint starts while a previous one is still
|
|
194
|
+
* pending, the previous request is automatically aborted. Useful for
|
|
195
|
+
* search-as-you-type or rapidly changing date filters.
|
|
196
|
+
*
|
|
197
|
+
* Identity is per `Request` instance — each Request object tracks at most
|
|
198
|
+
* one in-flight call.
|
|
199
|
+
*
|
|
200
|
+
* @default false (dedupe is opt-in)
|
|
201
|
+
*/
|
|
202
|
+
dedupe?: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Join identical concurrent calls onto a single in-flight request.
|
|
205
|
+
*
|
|
206
|
+
* Sibling of {@link RequestConfig.dedupe}, not a replacement: dedupe
|
|
207
|
+
* **cancels** the older request, share **joins** the existing one. Setting
|
|
208
|
+
* both throws at `createApi` time.
|
|
209
|
+
*
|
|
210
|
+
* Identity is the request name plus a stable serialisation of the params.
|
|
211
|
+
* A call carrying per-call `headers` or `middleware` is never shared — those
|
|
212
|
+
* change *what* is requested, and handing one caller another's response
|
|
213
|
+
* would be a security-shaped bug. (Emptiness is what counts: `headers: {}`
|
|
214
|
+
* and `middleware: []` still share.) A per-call `signal` or `timeout` does
|
|
215
|
+
* not prevent sharing: those bound *who is still waiting*, not what is asked
|
|
216
|
+
* for, and a sharer that gives up receives its own error `Result`
|
|
217
|
+
* (`kind: 'timeout'` or `'abort'`), reported to `onError` exactly as the
|
|
218
|
+
* same non-shared call would — which means a `'timeout'` give-up reports
|
|
219
|
+
* and an `'abort'` give-up does not (see {@link ApiConfig.onError}). A per-*request*
|
|
220
|
+
* {@link RequestConfig.timeout}, by contrast, bounds the shared request
|
|
221
|
+
* itself for everyone.
|
|
222
|
+
* A call whose params are a special body type — `FormData`, `Blob`,
|
|
223
|
+
* `ArrayBuffer`, `URLSearchParams`, or a raw string — is also never shared:
|
|
224
|
+
* the stable serialisation used for identity can't distinguish two
|
|
225
|
+
* different payloads of these types from each other, so sharing them could
|
|
226
|
+
* hand one caller the response to a *different* payload than the one it
|
|
227
|
+
* sent.
|
|
228
|
+
*
|
|
229
|
+
* `result.retry()` on a shared result re-runs the pipeline using the
|
|
230
|
+
* **acquiring caller's** own per-call options (headers, signal, timeout) —
|
|
231
|
+
* whichever call first started the shared request — not the options of
|
|
232
|
+
* whichever caller happens to invoke `retry()`. This falls out of every
|
|
233
|
+
* non-aborting sharer receiving the literal same `Result` object; it is
|
|
234
|
+
* unavoidable given that design, but worth knowing before relying on it.
|
|
235
|
+
*
|
|
236
|
+
* **Known limitation:** middleware (global or per-request) that replaces
|
|
237
|
+
* `ctx.request.signal` — see {@link MiddlewareContext.request.signal} — is
|
|
238
|
+
* re-merged with the dedupe signal under `dedupe: true`, but is **not**
|
|
239
|
+
* currently re-merged with the share refcount controller. Combining
|
|
240
|
+
* `share` with signal-replacing middleware means that middleware's signal,
|
|
241
|
+
* not the refcount, ends up controlling the shared request: one sharer's
|
|
242
|
+
* middleware-installed signal could cancel the request for every other
|
|
243
|
+
* sharer.
|
|
244
|
+
*
|
|
245
|
+
* @default false
|
|
246
|
+
*/
|
|
247
|
+
share?: boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Override the default body serialization strategy.
|
|
250
|
+
*
|
|
251
|
+
* By default, GET/DELETE serialize params as query strings, and
|
|
252
|
+
* POST/PUT/PATCH serialize params as request body (JSON). Use this to
|
|
253
|
+
* override that behavior:
|
|
254
|
+
*
|
|
255
|
+
* - `'query'` — force params to query string (e.g., POST with query params)
|
|
256
|
+
* - `'body'` — force params to request body (e.g., DELETE with JSON body)
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```ts
|
|
260
|
+
* // DELETE endpoint that expects a JSON body with IDs to delete
|
|
261
|
+
* const bulkDelete = new Request<{ ids: string[] }, { deleted: number }>({
|
|
262
|
+
* method: 'DELETE',
|
|
263
|
+
* path: '/items',
|
|
264
|
+
* bodyAs: 'body',
|
|
265
|
+
* })
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
bodyAs?: 'query' | 'body';
|
|
269
|
+
/**
|
|
270
|
+
* Abort this request if it has not completed within this many milliseconds.
|
|
271
|
+
*
|
|
272
|
+
* **This is a whole-operation deadline, not a per-attempt budget.** It covers
|
|
273
|
+
* the entire middleware chain including every retry and every backoff delay,
|
|
274
|
+
* so `timeout: 5000` with `retryMiddleware(3)` still means "an answer within
|
|
275
|
+
* 5 seconds" — not five seconds per attempt. This deliberately differs from
|
|
276
|
+
* axios, XHR and `got`, which apply timeouts per attempt.
|
|
277
|
+
*
|
|
278
|
+
* For a per-attempt budget, use a signal-replacing middleware placed inside
|
|
279
|
+
* the retry middleware instead:
|
|
280
|
+
*
|
|
281
|
+
* ```ts
|
|
282
|
+
* const perAttempt = (ms: number): Middleware => async (ctx, next) => {
|
|
283
|
+
* ctx.request.signal = AbortSignal.timeout(ms)
|
|
284
|
+
* return next()
|
|
285
|
+
* }
|
|
286
|
+
* middleware: [retryMiddleware(3), perAttempt(5000)]
|
|
287
|
+
* ```
|
|
288
|
+
*
|
|
289
|
+
* A timeout produces an error with `kind: 'timeout'` and `status: 0`.
|
|
290
|
+
* `result.retry()` starts a fresh budget. Non-positive or omitted means no
|
|
291
|
+
* timeout.
|
|
292
|
+
*
|
|
293
|
+
* This also bounds a middleware that never looks at the signal: one stuck
|
|
294
|
+
* awaiting work of its own (a stalled token refresh) cannot hold the call
|
|
295
|
+
* past the deadline. Once it passes, the chain gets one macrotask to answer
|
|
296
|
+
* by itself; if it has not, the call settles with the timeout Result. The
|
|
297
|
+
* stalled middleware keeps running — a promise cannot be cancelled — but
|
|
298
|
+
* its eventual return value or throw is discarded, and a `next()` it calls
|
|
299
|
+
* afterwards sends nothing. Pass `ctx.request.signal` into such work to
|
|
300
|
+
* actually stop it.
|
|
301
|
+
*
|
|
302
|
+
* Under {@link RequestConfig.share} this deadline belongs to the *operation*:
|
|
303
|
+
* it bounds the single shared request, measured from when that request
|
|
304
|
+
* started rather than from when each caller joined, so every sharer is
|
|
305
|
+
* bounded by it and no individual caller can extend or disable it. A caller's
|
|
306
|
+
* own {@link CallOptions.timeout} bounds only that caller.
|
|
307
|
+
*/
|
|
308
|
+
timeout?: number;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* A successful API call. `data` is populated, `error` is `null`, and the raw
|
|
312
|
+
* `Response` is always present because the server responded.
|
|
313
|
+
*/
|
|
314
|
+
export interface SuccessResult<TResponse> {
|
|
315
|
+
/**
|
|
316
|
+
* The parsed response data, narrowed by `error === null`. Since 4.0.0 a 2xx
|
|
317
|
+
* that carries no body under `responseType: 'json'` is a `'parse'` error
|
|
318
|
+
* rather than a success, so this is not `null` for that case — declare
|
|
319
|
+
* `responseType: 'none'` on an endpoint that answers with no body. See the
|
|
320
|
+
* `responseType` reference.
|
|
321
|
+
*/
|
|
322
|
+
data: TResponse;
|
|
323
|
+
/** Always `null` — this is the discriminant that narrows `data`. */
|
|
324
|
+
error: null;
|
|
325
|
+
/**
|
|
326
|
+
* The raw fetch `Response`. Always present on success.
|
|
327
|
+
*
|
|
328
|
+
* For results the library produces, **its body has already been consumed**
|
|
329
|
+
* to produce `data`, so `response.json()` throws "Body has already been
|
|
330
|
+
* read". Use `data`; `response` is for status, headers and redirect
|
|
331
|
+
* metadata. (A `Response` you construct yourself and hand to
|
|
332
|
+
* `successResult()` from `testing.ts` is not affected — its body is still
|
|
333
|
+
* readable.)
|
|
334
|
+
*/
|
|
335
|
+
response: Response;
|
|
336
|
+
/** Re-execute this request through the full middleware chain. */
|
|
337
|
+
retry: () => Promise<Result<TResponse>>;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* A failed API call. `error` is populated and `data` is `null`.
|
|
341
|
+
*
|
|
342
|
+
* `response` is present for HTTP and parse failures (the server responded)
|
|
343
|
+
* and `null` for network failures, aborts and timeouts. Check
|
|
344
|
+
* {@link ApiError.kind} to tell them apart.
|
|
345
|
+
*/
|
|
346
|
+
export interface ErrorResult<TResponse> {
|
|
347
|
+
/** Always `null` on this branch. */
|
|
348
|
+
data: null;
|
|
349
|
+
/** Structured error details. Never `null` on this branch. */
|
|
350
|
+
error: ApiError;
|
|
351
|
+
/** The raw `Response`, or `null` when no HTTP response exists. */
|
|
352
|
+
response: Response | null;
|
|
353
|
+
/** Re-execute this request through the full middleware chain. */
|
|
354
|
+
retry: () => Promise<Result<TResponse>>;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The result of every API call — a discriminated union on `error`.
|
|
358
|
+
*
|
|
359
|
+
* Checking `error` narrows `data`: after `if (error) return`, `data` is
|
|
360
|
+
* `TResponse`, not `TResponse | null`. That is the whole point of returning
|
|
361
|
+
* a result instead of throwing — the check *is* the narrowing.
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```ts
|
|
365
|
+
* const { data, error } = await api.getUser({ id: '42' })
|
|
366
|
+
* if (error) {
|
|
367
|
+
* if (error.kind === 'abort') return // we cancelled it ourselves
|
|
368
|
+
* console.error(error.status, error.body)
|
|
369
|
+
* return
|
|
370
|
+
* }
|
|
371
|
+
* console.log(data.name) // data is User
|
|
372
|
+
* ```
|
|
373
|
+
*/
|
|
374
|
+
export type Result<TResponse> = SuccessResult<TResponse> | ErrorResult<TResponse>;
|
|
375
|
+
/**
|
|
376
|
+
* Per-call options available as the optional second argument to any API method.
|
|
377
|
+
*
|
|
378
|
+
* These allow overriding middleware, headers, and cancellation on a
|
|
379
|
+
* call-by-call basis without changing the endpoint definition.
|
|
380
|
+
*
|
|
381
|
+
* @example
|
|
382
|
+
* ```ts
|
|
383
|
+
* // Add extra middleware and an abort signal for this one call
|
|
384
|
+
* const { data } = await api.getItems({ page: 1 }, {
|
|
385
|
+
* middleware: [customLogMiddleware],
|
|
386
|
+
* skipMiddleware: [cacheMiddleware],
|
|
387
|
+
* headers: { 'X-Request-Id': crypto.randomUUID() },
|
|
388
|
+
* signal: abortController.signal,
|
|
389
|
+
* })
|
|
390
|
+
* ```
|
|
391
|
+
*/
|
|
392
|
+
export interface CallOptions {
|
|
393
|
+
/**
|
|
394
|
+
* Additional middleware to append for this call only.
|
|
395
|
+
* Runs after global and per-request middleware.
|
|
396
|
+
*/
|
|
397
|
+
middleware?: Middleware[];
|
|
398
|
+
/**
|
|
399
|
+
* Middleware to skip for this call, compared by reference identity.
|
|
400
|
+
*
|
|
401
|
+
* ⚠️ Factory-style middleware (like `retryMiddleware(3)`) must be stored
|
|
402
|
+
* in a variable first — passing a new factory call here will create a
|
|
403
|
+
* new reference that won't match the original.
|
|
404
|
+
*/
|
|
405
|
+
skipMiddleware?: Middleware[];
|
|
406
|
+
/** Extra headers for this call. Highest merge priority (overrides all). */
|
|
407
|
+
headers?: HeadersInit;
|
|
408
|
+
/**
|
|
409
|
+
* An `AbortSignal` to cancel this request. When the signal fires,
|
|
410
|
+
* the fetch is aborted and the result contains an error with `status: 0`
|
|
411
|
+
* and `kind: 'abort'`.
|
|
412
|
+
*
|
|
413
|
+
* It settles the call even while a middleware is still awaiting work that
|
|
414
|
+
* ignores the signal — the same backstop as `RequestConfig.timeout`, which
|
|
415
|
+
* describes it.
|
|
416
|
+
*/
|
|
417
|
+
signal?: AbortSignal;
|
|
418
|
+
/**
|
|
419
|
+
* Overrides `RequestConfig.timeout` for this call only. Same whole-operation
|
|
420
|
+
* deadline semantics — see there for details. Non-positive means no timeout.
|
|
421
|
+
*
|
|
422
|
+
* Under `share: true` this bounds only *this* caller's wait. The operation's
|
|
423
|
+
* own `RequestConfig.timeout` still bounds the shared request for everyone,
|
|
424
|
+
* so a per-call `timeout: 0` cannot lift it and a longer per-call timeout
|
|
425
|
+
* cannot outlast it.
|
|
426
|
+
*
|
|
427
|
+
* A fractional or out-of-range value is normalised rather than rejected:
|
|
428
|
+
* rounded down to whole milliseconds, clamped to the platform timer ceiling,
|
|
429
|
+
* and treated as "no timeout" if it is `NaN` or non-positive.
|
|
430
|
+
*/
|
|
431
|
+
timeout?: number;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Context object passed to each middleware function in the chain.
|
|
435
|
+
*
|
|
436
|
+
* Contains all the information about the current request: the HTTP method,
|
|
437
|
+
* the fully-resolved URL, the original path template, the caller's params,
|
|
438
|
+
* the merged headers, and the serialized body.
|
|
439
|
+
*
|
|
440
|
+
* Middleware can read and modify `ctx.request.headers` and `ctx.request.body`
|
|
441
|
+
* before calling `next()` — changes will propagate to the actual fetch call.
|
|
442
|
+
*
|
|
443
|
+
* **Typing note:** The spec defines MiddlewareContext with generic TParams and
|
|
444
|
+
* TResponse, but middleware is intentionally loosely typed. Authors work with
|
|
445
|
+
* `unknown` and cast internally if they need specific types. This avoids
|
|
446
|
+
* complex generic inference issues and keeps middleware composable.
|
|
447
|
+
*/
|
|
448
|
+
export interface MiddlewareContext {
|
|
449
|
+
/** Mutable request details — middleware can modify headers and body. */
|
|
450
|
+
request: {
|
|
451
|
+
/** HTTP method (GET, POST, etc.) */
|
|
452
|
+
method: string;
|
|
453
|
+
/** Fully resolved URL with path params substituted and query string appended. */
|
|
454
|
+
url: string;
|
|
455
|
+
/** Original path template (e.g., '/users/:id') — useful for logging. */
|
|
456
|
+
path: string;
|
|
457
|
+
/** The original params object passed by the caller. */
|
|
458
|
+
params: unknown;
|
|
459
|
+
/** Merged headers — middleware can add/remove headers here. */
|
|
460
|
+
headers: Headers;
|
|
461
|
+
/** Serialized request body, or null for GET/DELETE requests. */
|
|
462
|
+
body: unknown | null;
|
|
463
|
+
/**
|
|
464
|
+
* The AbortSignal that will be handed to `fetch`.
|
|
465
|
+
*
|
|
466
|
+
* Middleware may read this, or replace it to impose its own cancellation
|
|
467
|
+
* policy — a timeout, a deadline, or a cancel-on-condition rule. The core
|
|
468
|
+
* fetch reads this field at call time, so a replacement made by any
|
|
469
|
+
* middleware takes effect. Under `dedupe: true` the replacement is merged
|
|
470
|
+
* into the dedupe signal rather than discarded: the fetch is then
|
|
471
|
+
* cancelled by whichever fires first, the middleware's signal or a newer
|
|
472
|
+
* call superseding this one.
|
|
473
|
+
*
|
|
474
|
+
* While middleware runs — before `next()` reaches the core fetch — this
|
|
475
|
+
* holds the caller's `CallOptions.signal` merged with the timeout signal
|
|
476
|
+
* (via `anySignal`) when a `timeout` is configured on the request,
|
|
477
|
+
* operation, or call. It is `undefined` only when neither is present —
|
|
478
|
+
* no `CallOptions.signal` and no effective `timeout`. This is true
|
|
479
|
+
* whether or not dedupe is enabled: the dedupe signal is installed here
|
|
480
|
+
* by the core fetch, so middleware only observes it after `next()`
|
|
481
|
+
* returns.
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* ```ts
|
|
485
|
+
* const timeout = (ms: number): Middleware => async (ctx, next) => {
|
|
486
|
+
* ctx.request.signal = AbortSignal.timeout(ms)
|
|
487
|
+
* return next()
|
|
488
|
+
* }
|
|
489
|
+
* ```
|
|
490
|
+
*/
|
|
491
|
+
signal?: AbortSignal;
|
|
492
|
+
};
|
|
493
|
+
/** The key name of the request in the api object (e.g., 'getUser'). */
|
|
494
|
+
requestName: string;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The "next" function passed to middleware. Calling it invokes the next
|
|
498
|
+
* middleware in the chain, or the actual fetch if this is the innermost layer.
|
|
499
|
+
*
|
|
500
|
+
* @typeParam TResponse - The expected response type (always `unknown` in practice).
|
|
501
|
+
*/
|
|
502
|
+
export type MiddlewareNext<TResponse> = () => Promise<Result<TResponse>>;
|
|
503
|
+
/**
|
|
504
|
+
* Middleware function signature.
|
|
505
|
+
*
|
|
506
|
+
* Middleware follows the "onion" model (like Koa or Redux middleware):
|
|
507
|
+
* each middleware wraps the next, can modify the request going in and
|
|
508
|
+
* the result coming out.
|
|
509
|
+
*
|
|
510
|
+
* - Call `next()` to proceed to the next layer.
|
|
511
|
+
* - Return early (without calling `next()`) to short-circuit (e.g., cache hit).
|
|
512
|
+
* - Call `next()` multiple times for retry behavior.
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```ts
|
|
516
|
+
* // Auth middleware — injects a Bearer token into every request
|
|
517
|
+
* const authMiddleware: Middleware = async (ctx, next) => {
|
|
518
|
+
* ctx.request.headers.set('Authorization', `Bearer ${getToken()}`)
|
|
519
|
+
* return next()
|
|
520
|
+
* }
|
|
521
|
+
* ```
|
|
522
|
+
*
|
|
523
|
+
* @example
|
|
524
|
+
* ```ts
|
|
525
|
+
* // Short-circuit middleware — returns cached data without hitting the server
|
|
526
|
+
* const cacheMiddleware: Middleware = async (ctx, next) => {
|
|
527
|
+
* const cached = cache.get(ctx.request.url)
|
|
528
|
+
* if (cached) return cached
|
|
529
|
+
* return next()
|
|
530
|
+
* }
|
|
531
|
+
* ```
|
|
532
|
+
*/
|
|
533
|
+
export type Middleware = (context: MiddlewareContext, next: MiddlewareNext<unknown>) => Promise<Result<unknown>>;
|
|
534
|
+
/**
|
|
535
|
+
* Configuration for the `createApi` constructor.
|
|
536
|
+
*
|
|
537
|
+
* This is the top-level config that wires together all request definitions,
|
|
538
|
+
* global middleware, default headers, and error handling into a typed API client.
|
|
539
|
+
*
|
|
540
|
+
* @typeParam TRequests - The record of request definitions (inferred from usage).
|
|
541
|
+
*
|
|
542
|
+
* @example
|
|
543
|
+
* ```ts
|
|
544
|
+
* const api = createApi({
|
|
545
|
+
* baseUrl: '/api',
|
|
546
|
+
* requests: { getUser, listUsers, createUser },
|
|
547
|
+
* middleware: [authMiddleware, logMiddleware],
|
|
548
|
+
* headers: { 'X-App-Version': '2.0.0' },
|
|
549
|
+
* onError: (error) => Sentry.captureException(error),
|
|
550
|
+
* })
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
export interface ApiConfig<TRequests extends Record<string, unknown>> {
|
|
554
|
+
/**
|
|
555
|
+
* Base URL prepended to every request path.
|
|
556
|
+
*
|
|
557
|
+
* @example '/api' → paths become '/api/users', '/api/items', etc.
|
|
558
|
+
* @example 'https://api.example.com' → for absolute URLs
|
|
559
|
+
* @example '' → same-origin requests (path used as-is)
|
|
560
|
+
*/
|
|
561
|
+
baseUrl: string;
|
|
562
|
+
/**
|
|
563
|
+
* Record of Request instances. Each key becomes a method on the API object.
|
|
564
|
+
*
|
|
565
|
+
* @example { getUser, listUsers, createUser } → api.getUser(), api.listUsers(), etc.
|
|
566
|
+
*/
|
|
567
|
+
requests: TRequests;
|
|
568
|
+
/**
|
|
569
|
+
* Global middleware applied to every request.
|
|
570
|
+
* Runs first in the middleware chain (before per-request and per-call middleware).
|
|
571
|
+
*/
|
|
572
|
+
middleware?: Middleware[];
|
|
573
|
+
/**
|
|
574
|
+
* Default headers sent with every request.
|
|
575
|
+
* Lowest merge priority — overridden by per-request and per-call headers.
|
|
576
|
+
*/
|
|
577
|
+
headers?: HeadersInit;
|
|
578
|
+
/**
|
|
579
|
+
* Global error callback. Fires after the full middleware chain completes,
|
|
580
|
+
* just before the result is returned to the caller.
|
|
581
|
+
*
|
|
582
|
+
* Only fires when the **final** result has an error. If a retry middleware
|
|
583
|
+
* recovers a 5xx to a 200, this does NOT fire.
|
|
584
|
+
*
|
|
585
|
+
* Fires for HTTP errors (4xx, 5xx), network errors (status 0), timeouts
|
|
586
|
+
* (`kind: 'timeout'`), parse failures (`kind: 'parse'`), and middleware
|
|
587
|
+
* failures (`kind: 'middleware'`). Does NOT fire for `kind: 'abort'` — a
|
|
588
|
+
* caller's own `AbortSignal` firing, or a request superseded by dedupe, is
|
|
589
|
+
* a cancellation the library caused deliberately, not a failure worth
|
|
590
|
+
* reporting to an error tracker. The caller still gets the abort back in
|
|
591
|
+
* the `Result` either way; only the report to this callback is suppressed.
|
|
592
|
+
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```ts
|
|
595
|
+
* onError: (error) => {
|
|
596
|
+
* if (error.status === 401) redirectToLogin()
|
|
597
|
+
* Sentry.captureException(error)
|
|
598
|
+
* }
|
|
599
|
+
* ```
|
|
600
|
+
*/
|
|
601
|
+
onError?: (error: ApiError) => void;
|
|
602
|
+
}
|
|
603
|
+
/** Information handed to {@link RetryOptions.onRetry} before each retry. */
|
|
604
|
+
export interface RetryInfo {
|
|
605
|
+
/** 1-based retry number — the first retry is 1. */
|
|
606
|
+
attempt: number;
|
|
607
|
+
/** The configured maximum number of retries. */
|
|
608
|
+
max: number;
|
|
609
|
+
/** The delay about to elapse, in ms, after jitter and `Retry-After`. */
|
|
610
|
+
delay: number;
|
|
611
|
+
/** The result that triggered this retry. */
|
|
612
|
+
result: Result<unknown>;
|
|
613
|
+
}
|
|
614
|
+
/** Options for {@link retryMiddleware}. */
|
|
615
|
+
export interface RetryOptions {
|
|
616
|
+
/** Additional attempts after the first. Default 3. */
|
|
617
|
+
max?: number;
|
|
618
|
+
/** Delay curve. Default `'exponential'`. */
|
|
619
|
+
delay?: 'exponential' | 'linear' | ((attempt: number) => number);
|
|
620
|
+
/** First delay in ms. Default 250. */
|
|
621
|
+
baseDelay?: number;
|
|
622
|
+
/** Per-delay cap in ms. Default 30000. */
|
|
623
|
+
maxDelay?: number;
|
|
624
|
+
/** Full jitter — uniform random in `[0, computed]`. Default true. */
|
|
625
|
+
jitter?: boolean;
|
|
626
|
+
/** Honour a `Retry-After` response header when present. Default true. */
|
|
627
|
+
respectRetryAfter?: boolean;
|
|
628
|
+
/**
|
|
629
|
+
* Whether to retry. Default `r => (r.error?.status ?? 0) >= 500`.
|
|
630
|
+
*
|
|
631
|
+
* 429 and network errors are deliberately not retried by default; opt in
|
|
632
|
+
* explicitly rather than having behaviour change under you on upgrade.
|
|
633
|
+
*/
|
|
634
|
+
retryOn?: (result: Result<unknown>, attempt: number) => boolean;
|
|
635
|
+
/**
|
|
636
|
+
* Observational hook fired before each retry's delay elapses. Its return
|
|
637
|
+
* value is ignored and a throw cannot fail the request.
|
|
638
|
+
*
|
|
639
|
+
* This exists because the call site observes nothing during retries: the
|
|
640
|
+
* promise stays pending through every attempt and resolves exactly once.
|
|
641
|
+
*/
|
|
642
|
+
onRetry?: (info: RetryInfo) => void;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Configuration object for the `Operation` class constructor.
|
|
646
|
+
*
|
|
647
|
+
* The `operation` field holds the GraphQL document string. It is sent to the
|
|
648
|
+
* server under the key `"query"` (per the GraphQL over HTTP spec), but is
|
|
649
|
+
* named `operation` here to make clear it can be a query or mutation.
|
|
650
|
+
*/
|
|
651
|
+
export interface OperationConfig {
|
|
652
|
+
/** The GraphQL document string. Sent as `{ query: ... }` on the wire. */
|
|
653
|
+
operation: string;
|
|
654
|
+
/**
|
|
655
|
+
* Middleware that runs only for this specific operation.
|
|
656
|
+
*
|
|
657
|
+
* Execution order: global middleware → per-operation middleware → per-call middleware.
|
|
658
|
+
* Each layer wraps the next in an "onion" pattern.
|
|
659
|
+
*/
|
|
660
|
+
middleware?: Middleware[];
|
|
661
|
+
/**
|
|
662
|
+
* Default headers for this operation.
|
|
663
|
+
*
|
|
664
|
+
* Merge precedence (most specific wins):
|
|
665
|
+
* 1. Global headers (from `createGraphQL` config) — lowest priority
|
|
666
|
+
* 2. Per-operation headers (this field)
|
|
667
|
+
* 3. Per-call headers (from `CallOptions`) — highest priority
|
|
668
|
+
*/
|
|
669
|
+
headers?: HeadersInit;
|
|
670
|
+
/**
|
|
671
|
+
* When `true`, enables auto-cancellation of duplicate in-flight requests.
|
|
672
|
+
*
|
|
673
|
+
* If a new call to this operation starts while a previous one is still
|
|
674
|
+
* pending, the previous request is automatically aborted.
|
|
675
|
+
*
|
|
676
|
+
* @default false
|
|
677
|
+
*/
|
|
678
|
+
dedupe?: boolean;
|
|
679
|
+
/**
|
|
680
|
+
* Optional runtime validation of the GraphQL response's `data`.
|
|
681
|
+
*
|
|
682
|
+
* Same contract as `RequestConfig.schema`: any Standard Schema validator, no
|
|
683
|
+
* dependency taken, `data` becomes the schema's output, and a failure is a
|
|
684
|
+
* `kind: 'parse'` error with the issues in `error.body`.
|
|
685
|
+
*
|
|
686
|
+
* Unlike the REST side, a schema here does **not** supply the response type —
|
|
687
|
+
* `Operation`'s `TData` stays explicit, because only the REST pipeline has a
|
|
688
|
+
* factory that can infer it. See the spec's non-goals.
|
|
689
|
+
*/
|
|
690
|
+
schema?: StandardSchemaV1<unknown>;
|
|
691
|
+
/**
|
|
692
|
+
* Abort this operation if it has not completed within this many
|
|
693
|
+
* milliseconds.
|
|
694
|
+
*
|
|
695
|
+
* **This is a whole-operation deadline, not a per-attempt budget** — see
|
|
696
|
+
* {@link RequestConfig.timeout} for the full rationale, which applies
|
|
697
|
+
* identically here — including that it bounds a middleware which never
|
|
698
|
+
* looks at the signal. A timeout produces an error with `kind: 'timeout'` and
|
|
699
|
+
* `status: 0`. `result.retry()` starts a fresh budget. Non-positive or
|
|
700
|
+
* omitted means no timeout.
|
|
701
|
+
*/
|
|
702
|
+
timeout?: number;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* A single GraphQL error as returned by the server in `{ errors: [...] }`.
|
|
706
|
+
* Mirrors the GraphQL spec error shape.
|
|
707
|
+
*/
|
|
708
|
+
export interface GraphQLError {
|
|
709
|
+
/** Human-readable description of the error. */
|
|
710
|
+
message: string;
|
|
711
|
+
/** Character positions in the GraphQL document where the error originated. */
|
|
712
|
+
locations?: Array<{
|
|
713
|
+
line: number;
|
|
714
|
+
column: number;
|
|
715
|
+
}>;
|
|
716
|
+
/** Path to the response field that produced the error, for partial responses. */
|
|
717
|
+
path?: Array<string | number>;
|
|
718
|
+
/** Server-defined additional metadata about the error. */
|
|
719
|
+
extensions?: Record<string, unknown>;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Base configuration shared by both `createGraphQL` overloads.
|
|
723
|
+
*/
|
|
724
|
+
export interface GraphQLBaseConfig {
|
|
725
|
+
/** The full URL of the GraphQL endpoint, e.g. `'https://api.example.com/graphql'`. */
|
|
726
|
+
endpoint: string;
|
|
727
|
+
/**
|
|
728
|
+
* Global middleware applied to every operation.
|
|
729
|
+
* Runs first in the middleware chain (before per-operation and per-call middleware).
|
|
730
|
+
*/
|
|
731
|
+
middleware?: Middleware[];
|
|
732
|
+
/**
|
|
733
|
+
* Default headers sent with every operation.
|
|
734
|
+
* Lowest merge priority — overridden by per-operation and per-call headers.
|
|
735
|
+
*/
|
|
736
|
+
headers?: HeadersInit;
|
|
737
|
+
/**
|
|
738
|
+
* Global error callback. Fires after the full middleware chain completes.
|
|
739
|
+
*
|
|
740
|
+
* Fires for GraphQL errors (any 2xx with `{ errors }`), HTTP errors (4xx/5xx),
|
|
741
|
+
* network errors (status 0), timeouts (`kind: 'timeout'`), parse failures
|
|
742
|
+
* (`kind: 'parse'`), and middleware failures (`kind: 'middleware'`). Does
|
|
743
|
+
* NOT fire when the result is successful, and does NOT fire for
|
|
744
|
+
* `kind: 'abort'` — a caller's own `AbortSignal` firing, or a request
|
|
745
|
+
* superseded by dedupe, is a cancellation the library caused deliberately,
|
|
746
|
+
* not a failure worth reporting to an error tracker. The caller still gets
|
|
747
|
+
* the abort back in the `Result` either way; only the report to this
|
|
748
|
+
* callback is suppressed.
|
|
749
|
+
*/
|
|
750
|
+
onError?: (error: ApiError) => void;
|
|
751
|
+
}
|