undici 8.2.0 → 8.3.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/README.md +8 -5
- package/docs/docs/api/GlobalInstallation.md +7 -5
- package/docs/docs/api/SnapshotAgent.md +23 -0
- package/lib/api/api-pipeline.js +4 -0
- package/lib/api/api-stream.js +51 -5
- package/lib/core/symbols.js +1 -0
- package/lib/core/util.js +2 -2
- package/lib/dispatcher/client-h1.js +59 -18
- package/lib/dispatcher/client-h2.js +373 -294
- package/lib/dispatcher/client.js +3 -1
- package/lib/dispatcher/pool-base.js +21 -3
- package/lib/dispatcher/pool.js +23 -0
- package/lib/dispatcher/proxy-agent.js +21 -4
- package/lib/dispatcher/round-robin-pool.js +26 -0
- package/lib/dispatcher/socks5-proxy-agent.js +19 -19
- package/lib/handler/retry-handler.js +14 -0
- package/lib/mock/mock-utils.js +3 -1
- package/lib/mock/snapshot-agent.js +2 -0
- package/lib/mock/snapshot-recorder.js +38 -3
- package/lib/web/fetch/body.js +2 -7
- package/lib/web/fetch/formdata.js +21 -2
- package/lib/web/fetch/index.js +2 -0
- package/package.json +4 -4
- package/types/client.d.ts +7 -7
- package/types/dispatcher.d.ts +0 -2
- package/types/formdata.d.ts +0 -6
- package/types/snapshot-agent.d.ts +4 -0
package/README.md
CHANGED
|
@@ -200,7 +200,9 @@ await fetch('https://example.com', {
|
|
|
200
200
|
```
|
|
201
201
|
|
|
202
202
|
`install()` replaces the global `fetch`, `Headers`, `Response`, `Request`, and
|
|
203
|
-
`FormData` implementations with undici's versions, so they all match.
|
|
203
|
+
`FormData` implementations with undici's versions, so they all match. It also
|
|
204
|
+
installs undici's `WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and
|
|
205
|
+
`EventSource` globals.
|
|
204
206
|
|
|
205
207
|
Avoid mixing a global `FormData` with `undici.fetch()`, or `undici.FormData`
|
|
206
208
|
with the built-in global `fetch()`.
|
|
@@ -283,12 +285,12 @@ const data2 = await getData();
|
|
|
283
285
|
|
|
284
286
|
## Global Installation
|
|
285
287
|
|
|
286
|
-
Undici provides an `install()` function to add
|
|
288
|
+
Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally:
|
|
287
289
|
|
|
288
290
|
```js
|
|
289
291
|
import { install } from 'undici'
|
|
290
292
|
|
|
291
|
-
// Install
|
|
293
|
+
// Install undici's global web APIs
|
|
292
294
|
install()
|
|
293
295
|
|
|
294
296
|
// Now you can use fetch classes globally without importing
|
|
@@ -316,8 +318,9 @@ The `install()` function adds the following classes to `globalThis`:
|
|
|
316
318
|
|
|
317
319
|
When you call `install()`, these globals come from the same undici
|
|
318
320
|
implementation. For example, global `fetch` and global `FormData` will both be
|
|
319
|
-
undici's versions,
|
|
320
|
-
through
|
|
321
|
+
undici's versions, and `WebSocket` and `EventSource` will also come from
|
|
322
|
+
undici, which is the recommended setup if you want to use undici through
|
|
323
|
+
globals.
|
|
321
324
|
|
|
322
325
|
This is useful for:
|
|
323
326
|
- Polyfilling environments that don't have fetch
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# Global Installation
|
|
2
2
|
|
|
3
|
-
Undici provides an `install()` function to add
|
|
3
|
+
Undici provides an `install()` function to add fetch-related and other web API classes to `globalThis`, making them available globally without requiring imports.
|
|
4
4
|
|
|
5
5
|
## `install()`
|
|
6
6
|
|
|
7
|
-
Install
|
|
7
|
+
Install undici's global web APIs on `globalThis`.
|
|
8
8
|
|
|
9
9
|
**Example:**
|
|
10
10
|
|
|
11
11
|
```js
|
|
12
12
|
import { install } from 'undici'
|
|
13
13
|
|
|
14
|
-
// Install
|
|
14
|
+
// Install undici's global web APIs
|
|
15
15
|
install()
|
|
16
16
|
|
|
17
17
|
// Now you can use fetch classes globally without importing
|
|
@@ -74,6 +74,8 @@ await fetch('https://example.com', {
|
|
|
74
74
|
|
|
75
75
|
After `install()`, `fetch`, `Headers`, `Response`, `Request`, and `FormData`
|
|
76
76
|
all come from the installed `undici` package, so they work as a matching set.
|
|
77
|
+
`WebSocket`, `CloseEvent`, `ErrorEvent`, `MessageEvent`, and `EventSource`
|
|
78
|
+
also come from the installed `undici` package.
|
|
77
79
|
|
|
78
80
|
If you do not want to install globals, import both from `undici` instead:
|
|
79
81
|
|
|
@@ -135,5 +137,5 @@ test('fetch API test', async () => {
|
|
|
135
137
|
|
|
136
138
|
- The `install()` function overwrites any existing global implementations
|
|
137
139
|
- Classes installed are undici's implementations, not Node.js built-ins
|
|
138
|
-
- This provides access to undici's latest features and performance improvements
|
|
139
|
-
- The global installation persists for the lifetime of the process
|
|
140
|
+
- This provides access to undici's latest fetch, WebSocket, and EventSource features and performance improvements
|
|
141
|
+
- The global installation persists for the lifetime of the process
|
|
@@ -27,7 +27,9 @@ new SnapshotAgent([options])
|
|
|
27
27
|
- **ignoreHeaders** `Array<String>` - Headers to ignore during request matching
|
|
28
28
|
- **excludeHeaders** `Array<String>` - Headers to exclude from snapshots (for security)
|
|
29
29
|
- **matchBody** `Boolean` - Whether to include request body in matching. Default: `true`
|
|
30
|
+
- **normalizeBody** `Function` - Optional function `(body) => string` to normalize the request body before matching (e.g. strip volatile fields like timestamps). Only used when `matchBody` is `true`.
|
|
30
31
|
- **matchQuery** `Boolean` - Whether to include query parameters in matching. Default: `true`
|
|
32
|
+
- **normalizeQuery** `Function` - Optional function `(query: URLSearchParams) => string` to normalize query parameters before matching (e.g. strip volatile params like cache-busters). Only used when `matchQuery` is `true`.
|
|
31
33
|
- **caseSensitive** `Boolean` - Whether header matching is case-sensitive. Default: `false`
|
|
32
34
|
- **shouldRecord** `Function` - Callback to determine if a request should be recorded
|
|
33
35
|
- **shouldPlayback** `Function` - Callback to determine if a request should be played back
|
|
@@ -108,6 +110,27 @@ await agent.saveSnapshots('./custom-snapshots.json')
|
|
|
108
110
|
|
|
109
111
|
## Advanced Configuration
|
|
110
112
|
|
|
113
|
+
### Body Matching
|
|
114
|
+
|
|
115
|
+
By default (`matchBody: true`) the full request body string is included in the snapshot key. Set it to `false` to ignore the body entirely, or use `normalizeBody` to strip volatile fields (like timestamps) before matching:
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const agent = new SnapshotAgent({
|
|
119
|
+
mode: 'playback',
|
|
120
|
+
snapshotPath: './snapshots.json',
|
|
121
|
+
|
|
122
|
+
// Match on everything except the timestamp field
|
|
123
|
+
normalizeBody: (body) => {
|
|
124
|
+
if (!body) return ''
|
|
125
|
+
const parsed = JSON.parse(String(body))
|
|
126
|
+
delete parsed.timestamp
|
|
127
|
+
return JSON.stringify(parsed)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`normalizeBody` receives the raw body (`string | Buffer | null | undefined`) and must return a `string`. It runs at both record and playback time so the hash is consistent. Two requests match the same snapshot whenever their normalized strings are identical.
|
|
133
|
+
|
|
111
134
|
### Header Filtering
|
|
112
135
|
|
|
113
136
|
Control which headers are used for request matching and what gets stored in snapshots:
|
package/lib/api/api-pipeline.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
RequestAbortedError
|
|
14
14
|
} = require('../core/errors')
|
|
15
15
|
const util = require('../core/util')
|
|
16
|
+
const { kBodyUsed } = require('../core/symbols')
|
|
16
17
|
const { addSignal, removeSignal } = require('./abort-signal')
|
|
17
18
|
|
|
18
19
|
function noop () {}
|
|
@@ -24,6 +25,9 @@ class PipelineRequest extends Readable {
|
|
|
24
25
|
super({ autoDestroy: true })
|
|
25
26
|
|
|
26
27
|
this[kResume] = null
|
|
28
|
+
// Pipeline request bodies come from a live writable side and cannot be
|
|
29
|
+
// replayed across redirects or retries, even before any bytes are read.
|
|
30
|
+
this[kBodyUsed] = true
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
_read () {
|
package/lib/api/api-stream.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert')
|
|
4
|
-
const { finished } = require('node:stream')
|
|
5
4
|
const { AsyncResource } = require('node:async_hooks')
|
|
6
5
|
const { InvalidArgumentError, InvalidReturnValueError } = require('../core/errors')
|
|
7
6
|
const util = require('../core/util')
|
|
@@ -9,6 +8,54 @@ const { addSignal, removeSignal } = require('./abort-signal')
|
|
|
9
8
|
|
|
10
9
|
function noop () {}
|
|
11
10
|
|
|
11
|
+
function getWritableError (stream) {
|
|
12
|
+
return stream.errored ?? stream.writableErrored ?? stream._writableState?.errored
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createPrematureCloseError () {
|
|
16
|
+
const err = new Error('Premature close')
|
|
17
|
+
err.code = 'ERR_STREAM_PREMATURE_CLOSE'
|
|
18
|
+
return err
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function trackWritableLifecycle (stream, callback) {
|
|
22
|
+
let done = false
|
|
23
|
+
|
|
24
|
+
const cleanup = () => {
|
|
25
|
+
stream.removeListener('close', onClose)
|
|
26
|
+
stream.removeListener('error', onError)
|
|
27
|
+
stream.removeListener('finish', onFinish)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const finish = (err, fromErrorEvent = false) => {
|
|
31
|
+
if (done) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
done = true
|
|
36
|
+
cleanup()
|
|
37
|
+
callback(err, fromErrorEvent)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const onClose = () => {
|
|
41
|
+
const err = getWritableError(stream)
|
|
42
|
+
finish(err ?? (!stream.writableFinished ? createPrematureCloseError() : undefined))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const onError = (err) => finish(err, true)
|
|
46
|
+
const onFinish = () => finish()
|
|
47
|
+
|
|
48
|
+
stream.on('close', onClose)
|
|
49
|
+
stream.on('error', onError)
|
|
50
|
+
stream.on('finish', onFinish)
|
|
51
|
+
|
|
52
|
+
if (stream.closed) {
|
|
53
|
+
process.nextTick(onClose)
|
|
54
|
+
} else if (stream.writableFinished) {
|
|
55
|
+
process.nextTick(onFinish)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
12
59
|
class StreamHandler extends AsyncResource {
|
|
13
60
|
constructor (opts, factory, callback) {
|
|
14
61
|
if (!opts || typeof opts !== 'object') {
|
|
@@ -117,20 +164,19 @@ class StreamHandler extends AsyncResource {
|
|
|
117
164
|
throw new InvalidReturnValueError('expected Writable')
|
|
118
165
|
}
|
|
119
166
|
|
|
120
|
-
|
|
121
|
-
finished(res, { readable: false }, (err) => {
|
|
167
|
+
trackWritableLifecycle(res, (err, fromErrorEvent) => {
|
|
122
168
|
const { callback, res, opaque, trailers, abort } = this
|
|
123
169
|
|
|
124
170
|
this.res = null
|
|
125
171
|
if (err || !res?.readable) {
|
|
126
|
-
util.destroy(res, err)
|
|
172
|
+
util.destroy(res, fromErrorEvent ? undefined : err)
|
|
127
173
|
}
|
|
128
174
|
|
|
129
175
|
this.callback = null
|
|
130
176
|
this.runInAsyncScope(callback, null, err || null, { opaque, trailers })
|
|
131
177
|
|
|
132
178
|
if (err) {
|
|
133
|
-
abort()
|
|
179
|
+
abort(err)
|
|
134
180
|
}
|
|
135
181
|
})
|
|
136
182
|
|
package/lib/core/symbols.js
CHANGED
|
@@ -62,6 +62,7 @@ module.exports = {
|
|
|
62
62
|
kListeners: Symbol('listeners'),
|
|
63
63
|
kHTTPContext: Symbol('http context'),
|
|
64
64
|
kMaxConcurrentStreams: Symbol('max concurrent streams'),
|
|
65
|
+
kHostAuthority: Symbol('host authority'),
|
|
65
66
|
kHTTP2InitialWindowSize: Symbol('http2 initial window size'),
|
|
66
67
|
kHTTP2ConnectionWindowSize: Symbol('http2 connection window size'),
|
|
67
68
|
kEnableConnectProtocol: Symbol('http2session connect protocol'),
|
package/lib/core/util.js
CHANGED
|
@@ -776,7 +776,7 @@ function isValidHeaderValue (characters) {
|
|
|
776
776
|
return !headerCharRegex.test(characters)
|
|
777
777
|
}
|
|
778
778
|
|
|
779
|
-
const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d
|
|
779
|
+
const rangeHeaderRegex = /^bytes (\d+)-(\d+)\/(\d+|\*)?$/
|
|
780
780
|
|
|
781
781
|
/**
|
|
782
782
|
* @typedef {object} RangeHeader
|
|
@@ -799,7 +799,7 @@ function parseRangeHeader (range) {
|
|
|
799
799
|
? {
|
|
800
800
|
start: parseInt(m[1]),
|
|
801
801
|
end: m[2] ? parseInt(m[2]) : null,
|
|
802
|
-
size: m[3] ? parseInt(m[3]) : null
|
|
802
|
+
size: m[3] && m[3] !== '*' ? parseInt(m[3]) : null
|
|
803
803
|
}
|
|
804
804
|
: null
|
|
805
805
|
}
|
|
@@ -360,16 +360,7 @@ class Parser {
|
|
|
360
360
|
this.paused = true
|
|
361
361
|
socket.unshift(data)
|
|
362
362
|
} else {
|
|
363
|
-
|
|
364
|
-
let message = ''
|
|
365
|
-
if (ptr) {
|
|
366
|
-
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
|
|
367
|
-
message =
|
|
368
|
-
'Response does not match the HTTP/1.1 protocol (' +
|
|
369
|
-
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
|
|
370
|
-
')'
|
|
371
|
-
}
|
|
372
|
-
throw new HTTPParserError(message, constants.ERROR[ret], data)
|
|
363
|
+
throw this.createError(ret, data)
|
|
373
364
|
}
|
|
374
365
|
}
|
|
375
366
|
} catch (err) {
|
|
@@ -377,6 +368,54 @@ class Parser {
|
|
|
377
368
|
}
|
|
378
369
|
}
|
|
379
370
|
|
|
371
|
+
finish () {
|
|
372
|
+
assert(currentParser === null)
|
|
373
|
+
assert(this.ptr != null)
|
|
374
|
+
assert(!this.paused)
|
|
375
|
+
|
|
376
|
+
const { llhttp } = this
|
|
377
|
+
|
|
378
|
+
let ret
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
currentParser = this
|
|
382
|
+
ret = llhttp.llhttp_finish(this.ptr)
|
|
383
|
+
} finally {
|
|
384
|
+
currentParser = null
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (ret === constants.ERROR.OK) {
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (ret === constants.ERROR.PAUSED || ret === constants.ERROR.PAUSED_UPGRADE) {
|
|
392
|
+
this.paused = true
|
|
393
|
+
return null
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return this.createError(ret, EMPTY_BUF)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
createError (ret, data) {
|
|
400
|
+
const { llhttp, contentLength, bytesRead } = this
|
|
401
|
+
|
|
402
|
+
if (contentLength !== -1 && bytesRead !== contentLength) {
|
|
403
|
+
return new ResponseContentLengthMismatchError()
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const ptr = llhttp.llhttp_get_error_reason(this.ptr)
|
|
407
|
+
let message = ''
|
|
408
|
+
if (ptr) {
|
|
409
|
+
const len = new Uint8Array(llhttp.memory.buffer, ptr).indexOf(0)
|
|
410
|
+
message =
|
|
411
|
+
'Response does not match the HTTP/1.1 protocol (' +
|
|
412
|
+
Buffer.from(llhttp.memory.buffer, ptr, len).toString() +
|
|
413
|
+
')'
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return new HTTPParserError(message, constants.ERROR[ret], data)
|
|
417
|
+
}
|
|
418
|
+
|
|
380
419
|
destroy () {
|
|
381
420
|
assert(currentParser === null)
|
|
382
421
|
assert(this.ptr != null)
|
|
@@ -888,8 +927,11 @@ function onHttpSocketError (err) {
|
|
|
888
927
|
// On Mac OS, we get an ECONNRESET even if there is a full body to be forwarded
|
|
889
928
|
// to the user.
|
|
890
929
|
if (err.code === 'ECONNRESET' && parser.statusCode && !parser.shouldKeepAlive) {
|
|
891
|
-
|
|
892
|
-
|
|
930
|
+
const parserErr = parser.finish()
|
|
931
|
+
if (parserErr) {
|
|
932
|
+
this[kError] = parserErr
|
|
933
|
+
this[kClient][kOnError](parserErr)
|
|
934
|
+
}
|
|
893
935
|
return
|
|
894
936
|
}
|
|
895
937
|
|
|
@@ -906,8 +948,10 @@ function onHttpSocketEnd () {
|
|
|
906
948
|
const parser = this[kParser]
|
|
907
949
|
|
|
908
950
|
if (parser.statusCode && !parser.shouldKeepAlive) {
|
|
909
|
-
|
|
910
|
-
|
|
951
|
+
const parserErr = parser.finish()
|
|
952
|
+
if (parserErr) {
|
|
953
|
+
util.destroy(this, parserErr)
|
|
954
|
+
}
|
|
911
955
|
return
|
|
912
956
|
}
|
|
913
957
|
|
|
@@ -919,8 +963,7 @@ function onHttpSocketClose () {
|
|
|
919
963
|
|
|
920
964
|
if (parser) {
|
|
921
965
|
if (!this[kError] && parser.statusCode && !parser.shouldKeepAlive) {
|
|
922
|
-
|
|
923
|
-
parser.onMessageComplete()
|
|
966
|
+
this[kError] = parser.finish() || this[kError]
|
|
924
967
|
}
|
|
925
968
|
|
|
926
969
|
this[kParser].destroy()
|
|
@@ -1382,8 +1425,6 @@ function writeBuffer (abort, body, client, request, socket, contentLength, heade
|
|
|
1382
1425
|
* @returns {Promise<void>}
|
|
1383
1426
|
*/
|
|
1384
1427
|
async function writeBlob (abort, body, client, request, socket, contentLength, header, expectsPayload) {
|
|
1385
|
-
assert(contentLength === body.size, 'blob body must have content length')
|
|
1386
|
-
|
|
1387
1428
|
try {
|
|
1388
1429
|
if (contentLength != null && contentLength !== body.size) {
|
|
1389
1430
|
throw new RequestContentLengthMismatchError()
|