liaise 0.0.0 → 5.0.1

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