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.
- package/CHANGELOG.md +1031 -0
- package/LICENSE +21 -0
- package/MIGRATION.md +971 -0
- package/README.md +1417 -4
- package/dist/built-in-middleware.d.ts +232 -0
- package/dist/built-in-middleware.js +147 -0
- package/dist/create-api.d.ts +120 -0
- package/dist/create-api.js +405 -0
- package/dist/define-request.d.ts +251 -0
- package/dist/define-request.js +4 -0
- package/dist/graphql.d.ts +30 -0
- package/dist/graphql.js +286 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +6 -0
- package/dist/middleware.d.ts +77 -0
- package/dist/middleware.js +12 -0
- package/dist/paginate.d.ts +71 -0
- package/dist/paginate.js +15 -0
- package/dist/request.d.ts +136 -0
- package/dist/request.js +12 -0
- package/dist/result.d.ts +178 -0
- package/dist/result.js +20 -0
- package/dist/testing.d.ts +51 -0
- package/dist/testing.js +135 -0
- package/dist/types.d.ts +751 -0
- package/dist/types.js +1 -0
- package/dist/utils/abort-kind.d.ts +58 -0
- package/dist/utils/abort-kind.js +28 -0
- package/dist/utils/any-signal.d.ts +27 -0
- package/dist/utils/any-signal.js +40 -0
- package/dist/utils/backstop.d.ts +49 -0
- package/dist/utils/backstop.js +80 -0
- package/dist/utils/budget.d.ts +53 -0
- package/dist/utils/budget.js +20 -0
- package/dist/utils/cache.d.ts +54 -0
- package/dist/utils/cache.js +37 -0
- package/dist/utils/classify-params.d.ts +13 -0
- package/dist/utils/classify-params.js +50 -0
- package/dist/utils/dedupe.d.ts +90 -0
- package/dist/utils/dedupe.js +20 -0
- package/dist/utils/headers.d.ts +1 -0
- package/dist/utils/headers.js +9 -0
- package/dist/utils/path-params.d.ts +89 -0
- package/dist/utils/path-params.js +85 -0
- package/dist/utils/serialize.d.ts +50 -0
- package/dist/utils/serialize.js +28 -0
- package/dist/utils/share.d.ts +49 -0
- package/dist/utils/share.js +48 -0
- package/dist/utils/special-body.d.ts +24 -0
- package/dist/utils/special-body.js +12 -0
- package/dist/utils/stable-key.d.ts +57 -0
- package/dist/utils/stable-key.js +111 -0
- package/dist/utils/timeout.d.ts +27 -0
- package/dist/utils/timeout.js +16 -0
- package/dist/utils/validate.d.ts +32 -0
- package/dist/utils/validate.js +6 -0
- 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
|