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