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