liaise 0.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +961 -0
  2. package/LICENSE +21 -0
  3. package/MIGRATION.md +925 -0
  4. package/README.md +1392 -4
  5. package/dist/built-in-middleware.d.ts +232 -0
  6. package/dist/built-in-middleware.js +127 -0
  7. package/dist/create-api.d.ts +120 -0
  8. package/dist/create-api.js +370 -0
  9. package/dist/define-request.d.ts +251 -0
  10. package/dist/define-request.js +4 -0
  11. package/dist/graphql.d.ts +30 -0
  12. package/dist/graphql.js +272 -0
  13. package/dist/index.d.ts +16 -0
  14. package/dist/index.js +6 -0
  15. package/dist/middleware.d.ts +77 -0
  16. package/dist/middleware.js +12 -0
  17. package/dist/paginate.d.ts +71 -0
  18. package/dist/paginate.js +15 -0
  19. package/dist/request.d.ts +136 -0
  20. package/dist/request.js +12 -0
  21. package/dist/result.d.ts +178 -0
  22. package/dist/result.js +20 -0
  23. package/dist/testing.d.ts +51 -0
  24. package/dist/testing.js +135 -0
  25. package/dist/types.d.ts +751 -0
  26. package/dist/types.js +1 -0
  27. package/dist/utils/abort-kind.d.ts +58 -0
  28. package/dist/utils/abort-kind.js +28 -0
  29. package/dist/utils/any-signal.d.ts +18 -0
  30. package/dist/utils/any-signal.js +29 -0
  31. package/dist/utils/backstop.d.ts +49 -0
  32. package/dist/utils/backstop.js +80 -0
  33. package/dist/utils/budget.d.ts +53 -0
  34. package/dist/utils/budget.js +20 -0
  35. package/dist/utils/cache.d.ts +54 -0
  36. package/dist/utils/cache.js +37 -0
  37. package/dist/utils/dedupe.d.ts +90 -0
  38. package/dist/utils/dedupe.js +20 -0
  39. package/dist/utils/headers.d.ts +1 -0
  40. package/dist/utils/headers.js +19 -0
  41. package/dist/utils/path-params.d.ts +89 -0
  42. package/dist/utils/path-params.js +80 -0
  43. package/dist/utils/serialize.d.ts +48 -0
  44. package/dist/utils/serialize.js +21 -0
  45. package/dist/utils/share.d.ts +49 -0
  46. package/dist/utils/share.js +48 -0
  47. package/dist/utils/special-body.d.ts +18 -0
  48. package/dist/utils/special-body.js +7 -0
  49. package/dist/utils/stable-key.d.ts +55 -0
  50. package/dist/utils/stable-key.js +111 -0
  51. package/dist/utils/timeout.d.ts +27 -0
  52. package/dist/utils/timeout.js +7 -0
  53. package/dist/utils/validate.d.ts +32 -0
  54. package/dist/utils/validate.js +6 -0
  55. package/package.json +67 -5
package/MIGRATION.md ADDED
@@ -0,0 +1,925 @@
1
+ # Migration Guide
2
+
3
+ Upgrade notes for `liaise` (published as `@iremlopsum/apify` up to 4.4.x). Only releases that need action appear
4
+ here — if a version isn't listed, upgrading to it requires no changes.
5
+
6
+ For the full record of what changed in each release, see [CHANGELOG.md](./CHANGELOG.md).
7
+
8
+ ---
9
+
10
+ ## Upgrading to 5.0.0
11
+
12
+ The package has a new name: **`@iremlopsum/apify` is now `liaise`**. Nothing
13
+ else about the API changes — every function, option, type and behaviour is the
14
+ same as 4.4.3.
15
+
16
+ **1. Swap the package.**
17
+
18
+ ```bash
19
+ npm uninstall @iremlopsum/apify
20
+ npm install liaise
21
+ ```
22
+
23
+ **2. Update the import paths** — a find-and-replace across your code:
24
+
25
+ | Before | After |
26
+ |---|---|
27
+ | `@iremlopsum/apify` | `liaise` |
28
+ | `@iremlopsum/apify/middleware` | `liaise/middleware` |
29
+ | `@iremlopsum/apify/testing` | `liaise/testing` |
30
+
31
+ Replacing `@iremlopsum/apify` with `liaise` everywhere covers all three.
32
+
33
+ **One behaviour change, only if you read the logs.** `logMiddleware` and
34
+ `cacheMiddleware({ debug: true })` print `[liaise]` and `[liaise cache]` where
35
+ they printed `[apify]` and `[apify cache]`. If a log filter, alert or test
36
+ matches on the old prefix, update it.
37
+
38
+ ---
39
+
40
+ ## Upgrading to 4.4.3
41
+
42
+ No code changes. Two things you may observe after upgrading.
43
+
44
+ **More requests where there were wrongly fewer.** `share: true` endpoints and
45
+ `cacheMiddleware` keyed params by their enumerable keys. A `Date`, `Map`, `Set`,
46
+ `ArrayBuffer`, BigInt or private-state class instance anywhere below the top
47
+ level therefore made every such call look identical, and one caller could
48
+ receive the response meant for another caller's params. Those calls now key by
49
+ content and go out separately. If request counts rise after upgrading, that is
50
+ the fix working: the previous count was wrong responses, not savings. A value
51
+ that cannot be keyed soundly (a BigInt, an `ArrayBuffer`, `Blob`, `FormData`
52
+ or `URLSearchParams`, an object with no enumerable state) is now never shared
53
+ or cached at any depth, where before only the top level was checked.
54
+
55
+ **`{ a: undefined }` and `{}` are now the same key.** An `undefined` member is
56
+ dropped, as `JSON.stringify` drops it on the wire, so equivalent calls hit the
57
+ same cache entry and the same in-flight share. There is no way to make
58
+ `undefined` mean "a different request", and the wire never carried one.
59
+
60
+ One smaller change at the top level only: a `Map`, `Set` or `Date` param was
61
+ previously never shared or cached; it now is, by content, so two identical such
62
+ params coalesce. `FormData`, `Blob`, `ArrayBuffer` and `URLSearchParams` still
63
+ never do.
64
+
65
+ ---
66
+
67
+ ## Upgrading to 4.4.2
68
+
69
+ No action is needed for almost everyone. This release makes `timeout` and
70
+ `CallOptions.signal` do what they were documented to do: settle a call even
71
+ when a middleware is stuck awaiting work that ignores the signal.
72
+
73
+ **The rule that changed:** a call is now bounded by its deadline no matter what
74
+ it is waiting on. Before, the deadline only reached `fetch` and whatever read
75
+ `ctx.request.signal`; anything else could run past it, and the call waited.
76
+ So the outcome changes for any call that was still running **after** its
77
+ `timeout` fired (or its signal aborted) on work the signal does not reach:
78
+
79
+ - a middleware awaiting something that never settles — before: the call never
80
+ settled; now: `kind: 'timeout'` / `'abort'`.
81
+ - response-side work that crosses the deadline — a middleware post-processing
82
+ a success, an async Standard Schema validator — or a `fetch` implementation
83
+ (a hand-rolled mock, a polyfill) that ignores its signal. Before: the late
84
+ result was delivered; now: `kind: 'timeout'` / `'abort'`, the same as if the
85
+ work had finished one moment later than it did.
86
+
87
+ A call that finishes within its deadline is unaffected, and so is any work that
88
+ already responds to the abort. If you have a test mock that ignores
89
+ `init.signal` and resolves *after* a `timeout` you set, that test now sees a
90
+ timeout — which is what the configuration asked for.
91
+
92
+ ### If you use `mockFetch` from `./testing`
93
+
94
+ `mock.fetch` now honours `init.signal`. A mocked call whose signal is aborted —
95
+ already, or while its route handler is still pending — rejects with
96
+ `signal.reason` instead of resolving, exactly as real `fetch` does. A test that
97
+ aborted a call and still expected the mocked response is the only thing this
98
+ changes; it now sees the abort.
99
+
100
+ ### If you wrapped calls in your own `Promise.race` against a timer
101
+
102
+ You can delete the wrapper and use `timeout` (or pass your `AbortSignal`)
103
+ instead. A call whose middleware stalls now settles with `kind: 'timeout'` (or
104
+ `'abort'`), `status: 0`.
105
+
106
+ ### If a middleware answers an abort slowly
107
+
108
+ This is the one case above where a middleware's own handling of the deadline
109
+ is overruled, so it gets its own section.
110
+
111
+ Once the signal aborts, the chain gets one macrotask to answer by itself. A
112
+ middleware that responds to a timeout by doing *more* I/O before returning its
113
+ own `Result` — reading a fallback from IndexedDB, say — used to have that
114
+ `Result` delivered, however long it took. Now, if it has not answered within
115
+ that macrotask, the caller gets the timeout `Result` and the middleware's later
116
+ answer is discarded.
117
+
118
+ A fallback that answers from memory, or from anything already in hand, is
119
+ unaffected — it settles within microtasks. If yours needs real I/O after the
120
+ deadline, give it a deadline of its own that fires earlier than the call's:
121
+
122
+ ```ts
123
+ const withFallback: Middleware = async (ctx, next) => {
124
+ ctx.request.signal = AbortSignal.timeout(4_000) // inside the call's 5_000
125
+ const result = await next()
126
+ return result.error?.kind === 'timeout' ? await readFallback(ctx) : result
127
+ }
128
+ ```
129
+
130
+ Like the per-attempt example in the README, this *replaces* the signal, so the
131
+ caller's own `AbortSignal` no longer reaches `fetch` — aborting it still settles
132
+ the call (the backstop watches it), but the socket stays open until the 4-second
133
+ signal fires. If that matters, merge the two instead of replacing, with
134
+ `AbortSignal.any([ctx.request.signal, own])` where your runtimes support it.
135
+
136
+ ### If a middleware calls `next()` long after the call timed out
137
+
138
+ It no longer sends a request. `next()` returns the `Result` the caller already
139
+ received. Before, a middleware that installed a fresh signal of its own could
140
+ still send one nobody was waiting for.
141
+
142
+ Under `dedupe: true`, a request still in flight when the call is settled this
143
+ way is aborted, not left running: nothing else can cancel it once the call has
144
+ given up its dedupe slot.
145
+
146
+ ---
147
+
148
+ ## Upgrading to 4.4.0
149
+
150
+ One change needs action, and only if you use `defineRequest` with a `path`
151
+ containing a `#`.
152
+
153
+ ### A fragment in a `defineRequest` path literal is now a compile error
154
+
155
+ If your build goes red on a line like this, that is this change:
156
+
157
+ ```ts
158
+ defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
159
+ // ^ Property '__fragmentInPath' is missing:
160
+ // a URL fragment is never sent to the server
161
+ ```
162
+
163
+ **Your code was already broken.** A URL fragment is never transmitted — `fetch`
164
+ strips it — so this endpoint could never reach `/docs#section`. Since 4.2.1 it
165
+ has been returning an error `Result` on **every call**. The only thing that
166
+ changed in 4.4.0 is *when* you find out: at compile time, where you declared it,
167
+ instead of at runtime on every request.
168
+
169
+ **The fix is to delete the fragment:**
170
+
171
+ ```ts
172
+ // before
173
+ defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
174
+
175
+ // after
176
+ defineRequest<Doc>()({ method: 'GET', path: '/docs' })
177
+ ```
178
+
179
+ If the fragment was carrying information you need on the server, it has to move
180
+ into the path or the query string, because the server never received it:
181
+
182
+ ```ts
183
+ defineRequest<Doc>()({ method: 'GET', path: '/docs/:section' })
184
+ // or
185
+ defineRequest<Doc>()({ method: 'GET', path: '/docs' }) // then pass { section } as a param
186
+ ```
187
+
188
+ ### What is not affected
189
+
190
+ The check reads the **path literal**, so these all still compile and behave
191
+ exactly as before — they are caught by the runtime error instead:
192
+
193
+ ```ts
194
+ const path: string = loadFromConfig()
195
+ defineRequest<Doc>()({ method: 'GET', path }) // not a literal
196
+
197
+ const cfg: RequestConfig = { method: 'GET', path: '/docs#section' }
198
+ defineRequest<Doc>()({ ...cfg, path: cfg.path }) // widened to string
199
+
200
+ new Request<P, Doc>({ method: 'GET', path: '/docs#section' }) // no path literal to read
201
+ ```
202
+
203
+ `new Request` has no equivalent check because its constructor takes no path type
204
+ parameter to read a literal from. That asymmetry is the reason `defineRequest`
205
+ exists.
206
+
207
+ ---
208
+
209
+ ## Upgrading to 4.2.1
210
+
211
+ One change needs action, and only if a `path` or `baseUrl` of yours contains a
212
+ `#`.
213
+
214
+ ### A URL fragment is now refused
215
+
216
+ A fragment is never transmitted — `fetch` strips it — so one in a request URL
217
+ could never do what it looked like it did. Worse, it silently swallowed the
218
+ query string:
219
+
220
+ ```ts
221
+ new Request({ method: 'GET', path: '/docs#section' })
222
+
223
+ // 4.2.0 and earlier
224
+ await api.docs({ page: 2 })
225
+ // URL built: '/docs#section?page=2'
226
+ // what was sent: path '/docs', NO query at all -- page=2 was dropped, silently
227
+
228
+ // 4.2.1
229
+ await api.docs({ page: 2 })
230
+ // error.kind === 'network'
231
+ // "A URL fragment is never sent to the server, so it cannot appear in a path.
232
+ // Remove "#section" from "/docs#section"."
233
+ ```
234
+
235
+ **What to do:** delete the fragment from the template. Nothing else changes —
236
+ the params that were being dropped now reach the server.
237
+
238
+ If neither your `baseUrl` nor any `path` contains a `#`, this release is a
239
+ no-op for you, and the `baseUrl` fix below needs nothing from you either.
240
+
241
+ ### A `baseUrl` with a query string now works
242
+
243
+ No action required — this only replaces broken output with correct output. If
244
+ your `baseUrl` carried a query string (`https://api.example.com/v1?key=abc`),
245
+ the path was previously appended *inside* the query value, so requests resolved
246
+ to the base path and went to the wrong endpoint. They now go where they should,
247
+ with the base's params merged ahead of the call's.
248
+
249
+ ## Upgrading to 4.0.0
250
+
251
+ One rule changes: **a success must carry data.** If you declared
252
+ `responseType: 'none'` on the endpoints 3.1.0's warning named, this release is
253
+ a no-op for you.
254
+
255
+ ### 1. An empty body under `responseType: 'json'` is now an error
256
+
257
+ ```ts
258
+ // A DELETE that answers 204 No Content, responseType left at the default:
259
+
260
+ // 3.x
261
+ const { data, error } = await api.deleteUser({ id: '42' })
262
+ // error === null, data === null -- and `data.deleted` compiles, then throws
263
+
264
+ // 4.0.0
265
+ const { data, error } = await api.deleteUser({ id: '42' })
266
+ // error.kind === 'parse', error.status === 204, data === null
267
+ ```
268
+
269
+ This is what makes `SuccessResult.data: TResponse` true. 3.0.0 made `Result<T>`
270
+ a discriminated union so `if (error) return` narrows `data` — but an empty body
271
+ produced `null` behind a non-null `TResponse`, so the narrowing was a lie for
272
+ exactly the endpoints least likely to be checked.
273
+
274
+ **The fix, on every endpoint that answers with no body:**
275
+
276
+ ```ts
277
+ const deleteUser = new Request<{ id: string }, undefined>({
278
+ method: 'DELETE',
279
+ path: '/users/:id',
280
+ responseType: 'none', // available since 3.1.0
281
+ })
282
+ ```
283
+
284
+ `'none'` reads no body on success, so it never reaches the rule. Non-2xx
285
+ responses are unaffected — their body is still read and parsed for
286
+ `error.body`, on `'none'` as on `'json'`.
287
+
288
+ **There is no 204 special case.** One rule — declared JSON, got no JSON —
289
+ applies at every status. A `200` with an empty body behaves identically to a
290
+ `204`.
291
+
292
+ **A literal `null` body still succeeds.** `JSON.parse("null")` is valid JSON, so
293
+ a server that sends the body `null` is sending data. Only a zero-length body is
294
+ an error.
295
+
296
+ **Which endpoints are affected?** 3.1.0 told you, by name, once per request:
297
+ any endpoint that logged `[apify] <name>: server returned an empty body`. If you
298
+ are coming from 3.1.0 and never saw that warning in development or staging, no
299
+ endpoint of yours hits this path.
300
+
301
+ ### 2. A GraphQL response with no `data` is now an error
302
+
303
+ The same rule, at the GraphQL client's own seam. A 2xx response carrying
304
+ neither `data` nor `errors` used to resolve as a success with `data: null`:
305
+
306
+ ```ts
307
+ // Server returns 200 with body {} (or "", or {"data": null})
308
+
309
+ // 3.x
310
+ const { data, error } = await client.getUser({ id: '1' })
311
+ // error === null, data === null
312
+
313
+ // 4.0.0
314
+ const { data, error } = await client.getUser({ id: '1' })
315
+ // error.kind === 'parse', error.status === 200, error.body === '{}'
316
+ ```
317
+
318
+ `error.body` carries the raw response text, which is the only useful answer to
319
+ "then what did the server send?".
320
+
321
+ **GraphQL errors are unchanged.** A `{"data": null, "errors": [...]}` response —
322
+ the legitimate shape for a field error — still reports `kind: 'http'` with the
323
+ errors in `error.body` and any partial result in `error.partialData`, exactly as
324
+ before. Only a response reporting *no* errors and *no* data is affected, which
325
+ the GraphQL over HTTP spec does not permit.
326
+
327
+ ### 3. The one-time empty-body warning is gone
328
+
329
+ 3.1.0's `console.warn` has served its purpose and is removed. Nothing replaces
330
+ it — the condition it warned about is now reported as an error through the
331
+ normal `Result`.
332
+
333
+ ### Nothing to do if…
334
+
335
+ - every endpoint that returns no body already declares `responseType: 'none'`, or
336
+ - you never saw 3.1.0's empty-body warning, or
337
+ - your GraphQL server always answers with `data` or `errors` (i.e. it is
338
+ spec-compliant).
339
+
340
+ In those cases 4.0.0 is a drop-in upgrade.
341
+
342
+ ---
343
+
344
+ ## Upgrading to 3.1.0
345
+
346
+ 3.1.0 is additive — no existing behaviour changed, and no action is required
347
+ to upgrade. It adds one new `responseType` option and a diagnostic warning;
348
+ read on if either applies to you.
349
+
350
+ ### 1. New: `responseType: 'none'`
351
+
352
+ Declares that an endpoint returns no body on success — the accurate
353
+ declaration for a `204`, or a `200` with an empty body, most commonly a
354
+ `DELETE`. Before 3.1.0, that shape only had `responseType: 'json'` (the
355
+ default) to reach for, which parses an empty body to `data: null` at runtime
356
+ while `TResponse` claims otherwise:
357
+
358
+ ```ts
359
+ // Before — TResponse widened to admit the null the endpoint actually returns
360
+ const deleteUserBefore = new Request<{ id: string }, { deleted: boolean } | null>({
361
+ method: 'DELETE',
362
+ path: '/users/:id',
363
+ })
364
+ const before = await api.deleteUserBefore({ id: '42' })
365
+ if (before.error) return
366
+ if (before.data) console.log(before.data.deleted) // null-check required even though error was null
367
+
368
+ // After — responseType: 'none' says exactly what happens: no body, ever
369
+ const deleteUserAfter = new Request<{ id: string }, undefined>({
370
+ method: 'DELETE',
371
+ path: '/users/:id',
372
+ responseType: 'none',
373
+ })
374
+ const after = await api.deleteUserAfter({ id: '42' })
375
+ if (after.error) return
376
+ console.log(after.data) // undefined -- no null-check needed, and none is possible
377
+ ```
378
+
379
+ Declare `TResponse` as `undefined` when using `responseType: 'none'` — this
380
+ is a convention, not a compile-time guarantee, and `new Request<P,
381
+ User>({ responseType: 'none' })` compiles without error. A non-2xx response
382
+ is unaffected: its body is still read and parsed as JSON for `error.body`,
383
+ since an error body (a message, a code) is worth reading even when the
384
+ caller wants nothing back on success.
385
+
386
+ **Known limitation:** the compiler does not check the `responseType: 'none'`
387
+ / `TResponse` pairing. Mismatch them and `data` is `undefined` at runtime
388
+ behind whatever type you declared, with no compile error to catch it.
389
+
390
+ ### 2. You may see a one-time console warning
391
+
392
+ If any existing endpoint declares `responseType: 'json'` (the default) and
393
+ the server answers with an empty body, 3.1.0 now logs this once per request
394
+ name, per `createApi` instance:
395
+
396
+ ```
397
+ [apify] deleteUser: server returned an empty body for responseType 'json'. This yields data: null today and will be an error in 4.0.0. Declare responseType: 'none' if the endpoint returns no content.
398
+ ```
399
+
400
+ This is a diagnostic, not a behaviour change — the call still resolves as a
401
+ success with `data: null`, exactly as it always has. It means the named
402
+ request is one of the empty-body endpoints described above, and declaring
403
+ `responseType: 'none'` on it both makes `TResponse` accurate and silences the
404
+ warning.
405
+
406
+ ### 3. 4.0.0: the empty-body rule (shipped)
407
+
408
+ This shipped. See [Upgrading to 4.0.0](#upgrading-to-400) — an empty body under
409
+ `responseType: 'json'` is now a `kind: 'parse'` error, and declaring
410
+ `responseType: 'none'` makes that upgrade a no-op.
411
+
412
+ ## Upgrading to 3.0.0
413
+
414
+ 3.0.0 tightens contracts the library always implied but never enforced. Most
415
+ of it is types catching up to behaviour that was already there; the error
416
+ *classification* changes (parse failures, aborts) change what a `Result`
417
+ actually contains for a narrow set of cases. Read the "Nothing to do if…"
418
+ section at the end first — it covers the common case.
419
+
420
+ ### 1. `Result<T>` is a discriminated union, not an interface
421
+
422
+ `data` and `error` used to be independent nullable fields, so `if (error)
423
+ return` never narrowed `data` — every consumer had to write `data!` or a
424
+ redundant null check. `Result<T>` is now `SuccessResult<T> | ErrorResult<T>`
425
+ (both exported), and checking `error` narrows `data` for real:
426
+
427
+ ```ts
428
+ const { data, error } = await api.getUser({ id: '42' })
429
+ if (error) return
430
+ // 2.x → data: User | null, so data!.name (or a redundant `if (!data) return`)
431
+ // 3.0.0 → data: User, so data.name — the `!` and the redundant check can go
432
+ console.log(data.name)
433
+ ```
434
+
435
+ **If you have custom middleware that synthesises a *success* `Result`**
436
+ (short-circuits with `{ data, error: null, response, retry }` rather than
437
+ calling `next()`), it must now supply a non-null `Response` — the type no
438
+ longer allows `response: null` on the success branch. There is no runtime
439
+ change here: the library's own factories (`createSuccessResult` and friends)
440
+ already only ever produced exactly these shapes, so this is a compile-time
441
+ tightening, not a behaviour change, for any middleware that was already
442
+ well-formed.
443
+
444
+ ### 2. `Request` generics are no longer interchangeable
445
+
446
+ `Request<TParams, TResponse>` never referenced its own generics in the class
447
+ body, so every instantiation was structurally identical to TypeScript and
448
+ `Request<{ id: string }, User>` silently accepted a `Request<{ slug: string
449
+ }, Post>` wherever one was expected. Phantom fields now make the generics
450
+ load-bearing:
451
+
452
+ ```ts
453
+ const getUser = new Request<{ id: string }, User>({ method: 'GET', path: '/users/:id' })
454
+ const getPost = new Request<{ slug: string }, Post>({ method: 'GET', path: '/posts/:slug' })
455
+
456
+ function useRequest(r: Request<{ id: string }, User>) { /* ... */ }
457
+
458
+ useRequest(getUser) // fine, always was
459
+ useRequest(getPost)
460
+ // 2.x → compiled (both Requests looked identical to the type system)
461
+ // 3.0.0 → compile error: Request<{ slug: string }, Post> is not assignable
462
+ // to Request<{ id: string }, User>
463
+ ```
464
+
465
+ **What to do:** if this fires after upgrading, the assignment was already
466
+ wrong — the two `Request`s describe different endpoints and were never
467
+ actually interchangeable at runtime. Fix the annotation to match the real
468
+ endpoint.
469
+
470
+ ### 3. `ApiError.kind` is required, and gained `'middleware'`
471
+
472
+ `kind` shipped optional in 2.2.0; every construction site inside the library
473
+ already set it, so this is the type catching up. `ApiErrorKind` is now
474
+ `'http' | 'network' | 'abort' | 'timeout' | 'parse' | 'middleware'`.
475
+
476
+ ```ts
477
+ // Custom middleware constructing its own ApiError (e.g. to short-circuit
478
+ // with a validation failure) must now supply `kind`:
479
+ new ApiError({
480
+ status: 422,
481
+ kind: 'http', // 3.0.0: required — omitting this is a type error
482
+ statusText: 'Unprocessable Entity',
483
+ body: { message: 'invalid payload' },
484
+ headers: new Headers(),
485
+ request: { method: 'POST', url, params },
486
+ })
487
+ ```
488
+
489
+ **If you have an exhaustive `switch (error.kind)`** (or a mapped type keyed on
490
+ `ApiErrorKind`), it needs a new `'middleware'` arm — see #5 below for what
491
+ produces it.
492
+
493
+ ### 4. Parse failures on a 2xx response changed shape
494
+
495
+ **Scope: 2xx responses only.** A 2xx response whose body failed to parse
496
+ according to `responseType` previously reported `status: 0`, `response:
497
+ null`, `kind: 'network'` — indistinguishable from being offline, and the
498
+ `Response` (status, headers) was discarded even though the server did
499
+ respond. It now reports the real `status`, a non-null `response`, and `kind:
500
+ 'parse'`:
501
+
502
+ ```ts
503
+ const { error } = await api.getUser({ id: '42' }) // server sent 200 with an unparseable body
504
+ // 2.x → error.status === 0, error.response === null, error.kind === 'network'
505
+ // 3.0.0 → error.status === 200, error.response !== null, error.kind === 'parse'
506
+ ```
507
+
508
+ **Non-2xx responses are unaffected.** `!response.ok` is checked before the
509
+ body is parsed, so a 5xx with an unparseable body already reported (and still
510
+ reports) `kind: 'http'` with the real status — **`retryMiddleware`'s default
511
+ 5xx retry behaviour has not changed.** Do not treat this as "parse errors are
512
+ now retried differently"; only the 2xx case moved.
513
+
514
+ **What to do:** code that branched on `status === 0` (or `kind === 'network'`)
515
+ to mean "the user is offline" now needs to also handle `kind: 'parse'`
516
+ explicitly if it wants to keep distinguishing "offline" from "the server
517
+ responded with something we couldn't read." Code that only checked `if
518
+ (error)` and logged generically needs no changes.
519
+
520
+ ### 5. A throwing middleware returns a `Result` instead of rejecting
521
+
522
+ `composeMiddleware`'s dispatch has no guard, so an `async` middleware that
523
+ threw used to reject the caller's promise — breaking the "never throws"
524
+ contract for the one path most likely to have a bug (your own middleware). It
525
+ now produces an ordinary `Result` with `kind: 'middleware'`:
526
+
527
+ ```ts
528
+ const buggyMiddleware: Middleware = async (ctx, next) => {
529
+ throw new Error('oops')
530
+ }
531
+
532
+ // 2.x
533
+ try {
534
+ const result = await api.getUser({ id: '42' })
535
+ } catch (err) {
536
+ // had to catch here — a middleware bug rejected the call
537
+ }
538
+
539
+ // 3.0.0 — no try/catch needed; remove it
540
+ const { error } = await api.getUser({ id: '42' })
541
+ if (error?.kind === 'middleware') {
542
+ // error.body is the Error the middleware threw
543
+ }
544
+ ```
545
+
546
+ **What to do:** delete any `try`/`catch` you placed around an API call
547
+ specifically to catch a middleware's throw. It's dead code now — the call
548
+ never rejects — and the failure is available as `error.kind === 'middleware'`
549
+ instead.
550
+
551
+ ### 6. `onError` no longer fires for `error.kind === 'abort'`
552
+
553
+ Every `dedupe` supersede and every caller-initiated cancellation used to reach
554
+ `onError` — i.e. your Sentry — as a reported error, even though the library
555
+ caused it deliberately. `'timeout'` is unaffected and still fires: a deadline
556
+ you missed is a real failure, unlike a cancellation you caused yourself.
557
+
558
+ ```ts
559
+ const controller = new AbortController()
560
+ const promise = api.getUser({ id: '42' }, { signal: controller.signal })
561
+ controller.abort()
562
+ await promise
563
+ // 2.x → onError(error) fires with error.kind === 'abort'
564
+ // 3.0.0 → onError does not fire; the caller still gets the abort Result back
565
+ ```
566
+
567
+ **What to do:** delete any hand-rolled filtering you added to your `onError`
568
+ handler to ignore `AbortError`/cancellations (e.g. `if (error.body?.name ===
569
+ 'AbortError') return`) — the library now does this for you, unconditionally,
570
+ for every abort it produces.
571
+
572
+ ### 7. Aborts are classified by provenance, not by the reason's name
573
+
574
+ This is the change most likely to surface silently, because it changes
575
+ `kind` for shapes that used to look like something else entirely.
576
+ Previously, the library guessed a cancellation by sniffing the *thrown
577
+ value's* `.name` (`'AbortError'` / `'TimeoutError'`). Now it asks a different
578
+ question: **did the signal that actually governs this request abort?** If
579
+ yes, the failure is `'abort'`/`'timeout'` regardless of what was thrown or
580
+ what it's named; if no, sniffing the thrown value's shape is the fallback.
581
+ Four consequences:
582
+
583
+ - **A caller's own custom abort reason is now silent, not reported.**
584
+ `controller.abort(new Error('unmounted'))` or `controller.abort('cancelled')`
585
+ used to fail the old name-based sniff (the reason isn't named
586
+ `AbortError`), so it fell through to `kind: 'network'` and reached
587
+ `onError`. It's now `kind: 'abort'` — correctly classified as *your*
588
+ cancellation — and, per #6, `onError` doesn't fire for it.
589
+
590
+ ```ts
591
+ controller.abort(new Error('component unmounted'))
592
+ // 2.x → error.kind === 'network', reported to onError
593
+ // 3.0.0 → error.kind === 'abort', not reported
594
+ ```
595
+
596
+ **What to do:** if you were branching on `error.kind === 'network'` (or
597
+ `error.status === 0`) to mean "offline," and relying on a custom abort
598
+ reason to *also* hit that branch, it no longer will. Branch on `kind ===
599
+ 'abort'` explicitly if you still want to observe your own cancellations.
600
+
601
+ - **A middleware that propagates the library's own abort reason now returns a
602
+ silent `kind: 'abort'` `Result`, instead of rejecting the caller's promise
603
+ outright.** This covers both re-throwing the exact reason (`throw
604
+ ctx.request.signal.reason`) and the shape `node:timers/promises` and most
605
+ abortable helpers actually produce — a fresh `AbortError` whose `.cause` is
606
+ the signal's reason:
607
+
608
+ ```ts
609
+ import { setTimeout as delay } from 'node:timers/promises'
610
+
611
+ const backoff: Middleware = async (ctx, next) => {
612
+ await delay(100, undefined, { signal: ctx.request.signal })
613
+ return next()
614
+ }
615
+ // if ctx.request.signal aborts during the delay, `delay` rejects with an
616
+ // AbortError whose `.cause` is ctx.request.signal.reason
617
+ // 2.x → non-shared: composeMiddleware's chain had no rejection handler at
618
+ // all, so the caller's own promise rejects with the raw AbortError
619
+ // — no Result, "kind" does not apply, onError never runs.
620
+ // Under `share: true` specifically, this *was* already converted to
621
+ // a Result (the share site's own rejection handler, present since
622
+ // 2.2.0), classified `kind: 'abort'` by sniffing the thrown value's
623
+ // `.name` — the same name-based sniff #7's intro paragraph
624
+ // describes, so it could not tell this genuine propagation apart
625
+ // from bullet 3's unrelated-failure case below. Reported either way
626
+ // (no abort suppression existed yet).
627
+ // 3.0.0 → error.kind === 'abort' (recognised as propagating our own signal,
628
+ // not merely name-matched), returned as an ordinary Result on every
629
+ // path, not reported
630
+ ```
631
+
632
+ **What to do:** delete any `try`/`catch` you placed around this kind of
633
+ call for the same reason as #5. Code was already correct if it treated this
634
+ as `kind: 'abort'` — it just could not have relied on that being *reliable*
635
+ (see bullet 3, which used to collide with this one under the old
636
+ name-based sniff). Middleware authors: see the "worth knowing" note below
637
+ about not attaching `signal.reason` as `.cause` to your *own* unrelated
638
+ failures — doing so makes them indistinguishable from this propagation
639
+ case.
640
+
641
+ - **A middleware throwing its own, unrelated `AbortError`-named failure now
642
+ reaches `onError` classified as `'middleware'`, as an ordinary `Result`,
643
+ instead of rejecting the caller's promise outright.** An IndexedDB quota
644
+ abort, say, rethrown by a caching middleware, has nothing to do with this
645
+ request's own signal:
646
+
647
+ ```ts
648
+ // 2.x → non-shared: composeMiddleware's chain had no rejection handler at
649
+ // all, so the caller's own promise rejects with the raw AbortError
650
+ // — no Result, "kind" does not apply, onError never runs.
651
+ // Under `share: true`, this was already converted to a Result, but
652
+ // misclassified `kind: 'abort'` by the same name-based sniff as
653
+ // bullet 2 above — 'middleware' did not exist as a kind at all
654
+ // before this release, on either path — and was reported (no
655
+ // suppression existed for 'abort' yet either).
656
+ // 3.0.0 → error.kind === 'middleware' (not a propagation of our signal),
657
+ // returned as an ordinary Result on every path, reported
658
+ ```
659
+
660
+ **What to do:** nothing to change in your code, but expect to *start*
661
+ seeing these correctly labelled `kind: 'middleware'` instead of either an
662
+ unhandled rejection (non-shared) or a misleading `kind: 'abort'` (shared) —
663
+ if you have middleware that can throw an `AbortError`-named failure
664
+ unrelated to request cancellation. See #5 above: this is the same
665
+ "throwing middleware now returns a Result" change, just for a failure that
666
+ happens to be named like an abort.
667
+
668
+ - **Any abort of the exact signal handed to `fetch` — including one installed
669
+ by middleware — is now `'abort'`/`'timeout'` and silent**, not classified by
670
+ what was thrown. A middleware that replaces `ctx.request.signal` (e.g. a
671
+ per-attempt timeout) and whose replacement signal aborts now gets the same
672
+ cancellation treatment as any other abort on the operative signal.
673
+
674
+ **What to do, generally:** grep your `onError` handler and any code branching
675
+ on `error.kind` or `error.status === 0` for logic that assumed "not named
676
+ `AbortError`" meant "not a cancellation," or that assumed `kind ===
677
+ 'middleware'` was reserved for genuine middleware bugs. Both assumptions are
678
+ now wrong in the specific ways above.
679
+
680
+ ### 8. Cancelling during the response body download is now classified as the cancellation
681
+
682
+ Aborting after headers arrive but while the body is still downloading — a
683
+ component unmounting mid-fetch, a deadline firing mid-download — used to be
684
+ misclassified for a **non-2xx** response specifically. The **2xx** case
685
+ already produced the right `kind`/`status`/`response` shape in 2.2.1; only
686
+ whether it was *reported* changes there, which is entirely #6 (`onError` no
687
+ longer fires for `'abort'`), not a distinct shape change:
688
+
689
+ ```ts
690
+ // A slow response body, aborted partway through download:
691
+ // 2xx response (e.g. a slow success payload):
692
+ // 2.x → kind: 'abort' (or 'network', if the thrown value wasn't
693
+ // name-recognisable as AbortError/TimeoutError), status: 0,
694
+ // response: null — reported (2.2.1 had no abort suppression at all)
695
+ // 3.0.0 → kind: 'abort' (or 'timeout'), status: 0, response: null — not
696
+ // reported (same shape, see #6 for why reporting stops)
697
+ // non-2xx response (e.g. a slow 502 gateway page) — this is the real shape change:
698
+ // 2.x → kind: 'http', the real status, response present, body: null —
699
+ // reported, regardless of whether the cancellation was ours
700
+ // 3.0.0 → kind: 'abort' (or 'timeout'), status: 0, response: null — not reported
701
+ ```
702
+
703
+ Two concrete hazards to check for, both specific to the **non-2xx** case:
704
+
705
+ - **Code branching on `error.status` for a cancellation that lands while a
706
+ non-2xx body downloads** now sees `status: 0` instead of the real status —
707
+ the same "was this reported?" question as #6/#7 applies.
708
+ - **Code reading `result.response!.headers` (or any non-null assertion on
709
+ `response`) for that same non-2xx-cancellation case** must now handle
710
+ `response === null` — it previously had a real `response` (with `body:
711
+ null`), even though the request never actually finished.
712
+
713
+ **Also:** `retryMiddleware`'s default `retryOn` (and any custom `retryOn`
714
+ keyed on `status >= 500`) no longer retries a cancellation caught in this
715
+ window, because `status` is now `0`, not the real (often 5xx-shaped) status.
716
+ This is strictly correct — retrying a cancellation you caused is never
717
+ useful — but it means **fewer requests** for code that happened to rely on
718
+ the old misclassification triggering a retry.
719
+
720
+ ### 9. GraphQL partial data is preserved (additive)
721
+
722
+ A GraphQL response with both `data` and `errors` (partial success — a
723
+ nullable field errored while the rest of the query resolved) used to discard
724
+ `data` entirely. It's now available as `error.partialData`:
725
+
726
+ ```ts
727
+ const { error } = await graphql.getCategory({ id: '123' })
728
+ if (error) {
729
+ console.log(error.body) // GraphQLError[]
730
+ console.log(error.partialData) // the data the server sent alongside the errors, or undefined
731
+ }
732
+ ```
733
+
734
+ This lives on `error.partialData`, not `result.data` — putting it on `data`
735
+ would break the `Result` union's narrowing from #1: a non-null `error` means
736
+ `data` is null, and a null `error` means the call succeeded (see the
737
+ empty-body caveat below). Nothing to change unless you want to start using
738
+ it.
739
+
740
+ ### Worth knowing, no action needed
741
+
742
+ - **A genuinely malformed body arriving while the signal *happens* to already
743
+ be aborted is classified as the cancellation, and so dropped from
744
+ `onError`**, even when the abort didn't actually cause the parse failure.
745
+ The guard asks "is the signal aborted right now," not "did the abort cause
746
+ this" — narrowing that further would require `parseResponse` to
747
+ distinguish its own read failure from a parse failure across all five
748
+ response types, which it doesn't attempt. In practice this only matters if
749
+ you're relying on `onError` to catch every malformed-body case with
750
+ certainty; it's a narrow, pre-existing edge case, not a new hazard to
751
+ design around.
752
+ - **Middleware authors: do not attach `signal.reason` as `.cause` to your
753
+ own, unrelated failure.** `throw new Error('cache write failed', { cause:
754
+ ctx.request.signal.reason })` is read as *relaying* the library's own
755
+ cancellation (see #7's `.cause`-unwrapping case) and silently dropped as
756
+ `'abort'`, even though your error is about something else entirely (a cache
757
+ write, not a cancellation) that merely happened to occur while the signal
758
+ was aborted. Use a different field to carry that context —
759
+ `{ cause: new Error('disk full') }`, or a custom property — and reserve
760
+ `.cause` for genuine propagation of the signal's own reason.
761
+ - **`SuccessResult.data` is typed `TResponse`, not `TResponse | null` — but an
762
+ empty body still parses to `null` at runtime.** A `DELETE` that answers
763
+ `200` with no body (or a `204`) is one of the most common REST shapes, and
764
+ `parseResponse` returns `null` for it exactly as it always has (see the
765
+ `responseType` reference for the empty-body case). 3.0.0 removes the `| null`
766
+ from the *type*, not from the *behaviour*: `error` is still `null` on that
767
+ response, so `data` narrows to `TResponse` and compiles, but the value you
768
+ get is `null` anyway.
769
+
770
+ ```ts
771
+ const deleteUser = new Request<{ id: string }, { deleted: boolean }>({
772
+ method: 'DELETE', path: '/users/:id',
773
+ })
774
+ const { data, error } = await api.deleteUser({ id: '42' })
775
+ if (error) return
776
+ console.log(data.deleted) // throws: data is null at runtime for a 200/204 empty body
777
+ ```
778
+
779
+ If an endpoint can answer 204 or an empty 200, say so accurately in its own
780
+ `TResponse`. **This guidance changed in 3.1.0:** at the time of the 3.0.0
781
+ release, the only option was widening to `Request<{ id: string }, { deleted:
782
+ boolean } | null>` and handling the `null` case explicitly; as of 3.1.0, use
783
+ `responseType: 'none'` instead (see [Upgrading to
784
+ 3.1.0](#upgrading-to-310) above) — it's more accurate (declares "no body,"
785
+ not "body or null") and it silences the empty-body warning 3.1.0 added. This
786
+ is not a new *behaviour* (2.2.1 had the identical runtime `null`); what
787
+ changed in 3.0.0 is that the type system no longer forces you to handle it,
788
+ and what changed in 3.1.0 is the recommended way to handle it anyway.
789
+
790
+ ### Nothing to do if…
791
+
792
+ …you only call API methods and check `error`, **and every endpoint's
793
+ `TResponse` accounts for its own empty-body responses** (see the bullet
794
+ above — as of 3.1.0, a `DELETE`/204/empty-200 endpoint should declare
795
+ `responseType: 'none'` to stay accurate; everything else needs no change):
796
+
797
+ ```ts
798
+ const { data, error } = await api.getUser({ id: '42' })
799
+ if (error) {
800
+ console.error(error.status, error.body)
801
+ return
802
+ }
803
+ console.log(data.name)
804
+ ```
805
+
806
+ The only change visible here is a good one: `data!.name` becomes `data.name`
807
+ (the `!` is now unnecessary and can be deleted, but leaving it is harmless —
808
+ a non-null assertion on an already-non-null value is a no-op). No behavior
809
+ changes for this pattern.
810
+
811
+ ## Upgrading to 2.2.0
812
+
813
+ 2.2.0 is additive — no API changed shape, and there is nothing you need to do.
814
+
815
+ ### Worth knowing, no action needed
816
+
817
+ - **Abort reasons now survive `dedupe`.** Previously, a `dedupe: true`
818
+ request's merged signal always aborted with a generic, reason-less
819
+ `AbortError`, regardless of what actually caused the abort:
820
+
821
+ ```ts
822
+ const withTimeout: Middleware = async (ctx, next) => {
823
+ ctx.request.signal = AbortSignal.timeout(20)
824
+ return next()
825
+ }
826
+
827
+ const api = createApi({
828
+ baseUrl: '/api',
829
+ requests: { getUser }, // getUser has dedupe: true
830
+ middleware: [withTimeout],
831
+ })
832
+
833
+ const { error } = await api.getUser({ id: '42' })
834
+ // 2.1.0 → error.body.name === 'AbortError' -- the real cause (a timeout) was lost
835
+ // 2.2.0 → error.body.name === 'TimeoutError' -- the actual cause survives
836
+ ```
837
+
838
+ This only differs when `dedupe: true` is combined with a caller-supplied
839
+ `signal` or a signal-setting middleware — plain `dedupe: true` with no
840
+ external signal involved is unaffected. `error.body.name` (and any custom
841
+ reason you pass to your own `AbortController.abort(reason)`) is a
842
+ **pre-2.2.0 surface** — existing code reading it does not need to touch
843
+ anything new to notice this, since it never had to opt into `kind` to read
844
+ `.name` in the first place. Going forward, prefer branching on the new
845
+ `error.kind` (`'timeout'` vs `'abort'` vs `'network'`) instead of
846
+ `error.body.name` — it's the field the library commits to maintaining.
847
+
848
+ - **Retries now back off instead of firing instantly.** `retryMiddleware(3)`
849
+ previously made all four attempts in the same tick, with no delay between
850
+ them. It now waits out a real backoff (exponential by default, with jitter)
851
+ between attempts, so a retrying request takes measurably longer in
852
+ wall-clock terms. Nothing breaks, but a test asserting on elapsed time
853
+ around a retrying call may need its tolerance revisited — or pass
854
+ `retryMiddleware({ baseDelay: 0, jitter: false })` to keep the old, instant
855
+ timing.
856
+
857
+ ## Upgrading to 2.1.0
858
+
859
+ 2.1.0 is a non-breaking release, but **two changes can surface as new errors** in
860
+ code that was already subtly wrong. Neither requires an API change on your side.
861
+
862
+ ### The Node floor moved from 18 to 20
863
+
864
+ Node 18 reached end-of-life on 2025-04-30, and CI never tested it — the "Node 18+"
865
+ claim in the README was untested, which is worse than not making it. `package.json`
866
+ now declares `"engines": { "node": ">=20" }`.
867
+
868
+ **What to do:** if you are on Node 18, nothing breaks today — the library uses no
869
+ API that Node 18 lacks. But the version is unsupported and untested, so treat this
870
+ as notice rather than a guarantee.
871
+
872
+ ### Path parameter mismatches now fail loudly
873
+
874
+ Previously, a mismatch between a request's params and its path template shipped a
875
+ malformed URL and said nothing:
876
+
877
+ ```ts
878
+ const getUser = new Request<{ id: string }, User>({
879
+ method: 'GET',
880
+ path: '/users/:userId', // note: :userId, but the param is `id`
881
+ })
882
+
883
+ await api.getUser({ id: '42' })
884
+ // 2.0.0 → GET /api/users/:userId?id=42 ← literal token, value duplicated as a query param
885
+ // 2.1.0 → Result with an error; no request is made
886
+ ```
887
+
888
+ The 2.1.0 result is an ordinary error `Result`, not a thrown exception — the
889
+ never-throws contract is intact:
890
+
891
+ ```ts
892
+ const { error } = await api.getUser({ id: '42' })
893
+ // error.status === 0
894
+ // String(error.body) === 'TypeError: Unresolved path parameter :userId in path "/users/:userId". …'
895
+ ```
896
+
897
+ **What to do:** if this fires after upgrading, the endpoint was making a malformed
898
+ request all along. Align the param name with the path token (or vice versa). The
899
+ error message names the offending token and the path.
900
+
901
+ **Related:** `baseUrl` and `path` now join with exactly one slash, so a `baseUrl`
902
+ ending in `/` no longer produces `//`. If a server was tolerating (or redirecting)
903
+ the double slash, requests will now go to the correct URL — worth checking if you
904
+ have path-sensitive routing or logging.
905
+
906
+ ### Worth knowing, no action needed
907
+
908
+ - `ctx.request.signal` is now readable and writable from middleware, which makes
909
+ timeouts and cancel-on-condition writable in userland for the first time:
910
+
911
+ ```ts
912
+ const timeout = (ms: number): Middleware => async (ctx, next) => {
913
+ ctx.request.signal = AbortSignal.timeout(ms)
914
+ return next()
915
+ }
916
+ ```
917
+
918
+ It composes with `dedupe: true` — dedupe merges whatever signal is current
919
+ rather than discarding it.
920
+
921
+ - The published package is roughly half its former size (240.6 kB → 112.7 kB
922
+ unpacked). Source maps were dropped: they referenced `../src/*.ts`, which was
923
+ never published, and carried no `sourcesContent`, so they resolved to nothing
924
+ in every consumer. JSDoc still ships in the `.d.ts` files, so editor hover
925
+ documentation is unchanged.