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/CHANGELOG.md ADDED
@@ -0,0 +1,961 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [5.0.0] — 2026-10-03
9
+
10
+ **Renamed from `@iremlopsum/apify` to `liaise`.** Same code, same API, full git
11
+ history; the repository moves from `iremlopsum/apify` to
12
+ [`iremlopsum/liaise`](https://github.com/iremlopsum/liaise). This is a major
13
+ version because the package name and one piece of observable output change — no
14
+ function, option or type does. See [MIGRATION.md](./MIGRATION.md#upgrading-to-500).
15
+
16
+ ### Changed
17
+
18
+ - **The package is published as `liaise`.** The three entry points become
19
+ `liaise`, `liaise/middleware` and `liaise/testing`.
20
+ - **`logMiddleware` and `cacheMiddleware` log with a `[liaise]` prefix.**
21
+ `logMiddleware` writes `[liaise] → GET getItems /api/items` and
22
+ `[liaise] ← getItems OK (142ms)` where it wrote `[apify] …`;
23
+ `cacheMiddleware({ debug: true })` writes `[liaise cache] HIT` / `MISS` where it
24
+ wrote `[apify cache] …`. Anything that filters or parses those lines needs the
25
+ new prefix.
26
+
27
+ ## [4.4.3] — 2026-10-03
28
+
29
+ ### Fixed
30
+
31
+ - **`share` and `cacheMiddleware` no longer hand one caller another caller's
32
+ response when params carry a `Date`, `Map`, `Set`, `ArrayBuffer`, BigInt or
33
+ a class instance with private state below the top level.** The key that
34
+ decides whether two calls are the same request fell through to
35
+ `Object.keys()` for any object, which is empty for all of those, so two
36
+ different requests keyed as `{}` — or, for a BigInt anywhere, as one shared
37
+ sentinel. 2.2.1 had closed this at the top level only. The key is now built
38
+ by content at every depth: anything with `toJSON` by what it returns (a
39
+ `Date` keys as its ISO string), `Map`, `Set` and typed arrays by their
40
+ entries, and `undefined` members dropped as `JSON.stringify` drops them. A
41
+ value that cannot be keyed soundly — a BigInt, an `ArrayBuffer`, `Blob`,
42
+ `FormData` or `URLSearchParams`, a circular structure, or an object with no
43
+ enumerable state — now declines at any depth: that call is neither shared
44
+ nor cached. Two consequences you can observe: calls that were wrongly
45
+ coalesced or cached together now go out separately, and `{ a: undefined }`
46
+ and `{}` now share one key. A top-level `Map`, `Set` or `Date` param, which
47
+ was never shared or cached before, now is, by content. The GraphQL client is
48
+ covered through `cacheMiddleware` on its variables. See
49
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-443).
50
+
51
+ ### Internal
52
+
53
+ - `stableStringify` and `isOpaqueParams` are replaced by one function,
54
+ `stableKey` (`src/utils/stable-key.ts`), the single source of truth for
55
+ what `share` and `cacheMiddleware` treat as the same request. Not exported.
56
+
57
+ ## [4.4.2] — 2026-09-23
58
+
59
+ ### Fixed
60
+
61
+ - **`timeout` and a caller's `signal` now settle a call whose middleware hangs.**
62
+ `timeout` is documented as covering the entire middleware chain, but it only
63
+ took effect once a middleware called `next()` and the request reached `fetch`.
64
+ A middleware awaiting something that never settled — a stalled auth-token
65
+ refresh is the realistic case — left the call pending forever: no `Result`, no
66
+ `onError`, no timeout. Aborting `CallOptions.signal` did not help either. Both
67
+ clients were affected, and every entry point: unshared, `dedupe: true`, and
68
+ `share: true` — where a hung shared chain also kept its slot forever, and a
69
+ sharer's longer per-call `timeout` outlasted the shorter `RequestConfig.timeout`
70
+ it is documented to be bounded by.
71
+
72
+ Now, once the operation's own signal aborts, the chain gets one macrotask to
73
+ answer by itself; if it has not, the call settles with the same `Result` an
74
+ aborted `fetch` produces — `kind: 'timeout'` (reported to `onError` once) or
75
+ `kind: 'abort'` (not reported), `status: 0`, classified by provenance as
76
+ before. The grace period is what keeps every chain that *does* respond to the
77
+ abort — `fetch` rejecting, a middleware rethrowing the reason, a fallback
78
+ middleware serving a cached response — on exactly the `Result` it produced
79
+ before.
80
+
81
+ The stalled middleware keeps running, since a promise cannot be cancelled. What
82
+ it later returns or throws is discarded, and a `next()` it calls after the call
83
+ has settled sends no request and registers nothing with `dedupe` — it returns
84
+ the `Result` the caller already has. Under `dedupe: true`, a newer call
85
+ superseding one parked in response-side middleware settles it as `'abort'`,
86
+ and a request still in flight when the backstop settles the call is aborted
87
+ rather than left running with nothing able to cancel it.
88
+
89
+ The same rule applies to anything else the signal does not reach: slow
90
+ response-side middleware, an async schema validator, or a `fetch` that
91
+ ignores its signal can no longer deliver a result after the deadline. See
92
+ MIGRATION.md.
93
+
94
+ No timer is armed for a call whose signal never aborts, no listener outlives
95
+ the call, and the post-execution hook runs at the same microtask as before —
96
+ share-site reporting depends on that ordering.
97
+
98
+ - **`result.retry()` called with arguments no longer drops the caller's own
99
+ `signal` and `timeout`.** `retry` was the internal `execute` function itself,
100
+ so `[r].map(r.retry)` or `retry({})` delivered the argument into a parameter
101
+ reserved for the share tracker's signal — which marks the run as shared, and a
102
+ shared run's budget deliberately excludes the caller's own signal and per-call
103
+ timeout. `retry` now takes no arguments and ignores any it is given.
104
+
105
+ - **`mockFetch` (`./testing`) honours `init.signal`.** It never looked at the
106
+ signal, so a stalled route could not be aborted and a consumer could not test
107
+ their own timeout or cancellation handling through the stub. It now rejects
108
+ with `signal.reason`, as real `fetch` does — for a signal already aborted and
109
+ for one that aborts while a handler is pending. An aborted call is still
110
+ recorded and counted, and does not use up a response from a sequence.
111
+
112
+ ### Documentation
113
+
114
+ - **The README's "Sharing → Known limitation" paragraph is gone.** It said a
115
+ signal-replacing middleware was not re-merged with the share refcount; it has
116
+ been, and a test pins it. The paragraph now says so.
117
+
118
+ - **`ctx.request.signal` is described accurately.** The README said it holds
119
+ "whatever the caller passed as `options.signal`"; it holds the caller's signal
120
+ merged with any `timeout`, and under `share` with the refcount.
121
+
122
+ ## [4.4.1] — 2026-09-19
123
+
124
+ ### Fixed
125
+
126
+ - **`error.request.url` now reports the substituted URL when a fragment is
127
+ refused.** It used to report the raw path template — `'/users/:id#f'` rather
128
+ than `'/users/42#f'` — because `buildUrl` refused the fragment before
129
+ substitution ran. This was 4.4.0's documented "Known gap"; it is the same
130
+ `:id`-reaching-telemetry defect 4.0.2 removed from the middleware path,
131
+ surviving on the one error path that could still produce it.
132
+
133
+ The fragment is now detected where it always was and thrown after
134
+ substitution. Detecting early preserves precedence — a fragment is wrong for
135
+ every call, an unfilled `:token` only for this one — so a config broken both
136
+ ways still reports the fragment first, exactly as before.
137
+
138
+ The error *message* is unchanged and still names the original `path` or
139
+ `baseUrl`. The two fields answer different questions: the message says what to
140
+ edit, the URL says what was called.
141
+
142
+ ### Unchanged, now pinned
143
+
144
+ - **A `#` inside a param value is data, not a fragment.** `encodeURIComponent`
145
+ escapes it to `%23`, so `{ id: 'a#b' }` sends `/users/a%23b` and is not
146
+ refused. This was the question 4.2.1 left open; refusing it would have broken
147
+ legitimate params. Now covered by a test.
148
+
149
+ ## [4.4.0] — 2026-09-19
150
+
151
+ ### Added
152
+
153
+ - **A fragment in a `defineRequest` path literal is now a compile error.**
154
+ 4.2.1 made the runtime refuse a `#` in a `path` or `baseUrl`, because `fetch`
155
+ never transmits a fragment. That left the type accepting what the runtime
156
+ rejects — `defineRequest()({ path: '/docs#section' })` compiled, and the
157
+ endpoint then failed on every call.
158
+
159
+ ```ts
160
+ defineRequest<Doc>()({ method: 'GET', path: '/docs#section' })
161
+ // ^ Property '__fragmentInPath' is missing:
162
+ // a URL fragment is never sent to the server
163
+ ```
164
+
165
+ The guard **fires only on a literal**. A path assembled at runtime, a
166
+ `RequestConfig`-typed variable, and a spread of one all still compile and are
167
+ caught by the runtime error instead — the same permissiveness constraint that
168
+ sank the 3.1.0 `responseType: 'none'` guard attempt. `new Request` has no
169
+ equivalent check because it takes no path literal.
170
+
171
+ **This can turn a previously-compiling build red.** See
172
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-440) — the rejected code was
173
+ already failing at runtime on every call.
174
+
175
+ ### Fixed
176
+
177
+ - **`joinUrl` composes a URL fragment structurally, like it already does the
178
+ query string.** `buildUrl` refuses a fragment, which sends `urlForError` down
179
+ its `joinUrl` fallback — and that fallback still concatenated, leaving the
180
+ base's fragment mid-string. `'https://api.test/v1#f'` joined to `'/items'`
181
+ produced `'.../v1#f/items'`, which parses to pathname `/v1`: the reported URL
182
+ named an endpoint the call was never for. It now produces
183
+ `'https://api.test/v1/items#f'`.
184
+
185
+ Diagnostic only — `error.request.url`, never a URL that reaches the network,
186
+ since the request is refused either way. But it is the same class of mangled
187
+ output 4.2.1 removed from the request path, and a report that resolves
188
+ somewhere else is worse than no report.
189
+
190
+ The fragment splits **before** the query, and the two are not interchangeable:
191
+ RFC 3986 orders a URL `path?query#fragment`, so a `?` after a `#` belongs to
192
+ the fragment. Splitting the query first read `#f?x=1` as a query string that
193
+ is not one and re-emitted it as a real one.
194
+
195
+ ### Known gap
196
+
197
+ - For a path template **with params**, `error.request.url` on the fragment
198
+ failure still reports the raw `:id` template rather than the substituted
199
+ value, because `buildUrl` throws before substitution runs. Pinned by a test.
200
+ Closing it means letting the fragment check run after substitution, which
201
+ changes `buildUrl`'s shape rather than `joinUrl`'s. **Closed in 4.4.1.**
202
+
203
+ ## [4.3.0] — 2026-09-19
204
+
205
+ ### Added
206
+
207
+ - **`paginate()` — walk a paginated endpoint as an async iterator.** Yields one
208
+ `Result` per page, so the shape is the same one every other entry point
209
+ returns.
210
+
211
+ ```ts
212
+ for await (const page of paginate(api.listItems, { limit: 50 }, {
213
+ next: (p, prev) => p.data.cursor ? { ...prev, cursor: p.data.cursor } : undefined,
214
+ })) {
215
+ if (page.error) break
216
+ render(page.data.items)
217
+ }
218
+ ```
219
+
220
+ `next` returns the **next params**, not a cursor. Returning a cursor would
221
+ leave this library deciding where to put it — `cursor`, `page_token`,
222
+ `after` — and a config option per API in existence is the outcome
223
+ `buildUrl`'s refusal to guess a nested-query-string format already rejected.
224
+ The previous params arrive as the second argument, so cursor, offset and
225
+ `Link`-header paging are all the same spread.
226
+
227
+ An error page is yielded and ends the walk: there is no data to read the next
228
+ cursor from. `maxPages` is available and has no default, because a silent
229
+ truncation at an invented ceiling is indistinguishable from reaching the last
230
+ page. Any `CallOptions` apply to every request, so one signal cancels the
231
+ crawl.
232
+
233
+ A `next` that throws propagates to the caller rather than becoming a
234
+ `Result` — it runs inside their own `for await`, and a `Result` would need an
235
+ error kind that fits nothing while hiding the stack that identifies the bug.
236
+
237
+ Standalone, not a method on generated endpoints: `createApi` and its types are
238
+ unchanged, and the import costs nothing to anyone who does not use it.
239
+
240
+ ## [4.2.1] — 2026-09-19
241
+
242
+ Two URL-composition fixes. Both produced strings that looked plausible and
243
+ resolved to the wrong request, so neither was visible without inspecting what
244
+ the network layer actually parsed.
245
+
246
+ ### Fixed
247
+
248
+ - **A `baseUrl` carrying a query string no longer swallows the path.**
249
+ `baseUrl: 'https://api.test/v1?key=abc'` with `path: '/items'` built
250
+ `https://api.test/v1?key=abc/items` — which resolves to path `/v1`, so the
251
+ request went to a different endpoint entirely, silently. The path is now
252
+ joined onto the base path and the query strings are merged, base params first:
253
+ `https://api.test/v1/items?key=abc&page=2`.
254
+
255
+ - **A URL fragment in a `path` or `baseUrl` is now refused.** A fragment is
256
+ never transmitted, so one in a request URL could not do what it appeared to —
257
+ and it silently discarded the query string: `'/docs#section'` with
258
+ `{ page: 2 }` built `'/docs#section?page=2'`, which the network layer reads as
259
+ path `/docs` with no query at all. `page=2` never left the client.
260
+
261
+ It is refused rather than stripped, because stripping hides the mistake and
262
+ leaves a line of code that does nothing. The failure is a `Result`, not a
263
+ throw. See [MIGRATION.md](./MIGRATION.md) — this is the one change that needs
264
+ action, and only if a `#` appears in one of your templates.
265
+
266
+ ## [4.2.0] — 2026-09-18
267
+
268
+ ### Added
269
+
270
+ - **Optional response validation against a Standard Schema validator.** Pass
271
+ `schema` on a request or a GraphQL operation and the successful response is
272
+ validated before it reaches you. Zod, Valibot and ArkType all implement the
273
+ interface; apify takes no dependency on any of them, because Standard Schema
274
+ is an interface rather than a package.
275
+
276
+ On the REST side the schema also **supplies the response type**, so
277
+ `defineRequest()({ method, path, schema })` needs no type argument at all —
278
+ which is what the curried factory added in 4.1.0 was for. Passing both a
279
+ schema and an explicit response type is a compile error, even when the two
280
+ agree: the failure that guards against is the schema changing later while the
281
+ explicit type quietly does not.
282
+
283
+ `data` is the schema's **output**, so transforms, coercions and defaults
284
+ apply — `z.coerce.date()` gives you a `Date`. This means `data` is no longer
285
+ byte-identical to the response body when a schema transforms.
286
+
287
+ A refusal is a `kind: 'parse'` error with the validator's issues in
288
+ `error.body` and the response's own status, matching every other parse error:
289
+ the server answered, we could not accept the answer. A validator that throws
290
+ rather than returning issues is reported the same way.
291
+
292
+ Only the success body is validated; a non-2xx body is left alone. On the
293
+ GraphQL client the response type stays explicit — only `defineRequest` infers.
294
+
295
+ ## [4.1.1] — 2026-09-18
296
+
297
+ ### Fixed
298
+
299
+ - **`defineRequest` now infers a path parameter that starts the path.** A `path`
300
+ of `':id'` or `':id/foo'` — the token at the very start of the string —
301
+ inferred no parameters at all, while the request still required one and failed
302
+ at runtime with `Unresolved path parameter :id`.
303
+
304
+ 4.1.0 anchored the type-level parser to a preceding `/`, mirroring the rule
305
+ that stops a colon *inside* a segment (`/v1/documents:batchGet`, `/events/at/12:30`)
306
+ being read as a parameter. But the runtime anchors to the start of each
307
+ `/`-separated segment, and the first segment begins at the start of the string
308
+ whether or not a slash precedes it — so a leading token was a parameter to the
309
+ request and not to the type.
310
+
311
+ A missing leading slash is now normalised before anchoring, making the type's
312
+ rule exactly equivalent to the runtime's. Paths that begin with `/` — every one
313
+ in this project's documentation and tests — are unaffected, as is a path like
314
+ `'users/:id'`, whose token was already preceded by a slash.
315
+
316
+ ## [4.1.0] — 2026-09-18
317
+
318
+ ### Added
319
+
320
+ - **`defineRequest()` — path parameters are now inferred from the `path`
321
+ literal.** `defineRequest<User>()({ method: 'GET', path: '/users/:id' })`
322
+ produces an endpoint whose params are `{ id: string | number }`, so calling it
323
+ with the wrong key is a compile error instead of a runtime throw from
324
+ `buildUrl`. Params the path does not name go in a second type argument:
325
+ `defineRequest<Repo[], { page?: number }>()`.
326
+
327
+ The two calls are load-bearing. TypeScript has no partial type-argument
328
+ inference, so if the response type and the config were arguments to one call,
329
+ supplying the response type explicitly would stop the path from being inferred,
330
+ and the checking would quietly do nothing. Splitting them keeps the response
331
+ type explicit and the path inferred.
332
+
333
+ It also enforces `responseType: 'none'`, which `new Request` could only
334
+ document — a guard attempted in 3.1.0 and dropped, because an overload pair
335
+ falls through to the general signature and so rejects nothing.
336
+
337
+ Purely additive. `new Request(...)` is unchanged and not deprecated, and
338
+ `createApi` is untouched — `defineRequest` returns an ordinary `Request`.
339
+
340
+ ### Fixed
341
+
342
+ - **Query params are appended with `&` when the URL already carries a query
343
+ string.** A `path` template with its own query string — `'/search/:q?x=1'` —
344
+ previously produced a second `?`: `/search/hi?x=1?page=2`. Path substitution
345
+ was always correct; only the append assumed no `?` was present yet.
346
+
347
+ A `baseUrl` carrying a query string is a separate and wider problem in
348
+ `joinUrl`, which appends the path *after* the query, and is not addressed
349
+ here.
350
+
351
+ ## [4.0.2] — 2026-09-17
352
+
353
+ One fix, completing 4.0.1's work. No public API change, and no behavioural
354
+ change to any successful call.
355
+
356
+ ### Fixed
357
+
358
+ - **`error.request.url` now reports the resolved, path-substituted URL on every
359
+ error path that can name one.** Three paths previously carried the raw route
360
+ template (`/users/:id`): a `share: true` caller giving up, `execute()`'s
361
+ setup-error catch, and the share path's own setup catch. Grouping telemetry
362
+ by that field produced two shapes for the same endpoint. 4.0.1 fixed the
363
+ `'middleware'` paths; this completes the set.
364
+
365
+ **Ordinary aborts were already correct** and are unchanged — a caller
366
+ cancelling mid-flight, a dedupe supersede, and a middleware rethrowing the
367
+ signal reason are all reclassified from the signal that cancelled them, and
368
+ none of them reaches the default this release changes.
369
+
370
+ The template still appears in the one case where it is the only honest
371
+ answer: `buildUrl` itself threw, so no URL was ever resolved. An unresolved
372
+ `:token` and a nested object reaching a query string both land there.
373
+
374
+ Consumers asserting on the template string in their own tests will see a
375
+ change. The value was wrong, and this is the same class of correction 4.0.1
376
+ shipped as a patch.
377
+
378
+ ## [4.0.1] — 2026-09-16
379
+
380
+ Three consistency fixes. No behavioural change to any successful call, and no
381
+ public API change — each fix replaces a wrong value with the right one.
382
+
383
+ ### Fixed
384
+
385
+ - **`error.request.url` now reports the path-substituted URL on `'middleware'`
386
+ failures.** It previously carried the raw route template (`/users/:id`) on
387
+ that path while every other error path — `'http'`, `'parse'`, and network
388
+ errors — reported the real address, so grouping telemetry by that field
389
+ produced two shapes for the same endpoint. It remains the template for a
390
+ `share: true` caller giving up — the only give-up path that reaches this
391
+ code — and on the setup-error path: the resolved URL is built inside
392
+ `execute()`, and both of those sites run in the outer closure, where it
393
+ isn't in scope. Ordinary aborts — a caller cancelling mid-flight, a
394
+ dedupe supersede — already reported the resolved URL, and still do.
395
+ - **A GraphQL `{ errors }` response now reports the response's own status.**
396
+ It previously hardcoded `200`, so a GraphQL error arriving on any other 2xx
397
+ reported a status the server never sent. `statusText` deliberately remains
398
+ `'GraphQL Error'` — with `kind` reporting `'http'` for both GraphQL and HTTP
399
+ failures, it is the only thing distinguishing them.
400
+
401
+ ### Internal
402
+
403
+ - Coverage for a GraphQL response carrying an empty `errors` array, which
404
+ falls past the errors branch into 4.0.0's no-data rule and reports `'parse'`.
405
+ Correct since 4.0.0; previously unpinned.
406
+ - Two stale comments corrected to match behaviour already fixed: `syntheticResult`'s
407
+ doc in `create-api.ts` (wrongly claimed `'middleware'` results still get the
408
+ route template) and `GraphQLBaseConfig.onError`'s doc in `types.ts` (wrongly
409
+ claimed GraphQL errors arrive only on HTTP 200). No behaviour changed.
410
+
411
+ ## [4.0.0] — 2026-09-16
412
+
413
+ One rule: a success must carry data. Both clients now report a 2xx response
414
+ that carries none as a `kind: 'parse'` error rather than resolving with
415
+ `data: null`. See [MIGRATION.md](./MIGRATION.md#upgrading-to-400) — if you
416
+ declared `responseType: 'none'` on the endpoints 3.1.0's warning named, this
417
+ release is a no-op for you.
418
+
419
+ ### Changed
420
+
421
+ - **BREAKING: an empty body under `responseType: 'json'` is a `kind: 'parse'`
422
+ error.** Previously it resolved as a success with `data: null`, at every
423
+ status including `204`. The error carries the response's own status (a `204`
424
+ reports `204`, not `0`), keeps the `Response`, and puts the raw body text —
425
+ `''` — in `error.body`. This is what makes `SuccessResult.data: TResponse`
426
+ true rather than documented-as-false: 3.0.0 removed the `| null` that had
427
+ been forcing consumers to check, so `data.deleted` against a `204` compiled
428
+ clean and threw at runtime. Declare `responseType: 'none'` (available since
429
+ 3.1.0) on endpoints that answer with no body. There is no `204` special
430
+ case, and a literal `null` body — valid JSON — still succeeds.
431
+ - **BREAKING: a GraphQL 2xx response carrying neither `data` nor `errors` is a
432
+ `kind: 'parse'` error.** Covers an empty body, `{}`, a literal
433
+ `{"data": null}`, and a non-object JSON root. `error.body` is the raw
434
+ response text. GraphQL *errors* are unchanged: `{"data": null, "errors":
435
+ [...]}` still reports `kind: 'http'`, with any partial result in
436
+ `partialData`, since that branch runs first and is the spec-compliant shape
437
+ for a field error.
438
+
439
+ ### Removed
440
+
441
+ - **The one-time empty-body `console.warn` from 3.1.0.** Its success condition
442
+ no longer exists. `responseType: 'none'`, which it pointed at, is permanent.
443
+
444
+ ### Unchanged
445
+
446
+ - **Non-2xx responses.** Both clients still read and parse an error body for
447
+ `error.body`, on `'none'` as on `'json'`, and an empty error body is still a
448
+ `'http'` error — not a `'parse'` one. 4.0.0 changes what a *success* means
449
+ and nothing else.
450
+ - **Public types.** `ResponseType` already had `'none'` and `ApiErrorKind`
451
+ already had `'parse'`; no type was added, removed, or changed.
452
+
453
+ ## [3.1.0] — 2026-09-16
454
+
455
+ Additive: a `responseType` for endpoints that answer with no body, and a
456
+ diagnostic warning for the empty-body gap it closes. No existing behaviour
457
+ changes; see [MIGRATION.md](./MIGRATION.md#upgrading-to-310).
458
+
459
+ ### Added
460
+
461
+ - **`responseType: 'none'`** — declares that an endpoint returns no body on
462
+ success. `data` is `undefined`, no body is read, and any body a successful
463
+ (2xx) response sends anyway is discarded (its stream is cancelled, so a
464
+ keep-alive connection is released). This is the accurate declaration for a
465
+ `204` endpoint, most commonly a `DELETE`. Declare `TResponse` as
466
+ `undefined` alongside it — but this is a convention, not a compile-time
467
+ guarantee: `new Request<P, User>({ responseType: 'none' })` compiles
468
+ clean, since TypeScript cannot infer a literal `responseType` on the
469
+ current non-generic constructor to enforce the pairing.
470
+ Compile-time enforcement is not shipped; 4.0.0 did not add it either, since
471
+ a generic factory would be purely additive and needs no major-version gate.
472
+ A non-2xx response is unaffected: its body is
473
+ still read and parsed as JSON for `error.body`, since `'none'` describes
474
+ the success shape only and an error body remains diagnostic. See
475
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-310).
476
+ - **A one-time warning when a `'json'` request receives an empty body.** The
477
+ call still resolves as a success with `data: null`, unchanged from every
478
+ prior release — only a `console.warn` is new, fired once per request name
479
+ per `createApi` instance, naming the request and pointing at
480
+ `responseType: 'none'` as the fix. This is transitional: 4.0.0 turns the
481
+ same case into a `kind: 'parse'` error, and declaring `'none'` now makes
482
+ that upgrade a no-op.
483
+
484
+ ### Internal
485
+
486
+ - **Test coverage added for the shared-signal re-merge under `share: true`
487
+ combined with signal-replacing middleware.** The behaviour — a middleware
488
+ that installs its own `ctx.request.signal` still has that signal re-merged
489
+ with the share refcount controller — shipped in 3.0.0; this release adds
490
+ the test that pins it, not a behaviour change.
491
+
492
+ ## [3.0.0] — 2026-09-16
493
+
494
+ Tightens contracts the types always implied but never enforced — a
495
+ discriminated `Result`, non-interchangeable `Request` generics, a required
496
+ `ApiError.kind` — plus corrected error classification for parse failures and
497
+ aborts, and preserved GraphQL partial data. Nine breaking changes; see
498
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-300) for upgrade instructions and
499
+ worked before/after examples for every one of them.
500
+
501
+ ### Added
502
+
503
+ - **`ApiError.partialData`** — GraphQL partial-success data (a nullable field
504
+ errored while the rest of the query resolved) is preserved instead of
505
+ discarded. It lives on `error.partialData`, not `Result.data`, so the
506
+ `Result` union's narrowing (see Changed) stays intact: a non-null `error`
507
+ means `data` is null, and a null `error` means the call succeeded (see
508
+ MIGRATION.md's empty-body caveat for the one case where `data` is null
509
+ too).
510
+ - **`SuccessResult<T>` and `ErrorResult<T>`** exported as types — the two
511
+ branches of the `Result<T>` union.
512
+
513
+ ### Changed
514
+
515
+ - **BREAKING: `Result<T>` is now a discriminated union**,
516
+ `SuccessResult<TResponse> | ErrorResult<TResponse>`, not an interface with
517
+ independently-nullable fields. `if (error) return` now narrows `data` to
518
+ `TResponse` — `data` was never actually narrowed before, so the README's own
519
+ headline example (`console.log(data.name)` with no assertion, right after
520
+ checking `error`) has **not** compiled since 2.0.0 without a `data!`
521
+ assertion or a redundant null check at every call site. Middleware that
522
+ synthesises a success `Result` must supply a non-null `Response`. See
523
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
524
+ - **BREAKING: `Request<TParams, TResponse>` generics are no longer
525
+ interchangeable.** Phantom fields make the class's own generics
526
+ load-bearing, so `Request<{ id }, User>` no longer silently accepts a
527
+ `Request<{ slug }, Post>` wherever one is expected. Code relying on the old
528
+ (always-incorrect) assignability now fails to compile. See
529
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
530
+ - **BREAKING: `ApiError.kind` is required, and `ApiErrorKind` gained
531
+ `'middleware'`.** Every construction site inside the library already set
532
+ it; this tightens the type to match. Custom middleware constructing an
533
+ `ApiError` must now supply `kind`, and an exhaustive `switch (error.kind)`
534
+ needs a new arm. See [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
535
+ - **BREAKING: A 2xx response with an unparseable body now reports the real
536
+ `status`, a non-null `response`, and `kind: 'parse'`** — previously
537
+ `status: 0`, `response: null`, `kind: 'network'`, indistinguishable from
538
+ being offline. Non-2xx responses are unaffected: `!response.ok` is checked
539
+ before the body is parsed, so a 5xx with an unparseable body still reports
540
+ `kind: 'http'`, and `retryMiddleware`'s default 5xx retry behaviour has not
541
+ changed. See [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
542
+ - **BREAKING: A throwing middleware now returns a `Result` with
543
+ `kind: 'middleware'` instead of rejecting.** `composeMiddleware` has no
544
+ guard against a middleware throwing, so this broke the library's
545
+ "never throws" contract on the one path most likely to have a bug — your
546
+ own middleware. A `try`/`catch` placed around an API call to catch this can
547
+ be deleted. See [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
548
+ - **BREAKING: `onError` no longer fires for `error.kind === 'abort'`.** A
549
+ cancellation the library caused deliberately — your own `AbortSignal`
550
+ firing, or a `dedupe` supersede — is no longer reported as an error;
551
+ `'timeout'` still fires, since a missed deadline is a genuine failure.
552
+ Hand-rolled `AbortError` filtering in an `onError` handler can be deleted.
553
+ See [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
554
+ - **BREAKING: Aborts are classified by signal provenance, not by the thrown
555
+ reason's name.** A caller's custom abort reason
556
+ (`controller.abort(new Error(...))`, or a string) is now `kind: 'abort'`
557
+ instead of `'network'`, and so is silent instead of reported. A middleware
558
+ propagating the library's own abort reason — verbatim, or wrapped one level
559
+ as `.cause` (the shape `node:timers/promises` and most abortable helpers
560
+ produce) — is now `'abort'`/`'timeout'` and silent, instead of
561
+ `'middleware'` and reported. A middleware throwing its own, unrelated
562
+ `AbortError`-named failure now correctly reports as `'middleware'`, instead
563
+ of being silently swallowed as `'abort'`. See
564
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
565
+ - **BREAKING: Cancelling during the response body download — for both 2xx
566
+ and non-2xx responses — is now classified as the cancellation**
567
+ (`kind: 'abort'`/`'timeout'`, `status: 0`, `response: null`), not by
568
+ whichever HTTP stage it happened to interrupt (previously `kind: 'parse'`/
569
+ `status: 200` for a 2xx, or `kind: 'http'`/the real status/`body: null` for
570
+ a non-2xx — both reported). `retryMiddleware`'s default `retryOn` (and any
571
+ custom `status >= 500` predicate) no longer retries a cancellation caught
572
+ in this window, since `status` is now `0` — strictly correct, but
573
+ observably fewer requests. See
574
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-300).
575
+
576
+ ### Fixed
577
+
578
+ - **Abort/timeout classification no longer hangs or crashes on a hostile
579
+ abort reason.** A caller-supplied `signal.reason` (or a value a middleware
580
+ throws) is arbitrary — a revoked `Proxy`, a reactive-framework wrapper, or
581
+ a class with a lazy `get name()`/`get cause()` can throw on property
582
+ access. Reading `.name` (to detect `AbortError`/`TimeoutError`) or `.cause`
583
+ (to detect a wrapped propagated reason) is now guarded; a throwing getter
584
+ is treated as "doesn't match" instead of escaping the last-resort handler
585
+ that exists specifically to keep the library's "never throws" contract
586
+ intact. Previously this could leave a `share: true` caller's promise
587
+ permanently pending, or reject an unshared call outright.
588
+ - **A shared (`share: true`) request whose signal a middleware replaces is
589
+ still cancelled when every sharer gives up.** A middleware that installs
590
+ its own `ctx.request.signal` (a deadline, a circuit breaker) used to drop
591
+ the shared refcounted signal entirely — every sharer releasing no longer
592
+ aborted the real request, so the socket stayed open with nobody waiting on
593
+ it, and with `retryMiddleware` it kept retrying in the background after
594
+ every caller had already resolved. The shared signal is now re-merged in
595
+ whenever a middleware replaces it, the same way dedupe's registration
596
+ already had to.
597
+ - **`result.retry()` no longer rejects when called with an unexpected call
598
+ shape.** `retry` is handed out directly as a plain function, so
599
+ `arr.map(result.retry)` (which passes the array index as a second
600
+ argument) or `result.retry(undefined, 0)` threw a `TypeError` out of the
601
+ one path that must always produce a `Result`. All call shapes now return a
602
+ `Result`.
603
+ - **A shared (`share: true`) call no longer re-reports a give-up that lands
604
+ after the operation has already settled.** The realistic trigger is a
605
+ consumer's `onError` handler reacting to a shared failure by aborting
606
+ another of its own still-outstanding callers with a hand-crafted
607
+ `TimeoutError`-shaped reason (`ac.abort(new DOMException('t',
608
+ 'TimeoutError'))`) — to give up on the rest of a batch, say. That caller's
609
+ own give-up listener was technically still armed even though the operation
610
+ already had its `Result`, and would otherwise report a second, misleading
611
+ failure for an operation that already reported once. (A plain
612
+ `AbortError`-shaped give-up doesn't need this fix to avoid a double report —
613
+ `onError` never fires for `error.kind === 'abort'` at all — so the fix
614
+ matters specifically for a give-up whose reason survives that filter.)
615
+
616
+ ## [2.2.1] — 2026-09-14
617
+
618
+ Five fixes closing findings that were identified and deliberately parked
619
+ during 2.2.0's final review (see "Known, recorded, not fixed" in that
620
+ release's notes). No public API change.
621
+
622
+ ### Fixed
623
+
624
+ - **`cacheMiddleware` no longer skips caching string-param endpoints — a
625
+ behavioural regression introduced by 2.2.0.** The special-body guard added
626
+ that release (for the pre-existing `FormData`/`Blob`/`ArrayBuffer`/
627
+ `URLSearchParams` cache-key collapse) reused `isSpecialBody`, which also
628
+ excludes a raw `string`. But `stableStringify` keys a string correctly —
629
+ unlike those four object types, which all collapse to the literal `"{}"` —
630
+ so excluding it was never necessary and silently stopped caching any
631
+ string-param endpoint. **If your string-param endpoints stopped being
632
+ cached after upgrading to 2.2.0, this restores it.** A new predicate,
633
+ `isOpaqueParams`, narrows the guard to the object types whose own
634
+ enumerable keys don't distinguish two different instances, and is used by
635
+ both `cacheMiddleware` and `share`'s coalescing gate; a string-param
636
+ endpoint under `share: true` is now soundly coalesced too. `isSpecialBody`
637
+ itself is unchanged and still used for body serialization, where a raw
638
+ string legitimately needs the same treatment. **`isOpaqueParams` also now
639
+ recognises `Date`, `Map`, and `Set`** (in addition to `FormData`, `Blob`,
640
+ `ArrayBuffer`, `URLSearchParams`) — the identical collapse-to-`"{}"` shape,
641
+ closed as one class rather than left as a known gap for three of the seven.
642
+ A `Date`/`Map`/`Set`-param endpoint is now correctly excluded from caching
643
+ and coalescing instead of risking one caller's response being served to
644
+ another's different payload.
645
+ - **A shared call under `share: true` no longer reports to `onError` more (or
646
+ fewer) times than the identical non-shared call would.** A sharer that
647
+ gives up reports its own failure directly — correct when it isn't the last
648
+ reference, since the shared request keeps running and nothing else would
649
+ ever report that give-up. But when it *is* the last reference, releasing
650
+ also aborts the shared request, and the shared operation *usually* then
651
+ reports that same failure again through its own, normal post-execution
652
+ hook — doubling it. `ShareTracker.release()` now reports whether its
653
+ release was the one that aborted the shared request, and the per-caller
654
+ path reports only when it was not — **except** when the shared operation's
655
+ own hook would never report at all: if the shared middleware chain rejects
656
+ instead of resolving (a middleware that throws on abort — a token-fetching
657
+ auth middleware is the realistic case), or short-circuits to a *success*
658
+ regardless of the abort (a `cacheMiddleware` hit, which ignores the
659
+ signal). Both used to mean the cancellation vanished from `onError`
660
+ entirely — worse than the duplicate this fix removes — so the last-release
661
+ path now watches what the shared operation actually does and reports
662
+ itself whenever the delegate didn't (and won't). A related "cross-kind"
663
+ duplicate — a caller that already gave up still had a live rejection
664
+ handler on the shared promise, which built and reported a *second*,
665
+ differently-kinded failure when the shared operation later rejected, even
666
+ though the Result it built was discarded — is fixed the same way: a caller
667
+ that has already finished no longer reports again.
668
+ - **`timeout: 0.5` (or any sub-millisecond value) no longer silently means "no
669
+ timeout".** `Math.floor` flooring a positive-but-fractional deadline to `0`
670
+ failed the "must be positive" check and left the request unbounded — the
671
+ opposite of the caller's intent. A resolved deadline greater than zero is
672
+ now clamped up to a 1ms minimum instead of down to nothing; `0`, negative,
673
+ `NaN`, and omitted still all mean "no timeout".
674
+ - **`retryMiddleware`'s `maxDelay: NaN` no longer collapses backoff to a tight
675
+ retry burst.** `Math.min(computed, maxDelay)` is `NaN` whenever `maxDelay`
676
+ is, and the existing backstop then clamped that `NaN` down to `0` — turning
677
+ the whole point of a backoff policy (bounding retries, not eliminating the
678
+ delay) inside out. `maxDelay` is now validated where it's resolved and
679
+ falls back to its default (`30_000`) when it is specifically `NaN`, before
680
+ it ever reaches the arithmetic; `baseDelay` gets the identical treatment,
681
+ for the identical reason (it poisons the same computation the same way).
682
+ **`maxDelay: Infinity` (and `baseDelay: Infinity`) are accepted, not
683
+ redirected to the default** — `Infinity` is the documented "no cap" idiom
684
+ (`Math.min(computed, Infinity)` is always `computed`), so only `NaN` is
685
+ guarded against, not "not finite" generally.
686
+
687
+ ## [2.2.0] — 2026-09-13
688
+
689
+ Four new capabilities — a whole-operation `timeout`, a real retry backoff
690
+ policy, request coalescing via `share`, and a framework-agnostic testing entry
691
+ point — plus an `ApiError.kind` discriminator. Additive for typical consumers,
692
+ with two behavioural changes existing callers will notice, called out under
693
+ Changed.
694
+
695
+ ### Added
696
+
697
+ - **`timeout`** on `RequestConfig`, `CallOptions`, and `OperationConfig` — a
698
+ whole-operation deadline, not a per-attempt budget. One signal covers the
699
+ entire middleware chain, including every retry and its backoff delay, so
700
+ `timeout: 5000` combined with `retryMiddleware(3)` still means "an answer
701
+ within 5 seconds" for the call as a whole. This deliberately differs from
702
+ axios, XHR and `got`, which apply a timeout per attempt; the README shows the
703
+ per-attempt recipe (a signal-replacing middleware placed inside the retry
704
+ middleware) for readers who want that instead. `result.retry()` always
705
+ starts a fresh budget. A timeout produces `status: 0`, `kind: 'timeout'`.
706
+ Non-positive or omitted disables it.
707
+ - **A real retry backoff policy.** `retryMiddleware` now accepts
708
+ `number | RetryOptions`: `max`, `delay` (`'exponential' | 'linear'` or a
709
+ custom function), `baseDelay`, `maxDelay`, `jitter` (full jitter, default
710
+ on), `respectRetryAfter` (honours a `Retry-After` response header, default
711
+ on), `retryOn` (default: retry 5xx only — 429 and network errors are
712
+ opt-in), and an observational `onRetry` hook. `retryMiddleware(3)` keeps
713
+ working exactly as before, as shorthand for `{ max: 3 }`.
714
+ - **`share: true`** on `RequestConfig` — coalesces identical concurrent calls
715
+ onto a single in-flight request. Sibling of `dedupe`, with the opposite
716
+ intent: dedupe cancels the older call, share joins the existing one. Setting
717
+ both on the same `Request` throws at `createApi(...)` time. A per-call
718
+ `signal` or `timeout` bounds only that caller, via a refcount, and never the
719
+ shared request itself; a per-call `headers` or `middleware`, or params that
720
+ are a special body type (`FormData`, `Blob`, `ArrayBuffer`,
721
+ `URLSearchParams`, a raw `string`), always get their own unshared request.
722
+ - **`@iremlopsum/apify/testing`** — a new, framework-agnostic entry point with
723
+ no test-runner dependency: `mockFetch` (a route-matching `fetch` stub keyed
724
+ by `"METHOD /path"`, with `:token` capture, call recording, and response
725
+ sequencing), `jsonResponse`, `successResult`, and `errorResult`.
726
+ - **`ApiError.kind`** — an optional discriminator:
727
+ `'http' | 'network' | 'abort' | 'timeout' | 'parse'`. Branch on this instead
728
+ of `status` to tell a timeout, a cancellation, and a genuine network failure
729
+ apart — all three carry `status: 0`. `'parse'` is reserved for a future
730
+ release and is not produced by this one.
731
+
732
+ ### Fixed
733
+
734
+ - **A non-integer or oversized `timeout` no longer breaks the request.**
735
+ `AbortSignal.timeout()` accepts only an integer in `[0, 2^31 - 1]`, so a
736
+ perfectly ordinary `budget / 3` or `Number(process.env.TIMEOUT)` threw a
737
+ `RangeError` during setup — the request was never sent, and the caller got a
738
+ `kind: 'network'` Result indistinguishable from being offline. Values are now
739
+ rounded down to whole milliseconds and clamped to the timer ceiling; `NaN`
740
+ and non-positive values still mean "no timeout". Applies to `createGraphQL`
741
+ too, which shares the helper.
742
+ - **`share: true` returns a `Result` from every exit.** The coalescing block
743
+ ran outside the request pipeline's `try`/`catch`, so a throw in it escaped as
744
+ a rejection; and a rejection from the shared operation was handed back *as
745
+ if it were a `Result`*, leaving `data` and `error` both `undefined` so
746
+ `if (error)` was false and the call looked like a success with no data. Both
747
+ now produce a proper network-error `Result`.
748
+ - **A throwing `onError` no longer rejects the caller.** `onError` fires after
749
+ the `Result` is in hand, so a misconfigured error reporter — or a logger
750
+ reaching for `error.response.status` where `response` is `null` — rejected a
751
+ promise that already held a perfectly good `Result`. It is now guarded on
752
+ both `createApi` and `createGraphQL`, matching the retry policy's `retryOn`,
753
+ `onRetry` and custom `delay` callbacks.
754
+ - **Under `share`, `RequestConfig.timeout` now bounds the shared request.** It
755
+ was applied per-caller, from each caller's join time, so a steady arrival of
756
+ joiners could hold one socket open indefinitely against the configured
757
+ deadline. The operation's deadline now bounds the one real request for
758
+ everyone, measured from when that request started; `CallOptions.timeout`
759
+ still bounds only the caller that passed it, which means a per-call
760
+ `timeout: 0` cannot lift the operation's own deadline.
761
+ - **A sharer's own timeout or abort now reaches `onError`**, as the identical
762
+ non-shared call always did.
763
+ - **`headers: {}` or `middleware: []` no longer disables coalescing.** The gate
764
+ tested truthiness rather than emptiness.
765
+ - **`cacheMiddleware` no longer collapses special-body params to one key.**
766
+ `FormData`, `Blob`, `ArrayBuffer` and `URLSearchParams` all stringify to
767
+ `"{}"` for keying purposes, so two different uploads through one cache served
768
+ each other's responses. Such calls are now neither cached nor served from
769
+ cache — the same stance `share` takes. Pre-existing (not new in 2.2.0), fixed
770
+ here because this release introduces the guard for the identical bug under
771
+ `share`.
772
+ - **A custom retry `delay` curve returning `NaN` or a negative no longer
773
+ reaches `setTimeout`**, where both mean "retry immediately" and turn a
774
+ backoff policy into a tight loop. A non-finite result falls back to the
775
+ exponential default; a negative is clamped to zero.
776
+ - **`mockFetch` rejects a route key with no method** (`'/users'`) at
777
+ construction, instead of registering a route that can never match.
778
+
779
+ ### Changed
780
+
781
+ - **Retries now back off instead of firing instantly.** Before this release,
782
+ `retryMiddleware(3)` made all four attempts in the same tick, with no delay
783
+ between them. It now waits out a real backoff (exponential by default, with
784
+ full jitter) between attempts, honouring a `Retry-After` response header
785
+ when the server sends one. Tests or timing assumptions that depended on the
786
+ old zero-delay retries will need `baseDelay: 0` (and `jitter: false`, and
787
+ possibly fake timers) to stay fast and deterministic.
788
+ - **Abort reasons now propagate through `dedupe`.** This is worth reading even
789
+ if you never touch the new `kind` field: `error.body` — the native
790
+ `Error`/`DOMException` the library has always put there for a network
791
+ error or abort — and its `.name` are a **pre-2.2.0 surface** that existing
792
+ consumers can already be reading. Previously, a `dedupe: true` request's
793
+ merged signal always aborted with a generic, reason-less `AbortError`,
794
+ discarding whatever reason the external signal actually carried (a
795
+ `TimeoutError` from a timeout-setting middleware, or a custom reason passed
796
+ to your own `AbortController.abort(reason)`). The merged signal now
797
+ preserves that original reason, so code reading `error.body.name` under
798
+ `dedupe: true` combined with a signal-setting middleware can see a different
799
+ value after upgrading — independent of whether it adopts `kind` at all.
800
+
801
+ ## [2.1.0] — 2026-09-13
802
+
803
+ Six audit fixes plus a package-size reduction. Non-breaking for consumers, with
804
+ one exception called out under Removed: the supported Node floor moves to 20.
805
+
806
+ ### Added
807
+
808
+ - `ctx.request.signal` on `MiddlewareContext` — middleware can now read the
809
+ `AbortSignal` handed to `fetch`, or replace it to impose its own cancellation
810
+ policy. A timeout middleware is four lines; see the README. Under
811
+ `dedupe: true` a replacement is merged into the dedupe signal rather than
812
+ discarded, so the request is cancelled by whichever fires first.
813
+ - `engines: { node: ">=20" }` in `package.json`, so the support floor is visible
814
+ to package managers rather than only to README readers.
815
+ - CI workflow — typecheck, unit tests, integration tests and build on push and
816
+ pull request, across Node 20, 22 and 24. Runs with a read-only token and
817
+ cancels superseded runs for the same ref.
818
+
819
+ ### Fixed
820
+
821
+ - **Dedupe no longer cancels the wrong request.** `clear()` deletes the map
822
+ entry only when it still owns it; previously a superseded request settling
823
+ late deleted the entry belonging to whichever newer request replaced it,
824
+ silently disabling dedupe from the second cancellation onward.
825
+ - **A cache hit no longer aborts a live request.** Dedupe registration moved
826
+ inside the core fetch, so a middleware that short-circuits above it never
827
+ registers — and so never cancels a request that is genuinely in flight.
828
+ - **An older request's retry no longer aborts a newer call.** Registration
829
+ happens once per call rather than once per attempt, which keeps
830
+ `retryMiddleware` composed with `dedupe: true` from inverting dedupe's
831
+ newest-wins contract.
832
+ - **A missing path param is now an error, not a malformed request.** An
833
+ unresolved `:token` used to ship literally in the URL *and* duplicate its
834
+ value as a query param. It now surfaces as a network-error `Result` naming
835
+ the offending token.
836
+ - **Repeated path tokens substitute.** `/orgs/:id/members/:id` fills both
837
+ occurrences; previously only the first was replaced.
838
+ - **`baseUrl` and `path` join with exactly one slash.** A trailing slash on
839
+ `baseUrl` — the shape `process.env.API_URL` usually has — produced `//`,
840
+ which some servers 404 on and which can trigger a cross-origin redirect that
841
+ drops the `Authorization` header. The synchronous error path reports the same
842
+ normalised URL.
843
+ - Unresolved-token detection no longer uses a regex lookbehind. An unsupported
844
+ regex literal is a parse-time `SyntaxError` that takes down the whole module,
845
+ which is the wrong failure mode for a library that advertises being
846
+ runtime-agnostic.
847
+
848
+ ### Changed
849
+
850
+ - **Package size roughly halved** — 241 kB → 113 kB unpacked, 65 kB → 32 kB
851
+ packed. JS and declaration emit are split into two `tsc` passes, so comments
852
+ are stripped from the shipped `.js` while JSDoc survives intact in the `.d.ts`
853
+ and editor hovers are unaffected. Broken source maps — they referenced
854
+ `../src/*.ts`, which is not published — are no longer emitted. Runtime cost is
855
+ unchanged: about 2 kB gzipped for a REST-only import.
856
+ - `sideEffects: false` declared, so webpack and Rollup tree-shake as
857
+ aggressively as esbuild already did.
858
+ - `build` cleans `dist/` first. Without it, building over a `dist/` from an
859
+ earlier version would publish stale source maps and orphaned modules.
860
+ - `MiddlewareContext['request'].signal` is optional (`signal?: AbortSignal`), so
861
+ consumers constructing a context by hand to unit-test their own middleware are
862
+ not forced to supply it.
863
+
864
+ ### Removed
865
+
866
+ - **Node 18 support.** The package now requires Node 20 or newer. Node 18 went
867
+ end-of-life in April 2025; CI tests 20, 22 and 24.
868
+ - Dead `eslint-disable` comments for a linter that is not installed.
869
+
870
+ ## [2.0.0] — 2026-05-09
871
+
872
+ ### Added
873
+
874
+ #### GraphQL client
875
+
876
+ - New `createGraphQL` factory with flat and split APIs — mirrors REST `createApi` DX with the same middleware pipeline, `onError`, `retry()`, and dedupe support
877
+ - `Operation<TVariables, TData>` class for typed GraphQL operations — parallel to `Request` for REST
878
+ - `gql` template-literal tag for syntax highlighting and no-op passthrough
879
+ - `GraphQLError` type, `GraphQLResponse<T>` wrapper, and full GraphQL types in `types.ts`
880
+ - All GraphQL exports (`createGraphQL`, `Operation`, `gql`) available from the core entry point (`.`)
881
+
882
+ #### Cache middleware
883
+
884
+ - New `cacheMiddleware` built-in — response caching with configurable TTL, LRU/LFU eviction, `clear()`, and optional debug logging
885
+ - `CacheStore` utility with `stableStringify` for deterministic cache-key generation; handles key escaping and `null`/`undefined` distinction correctly
886
+ - `CacheMiddleware` type exported from `./middleware` entry point
887
+ - Documented in README under Built-in Middleware
888
+
889
+ ### Changed
890
+
891
+ - `mergeHeaders` extracted to a shared utility (`src/utils/headers.ts`) — used by both REST and GraphQL pipelines
892
+ - README substantially expanded: new introduction, full table of contents, GraphQL client section, cache middleware section; clarified that GraphQL shares all REST DX features
893
+
894
+ ### Fixed
895
+
896
+ - `stableStringify` key escaping and `null`/`undefined` handling corrected
897
+ - `Operation` type strengthened with phantom generics to preserve `TVariables`/`TData` through inference
898
+ - `GraphQLError` type used consistently for the `errors` array; `SplitClient` uses proper `{}` constraint
899
+ - `CacheMiddleware` type export was missing — added
900
+ - Spurious `eslint-disable` comment removed from `headers.ts`
901
+ - Final review issues addressed across GraphQL implementation
902
+
903
+ ### Tests
904
+
905
+ - Integration test suite added (`tests/integration/`) — exercises the full library against a real `node:http` server with no mocked network:
906
+ - REST core: success, error, network error, path params, query strings, body serialization, response types
907
+ - REST middleware: `logMiddleware`, `retryMiddleware`, `skipMiddleware`, `onError`, `retry()`, dedupe
908
+ - GraphQL: flat and split clients, error responses, middleware, real HTTP round-trip assertions via `callCounts`
909
+ - `vitest.integration.ts` config and `@types/node@^22` dependency added
910
+ - `tests/integration/server.ts` exports `startServer()` returning `{ baseUrl, callCounts, close() }` for precise per-request assertion
911
+
912
+ ### Internal
913
+
914
+ - `@types/node` pinned to `^22` to match the Node 22 runtime target
915
+
916
+ ## [1.0.0] — 2026-04-01
917
+
918
+ Initial release of the rewritten client. Reconstructed from the release commit
919
+ (`e996cf3`), which predates per-feature changelog entries.
920
+
921
+ ### Added
922
+
923
+ - `createApi` — factory turning a record of `Request` definitions into a typed,
924
+ callable API object, with per-call options for middleware, headers and signals
925
+ - `Request` — typed endpoint definition carrying method, path template,
926
+ middleware, headers, response type and body-serialisation strategy
927
+ - `Result<T>` — `{ data, error, response, retry }` returned by every call; the
928
+ library never throws
929
+ - `ApiError` — structured error with status, body, headers and request metadata.
930
+ Deliberately not an `Error` subclass
931
+ - Middleware onion (`composeMiddleware`) with three layers — global,
932
+ per-request, per-call — plus `skipMiddleware` for per-call opt-out
933
+ - Built-in `retryMiddleware` (5xx only) and `logMiddleware`, on the
934
+ `./middleware` entry point
935
+ - Request deduplication (`dedupe: true`), auto-cancelling a previous in-flight
936
+ call to the same endpoint
937
+ - Path parameter substitution and query-string building
938
+ - Body serialisation for JSON, `FormData`, `URLSearchParams`, `Blob`,
939
+ `ArrayBuffer` and strings
940
+ - Response parsing as `json`, `text`, `blob`, `arrayBuffer` or `formData`
941
+
942
+ [5.0.0]: https://github.com/iremlopsum/liaise/compare/v4.4.3...v5.0.0
943
+ [4.4.3]: https://github.com/iremlopsum/liaise/compare/v4.4.2...v4.4.3
944
+ [4.4.2]: https://github.com/iremlopsum/liaise/compare/v4.4.1...v4.4.2
945
+ [4.4.1]: https://github.com/iremlopsum/liaise/compare/v4.4.0...v4.4.1
946
+ [4.4.0]: https://github.com/iremlopsum/liaise/compare/v4.3.0...v4.4.0
947
+ [4.3.0]: https://github.com/iremlopsum/liaise/compare/v4.2.1...v4.3.0
948
+ [4.2.1]: https://github.com/iremlopsum/liaise/compare/v4.2.0...v4.2.1
949
+ [4.2.0]: https://github.com/iremlopsum/liaise/compare/v4.1.1...v4.2.0
950
+ [4.1.1]: https://github.com/iremlopsum/liaise/compare/v4.1.0...v4.1.1
951
+ [4.1.0]: https://github.com/iremlopsum/liaise/compare/v4.0.2...v4.1.0
952
+ [4.0.2]: https://github.com/iremlopsum/liaise/compare/v4.0.1...v4.0.2
953
+ [4.0.1]: https://github.com/iremlopsum/liaise/compare/v4.0.0...v4.0.1
954
+ [4.0.0]: https://github.com/iremlopsum/liaise/compare/v3.1.0...v4.0.0
955
+ [3.1.0]: https://github.com/iremlopsum/liaise/compare/v3.0.0...v3.1.0
956
+ [3.0.0]: https://github.com/iremlopsum/liaise/compare/v2.2.1...v3.0.0
957
+ [2.2.1]: https://github.com/iremlopsum/liaise/compare/v2.2.0...v2.2.1
958
+ [2.2.0]: https://github.com/iremlopsum/liaise/compare/v2.1.0...v2.2.0
959
+ [2.1.0]: https://github.com/iremlopsum/liaise/compare/v2.0.0...v2.1.0
960
+ [2.0.0]: https://github.com/iremlopsum/liaise/compare/v1.0.0...v2.0.0
961
+ [1.0.0]: https://github.com/iremlopsum/liaise/releases/tag/v1.0.0