liaise 0.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +961 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +925 -0
  4. package/README.md +1392 -4
  5. package/dist/built-in-middleware.d.ts +232 -0
  6. package/dist/built-in-middleware.js +127 -0
  7. package/dist/create-api.d.ts +120 -0
  8. package/dist/create-api.js +370 -0
  9. package/dist/define-request.d.ts +251 -0
  10. package/dist/define-request.js +4 -0
  11. package/dist/graphql.d.ts +30 -0
  12. package/dist/graphql.js +272 -0
  13. package/dist/index.d.ts +16 -0
  14. package/dist/index.js +6 -0
  15. package/dist/middleware.d.ts +77 -0
  16. package/dist/middleware.js +12 -0
  17. package/dist/paginate.d.ts +71 -0
  18. package/dist/paginate.js +15 -0
  19. package/dist/request.d.ts +136 -0
  20. package/dist/request.js +12 -0
  21. package/dist/result.d.ts +178 -0
  22. package/dist/result.js +20 -0
  23. package/dist/testing.d.ts +51 -0
  24. package/dist/testing.js +135 -0
  25. package/dist/types.d.ts +751 -0
  26. package/dist/types.js +1 -0
  27. package/dist/utils/abort-kind.d.ts +58 -0
  28. package/dist/utils/abort-kind.js +28 -0
  29. package/dist/utils/any-signal.d.ts +18 -0
  30. package/dist/utils/any-signal.js +29 -0
  31. package/dist/utils/backstop.d.ts +49 -0
  32. package/dist/utils/backstop.js +80 -0
  33. package/dist/utils/budget.d.ts +53 -0
  34. package/dist/utils/budget.js +20 -0
  35. package/dist/utils/cache.d.ts +54 -0
  36. package/dist/utils/cache.js +37 -0
  37. package/dist/utils/dedupe.d.ts +90 -0
  38. package/dist/utils/dedupe.js +20 -0
  39. package/dist/utils/headers.d.ts +1 -0
  40. package/dist/utils/headers.js +19 -0
  41. package/dist/utils/path-params.d.ts +89 -0
  42. package/dist/utils/path-params.js +80 -0
  43. package/dist/utils/serialize.d.ts +48 -0
  44. package/dist/utils/serialize.js +21 -0
  45. package/dist/utils/share.d.ts +49 -0
  46. package/dist/utils/share.js +48 -0
  47. package/dist/utils/special-body.d.ts +18 -0
  48. package/dist/utils/special-body.js +7 -0
  49. package/dist/utils/stable-key.d.ts +55 -0
  50. package/dist/utils/stable-key.js +111 -0
  51. package/dist/utils/timeout.d.ts +27 -0
  52. package/dist/utils/timeout.js +7 -0
  53. package/dist/utils/validate.d.ts +32 -0
  54. package/dist/utils/validate.js +6 -0
  55. package/package.json +67 -5
@@ -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
+ }