undici 8.2.0 → 8.4.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 +67 -23
- package/docs/docs/api/Agent.md +3 -0
- package/docs/docs/api/Client.md +43 -5
- package/docs/docs/api/Connector.md +1 -0
- package/docs/docs/api/Dispatcher.md +7 -0
- package/docs/docs/api/Errors.md +12 -0
- package/docs/docs/api/EventSource.md +50 -3
- package/docs/docs/api/Fetch.md +3 -1
- package/docs/docs/api/GlobalInstallation.md +7 -5
- package/docs/docs/api/H2CClient.md +2 -2
- package/docs/docs/api/Pool.md +3 -0
- package/docs/docs/api/RedirectHandler.md +4 -1
- 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/connect.js +29 -4
- package/lib/core/symbols.js +1 -0
- package/lib/core/util.js +10 -8
- package/lib/dispatcher/client-h1.js +59 -18
- package/lib/dispatcher/client-h2.js +418 -298
- package/lib/dispatcher/client.js +25 -4
- 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/redirect-handler.js +36 -11
- package/lib/handler/retry-handler.js +14 -0
- package/lib/interceptor/redirect.js +3 -3
- package/lib/mock/mock-call-history.js +1 -1
- package/lib/mock/mock-utils.js +3 -1
- package/lib/mock/snapshot-agent.js +11 -1
- 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 +19 -3
- package/lib/web/fetch/request.js +32 -3
- package/package.json +4 -4
- package/types/client.d.ts +7 -7
- package/types/connector.d.ts +1 -0
- package/types/dispatcher.d.ts +0 -2
- package/types/fetch.d.ts +4 -1
- package/types/formdata.d.ts +0 -6
- package/types/interceptors.d.ts +1 -1
- package/types/snapshot-agent.d.ts +4 -0
package/lib/dispatcher/client.js
CHANGED
|
@@ -52,6 +52,7 @@ const {
|
|
|
52
52
|
kOnError,
|
|
53
53
|
kHTTPContext,
|
|
54
54
|
kMaxConcurrentStreams,
|
|
55
|
+
kHostAuthority,
|
|
55
56
|
kHTTP2InitialWindowSize,
|
|
56
57
|
kHTTP2ConnectionWindowSize,
|
|
57
58
|
kResume,
|
|
@@ -75,6 +76,18 @@ function getPipelining (client) {
|
|
|
75
76
|
return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
// Protocol-aware dispatch ceiling. h1 RFC7230 pipelining is unrelated to h2
|
|
80
|
+
// stream multiplexing — over h2 the ceiling is the (server-confirmed)
|
|
81
|
+
// maxConcurrentStreams. Before a context is attached we use the h1
|
|
82
|
+
// pipelining factor; once h2 attaches the queued requests can drain in
|
|
83
|
+
// one batch up to maxConcurrentStreams.
|
|
84
|
+
function getMaxConcurrent (client) {
|
|
85
|
+
if (client[kHTTPContext]?.version === 'h2') {
|
|
86
|
+
return client[kMaxConcurrentStreams]
|
|
87
|
+
}
|
|
88
|
+
return getPipelining(client)
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
/**
|
|
79
92
|
* @type {import('../../types/client.js').default}
|
|
80
93
|
*/
|
|
@@ -246,6 +259,7 @@ class Client extends DispatcherBase {
|
|
|
246
259
|
}
|
|
247
260
|
|
|
248
261
|
this[kUrl] = util.parseOrigin(url)
|
|
262
|
+
this[kHostAuthority] = `${this[kUrl].hostname}${this[kUrl].port ? `:${this[kUrl].port}` : ''}`
|
|
249
263
|
this[kConnector] = connect
|
|
250
264
|
this[kPipelining] = pipelining != null ? pipelining : 1
|
|
251
265
|
this[kMaxHeadersSize] = maxHeaderSize
|
|
@@ -257,7 +271,7 @@ class Client extends DispatcherBase {
|
|
|
257
271
|
this[kLocalAddress] = localAddress != null ? localAddress : null
|
|
258
272
|
this[kResuming] = 0 // 0, idle, 1, scheduled, 2 resuming
|
|
259
273
|
this[kNeedDrain] = 0 // 0, idle, 1, scheduled, 2 resuming
|
|
260
|
-
this[kHostHeader] = `host: ${this[
|
|
274
|
+
this[kHostHeader] = `host: ${this[kHostAuthority]}\r\n`
|
|
261
275
|
this[kBodyTimeout] = bodyTimeout != null ? bodyTimeout : 300e3
|
|
262
276
|
this[kHeadersTimeout] = headersTimeout != null ? headersTimeout : 300e3
|
|
263
277
|
this[kStrictContentLength] = strictContentLength == null ? true : strictContentLength
|
|
@@ -324,10 +338,17 @@ class Client extends DispatcherBase {
|
|
|
324
338
|
}
|
|
325
339
|
|
|
326
340
|
get [kBusy] () {
|
|
341
|
+
// The `kPending > 0` check below is the gate Pool uses to decide whether
|
|
342
|
+
// to spin up an additional Client. For h1 that fan-out is correct —
|
|
343
|
+
// each socket only handles one pipelined request at a time. Once an h2
|
|
344
|
+
// context is attached we want concurrent dispatches to multiplex onto
|
|
345
|
+
// the shared session, so suppress that signal in the h2 case.
|
|
346
|
+
const allowsMux = this[kHTTPContext]?.version === 'h2'
|
|
347
|
+
|
|
327
348
|
return Boolean(
|
|
328
349
|
this[kHTTPContext]?.busy(null) ||
|
|
329
|
-
(this[kSize] >= (
|
|
330
|
-
this[kPending] > 0
|
|
350
|
+
(this[kSize] >= (getMaxConcurrent(this) || 1)) ||
|
|
351
|
+
(this[kPending] > 0 && !allowsMux)
|
|
331
352
|
)
|
|
332
353
|
}
|
|
333
354
|
|
|
@@ -614,7 +635,7 @@ function _resume (client, sync) {
|
|
|
614
635
|
return
|
|
615
636
|
}
|
|
616
637
|
|
|
617
|
-
if (client[kRunning] >= (
|
|
638
|
+
if (client[kRunning] >= (getMaxConcurrent(client) || 1)) {
|
|
618
639
|
return
|
|
619
640
|
}
|
|
620
641
|
|
|
@@ -14,6 +14,7 @@ const kOnConnect = Symbol('onConnect')
|
|
|
14
14
|
const kOnDisconnect = Symbol('onDisconnect')
|
|
15
15
|
const kOnConnectionError = Symbol('onConnectionError')
|
|
16
16
|
const kGetDispatcher = Symbol('get dispatcher')
|
|
17
|
+
const kHasDispatcher = Symbol('has dispatcher')
|
|
17
18
|
const kAddClient = Symbol('add client')
|
|
18
19
|
const kRemoveClient = Symbol('remove client')
|
|
19
20
|
|
|
@@ -162,12 +163,28 @@ class PoolBase extends DispatcherBase {
|
|
|
162
163
|
this[kQueued]++
|
|
163
164
|
} else if (!dispatcher.dispatch(opts, handler)) {
|
|
164
165
|
dispatcher[kNeedDrain] = true
|
|
165
|
-
this[kNeedDrain] = !this[
|
|
166
|
+
this[kNeedDrain] = !this[kHasDispatcher]()
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
return !this[kNeedDrain]
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
[kHasDispatcher] () {
|
|
173
|
+
for (let i = 0; i < this[kClients].length; i++) {
|
|
174
|
+
const dispatcher = this[kClients][i]
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
!dispatcher[kNeedDrain] &&
|
|
178
|
+
dispatcher.closed !== true &&
|
|
179
|
+
dispatcher.destroyed !== true
|
|
180
|
+
) {
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
|
|
171
188
|
[kAddClient] (client) {
|
|
172
189
|
client
|
|
173
190
|
.on('drain', this[kOnDrain].bind(this, client))
|
|
@@ -196,7 +213,7 @@ class PoolBase extends DispatcherBase {
|
|
|
196
213
|
|
|
197
214
|
client.close(() => {})
|
|
198
215
|
|
|
199
|
-
this[kNeedDrain] = this[kClients].some(dispatcher => (
|
|
216
|
+
this[kNeedDrain] = !this[kClients].some(dispatcher => (
|
|
200
217
|
!dispatcher[kNeedDrain] &&
|
|
201
218
|
dispatcher.closed !== true &&
|
|
202
219
|
dispatcher.destroyed !== true
|
|
@@ -210,5 +227,6 @@ module.exports = {
|
|
|
210
227
|
kNeedDrain,
|
|
211
228
|
kAddClient,
|
|
212
229
|
kRemoveClient,
|
|
213
|
-
kGetDispatcher
|
|
230
|
+
kGetDispatcher,
|
|
231
|
+
kHasDispatcher
|
|
214
232
|
}
|
package/lib/dispatcher/pool.js
CHANGED
|
@@ -6,6 +6,7 @@ const {
|
|
|
6
6
|
kNeedDrain,
|
|
7
7
|
kAddClient,
|
|
8
8
|
kGetDispatcher,
|
|
9
|
+
kHasDispatcher,
|
|
9
10
|
kRemoveClient
|
|
10
11
|
} = require('./pool-base')
|
|
11
12
|
const Client = require('./client')
|
|
@@ -115,6 +116,28 @@ class Pool extends PoolBase {
|
|
|
115
116
|
return dispatcher
|
|
116
117
|
}
|
|
117
118
|
}
|
|
119
|
+
|
|
120
|
+
[kHasDispatcher] () {
|
|
121
|
+
const clientTtlOption = this[kOptions].clientTtl
|
|
122
|
+
for (let i = 0; i < this[kClients].length; i++) {
|
|
123
|
+
const client = this[kClients][i]
|
|
124
|
+
|
|
125
|
+
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
|
|
126
|
+
this[kRemoveClient](client)
|
|
127
|
+
i--
|
|
128
|
+
} else if (!client[kNeedDrain]) {
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!this[kConnections] || this[kClients].length < this[kConnections]) {
|
|
134
|
+
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
|
|
135
|
+
this[kAddClient](dispatcher)
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return false
|
|
140
|
+
}
|
|
118
141
|
}
|
|
119
142
|
|
|
120
143
|
module.exports = Pool
|
|
@@ -18,6 +18,7 @@ const kProxyTls = Symbol('proxy tls settings')
|
|
|
18
18
|
const kConnectEndpoint = Symbol('connect endpoint function')
|
|
19
19
|
const kConnectEndpointHTTP1 = Symbol('connect endpoint function (http/1.1 only)')
|
|
20
20
|
const kTunnelProxy = Symbol('tunnel proxy')
|
|
21
|
+
const proxyAuthorization = 'proxy-authorization'
|
|
21
22
|
|
|
22
23
|
function defaultProtocolPort (protocol) {
|
|
23
24
|
return protocol === 'https:' ? 443 : 80
|
|
@@ -298,6 +299,10 @@ function buildHeaders (headers) {
|
|
|
298
299
|
const headersPair = {}
|
|
299
300
|
|
|
300
301
|
for (let i = 0; i < headers.length; i += 2) {
|
|
302
|
+
if (isProxyAuthorizationHeader(headers[i])) {
|
|
303
|
+
throwProxyAuthError()
|
|
304
|
+
}
|
|
305
|
+
|
|
301
306
|
headersPair[headers[i]] = headers[i + 1]
|
|
302
307
|
}
|
|
303
308
|
|
|
@@ -316,11 +321,23 @@ function buildHeaders (headers) {
|
|
|
316
321
|
* It should be removed in the next major version for performance reasons
|
|
317
322
|
*/
|
|
318
323
|
function throwIfProxyAuthIsSent (headers) {
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
324
|
+
for (const key in headers) {
|
|
325
|
+
if (isProxyAuthorizationHeader(key)) {
|
|
326
|
+
throwProxyAuthError()
|
|
327
|
+
}
|
|
323
328
|
}
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
/**
|
|
332
|
+
* @param {string} key
|
|
333
|
+
* @returns {boolean}
|
|
334
|
+
*/
|
|
335
|
+
function isProxyAuthorizationHeader (key) {
|
|
336
|
+
return key.length === proxyAuthorization.length && key.toLowerCase() === proxyAuthorization
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function throwProxyAuthError () {
|
|
340
|
+
throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor')
|
|
341
|
+
}
|
|
342
|
+
|
|
326
343
|
module.exports = ProxyAgent
|
|
@@ -6,6 +6,7 @@ const {
|
|
|
6
6
|
kNeedDrain,
|
|
7
7
|
kAddClient,
|
|
8
8
|
kGetDispatcher,
|
|
9
|
+
kHasDispatcher,
|
|
9
10
|
kRemoveClient
|
|
10
11
|
} = require('./pool-base')
|
|
11
12
|
const Client = require('./client')
|
|
@@ -128,6 +129,31 @@ class RoundRobinPool extends PoolBase {
|
|
|
128
129
|
return dispatcher
|
|
129
130
|
}
|
|
130
131
|
}
|
|
132
|
+
|
|
133
|
+
[kHasDispatcher] () {
|
|
134
|
+
const clientTtlOption = this[kOptions].clientTtl
|
|
135
|
+
for (let i = 0; i < this[kClients].length; i++) {
|
|
136
|
+
const client = this[kClients][i]
|
|
137
|
+
|
|
138
|
+
if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
|
|
139
|
+
this[kRemoveClient](client)
|
|
140
|
+
if (i <= this[kIndex]) {
|
|
141
|
+
this[kIndex]--
|
|
142
|
+
}
|
|
143
|
+
i--
|
|
144
|
+
} else if (!client[kNeedDrain]) {
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (!this[kConnections] || this[kClients].length < this[kConnections]) {
|
|
150
|
+
const dispatcher = this[kFactory](this[kUrl], this[kOptions])
|
|
151
|
+
this[kAddClient](dispatcher)
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
module.exports = RoundRobinPool
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const net = require('node:net')
|
|
4
3
|
const { URL } = require('node:url')
|
|
5
4
|
|
|
6
5
|
let tls // include tls conditionally since it is not always available
|
|
@@ -17,6 +16,7 @@ const debug = debuglog('undici:socks5-proxy')
|
|
|
17
16
|
const kProxyUrl = Symbol('proxy url')
|
|
18
17
|
const kProxyHeaders = Symbol('proxy headers')
|
|
19
18
|
const kProxyAuth = Symbol('proxy auth')
|
|
19
|
+
const kProxyProtocol = Symbol('proxy protocol')
|
|
20
20
|
const kPools = Symbol('pools')
|
|
21
21
|
const kConnector = Symbol('connector')
|
|
22
22
|
|
|
@@ -52,6 +52,7 @@ class Socks5ProxyAgent extends DispatcherBase {
|
|
|
52
52
|
|
|
53
53
|
this[kProxyUrl] = url
|
|
54
54
|
this[kProxyHeaders] = options.headers || {}
|
|
55
|
+
this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:'
|
|
55
56
|
|
|
56
57
|
// Extract auth from URL or options
|
|
57
58
|
this[kProxyAuth] = {
|
|
@@ -81,25 +82,20 @@ class Socks5ProxyAgent extends DispatcherBase {
|
|
|
81
82
|
// Connect to the SOCKS5 proxy
|
|
82
83
|
const socketReady = Promise.withResolvers()
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
socketReady.resolve(socket)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const onSocketError = (err) => {
|
|
90
|
-
socket.removeListener('connect', onSocketConnect)
|
|
91
|
-
socketReady.reject(err)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const socket = net.connect({
|
|
85
|
+
this[kConnector]({
|
|
86
|
+
hostname: proxyHost,
|
|
95
87
|
host: proxyHost,
|
|
96
|
-
port: proxyPort
|
|
88
|
+
port: proxyPort,
|
|
89
|
+
protocol: this[kProxyProtocol]
|
|
90
|
+
}, (err, socket) => {
|
|
91
|
+
if (err) {
|
|
92
|
+
socketReady.reject(err)
|
|
93
|
+
} else {
|
|
94
|
+
socketReady.resolve(socket)
|
|
95
|
+
}
|
|
97
96
|
})
|
|
98
97
|
|
|
99
|
-
socket.
|
|
100
|
-
socket.once('error', onSocketError)
|
|
101
|
-
|
|
102
|
-
await socketReady.promise
|
|
98
|
+
const socket = await socketReady.promise
|
|
103
99
|
|
|
104
100
|
// Create SOCKS5 client
|
|
105
101
|
const socks5Client = new Socks5Client(socket, this[kProxyAuth])
|
|
@@ -177,7 +173,7 @@ class Socks5ProxyAgent extends DispatcherBase {
|
|
|
177
173
|
/**
|
|
178
174
|
* Dispatch a request through the SOCKS5 proxy
|
|
179
175
|
*/
|
|
180
|
-
|
|
176
|
+
[kDispatch] (opts, handler) {
|
|
181
177
|
const { origin } = opts
|
|
182
178
|
|
|
183
179
|
debug('dispatching request to', origin, 'via SOCKS5')
|
|
@@ -234,8 +230,12 @@ class Socks5ProxyAgent extends DispatcherBase {
|
|
|
234
230
|
return pool[kDispatch](opts, handler)
|
|
235
231
|
} catch (err) {
|
|
236
232
|
debug('dispatch error:', err)
|
|
237
|
-
if (typeof handler.
|
|
233
|
+
if (typeof handler.onResponseError === 'function') {
|
|
234
|
+
handler.onResponseError(null, err)
|
|
235
|
+
return false
|
|
236
|
+
} else if (typeof handler.onError === 'function') {
|
|
238
237
|
handler.onError(err)
|
|
238
|
+
return false
|
|
239
239
|
} else {
|
|
240
240
|
throw err
|
|
241
241
|
}
|
|
@@ -29,9 +29,11 @@ class RedirectHandler {
|
|
|
29
29
|
|
|
30
30
|
this.dispatch = dispatch
|
|
31
31
|
this.location = null
|
|
32
|
-
const { maxRedirections: _, ...cleanOpts } = opts
|
|
32
|
+
const { maxRedirections: _, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect, ...cleanOpts } = opts
|
|
33
33
|
this.opts = cleanOpts // opts must be a copy, exclude maxRedirections
|
|
34
34
|
this.opts.body = util.wrapRequestBody(this.opts.body)
|
|
35
|
+
this.stripHeadersOnRedirect = normalizeStripHeaders(stripHeadersOnRedirect, 'stripHeadersOnRedirect')
|
|
36
|
+
this.stripHeadersOnCrossOriginRedirect = normalizeStripHeaders(stripHeadersOnCrossOriginRedirect, 'stripHeadersOnCrossOriginRedirect')
|
|
35
37
|
this.maxRedirections = maxRedirections
|
|
36
38
|
this.handler = handler
|
|
37
39
|
this.history = []
|
|
@@ -100,7 +102,7 @@ class RedirectHandler {
|
|
|
100
102
|
// Remove headers referring to the original URL.
|
|
101
103
|
// By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
|
|
102
104
|
// https://tools.ietf.org/html/rfc7231#section-6.4
|
|
103
|
-
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin)
|
|
105
|
+
this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin, this.stripHeadersOnRedirect, this.stripHeadersOnCrossOriginRedirect)
|
|
104
106
|
this.opts.path = path
|
|
105
107
|
this.opts.origin = origin
|
|
106
108
|
this.opts.query = null
|
|
@@ -152,26 +154,49 @@ class RedirectHandler {
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
// https://tools.ietf.org/html/rfc7231#section-6.4.4
|
|
155
|
-
function shouldRemoveHeader (header, removeContent, unknownOrigin) {
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
function shouldRemoveHeader (header, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
|
|
158
|
+
const name = util.headerNameToString(header)
|
|
159
|
+
if (name === 'host') {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
if (stripHeaders?.has(name) || (unknownOrigin && stripHeadersOnCrossOrigin?.has(name))) {
|
|
163
|
+
return true
|
|
158
164
|
}
|
|
159
|
-
if (removeContent &&
|
|
165
|
+
if (removeContent && name.startsWith('content-')) {
|
|
160
166
|
return true
|
|
161
167
|
}
|
|
162
|
-
if (unknownOrigin
|
|
163
|
-
const name = util.headerNameToString(header)
|
|
168
|
+
if (unknownOrigin) {
|
|
164
169
|
return name === 'authorization' || name === 'cookie' || name === 'proxy-authorization'
|
|
165
170
|
}
|
|
166
171
|
return false
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
// https://tools.ietf.org/html/rfc7231#section-6.4
|
|
170
|
-
function
|
|
175
|
+
function normalizeStripHeaders (headers, optionName) {
|
|
176
|
+
if (headers == null) {
|
|
177
|
+
return null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!Array.isArray(headers)) {
|
|
181
|
+
throw new InvalidArgumentError(`${optionName} must be an array`)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const normalized = new Set()
|
|
185
|
+
for (const header of headers) {
|
|
186
|
+
if (typeof header !== 'string') {
|
|
187
|
+
throw new InvalidArgumentError(`${optionName} must contain header names`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
normalized.add(util.headerNameToString(header))
|
|
191
|
+
}
|
|
192
|
+
return normalized
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
|
|
171
196
|
const ret = []
|
|
172
197
|
if (Array.isArray(headers)) {
|
|
173
198
|
for (let i = 0; i < headers.length; i += 2) {
|
|
174
|
-
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) {
|
|
199
|
+
if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
|
|
175
200
|
ret.push(headers[i], headers[i + 1])
|
|
176
201
|
}
|
|
177
202
|
}
|
|
@@ -179,7 +204,7 @@ function cleanRequestHeaders (headers, removeContent, unknownOrigin) {
|
|
|
179
204
|
const entries = util.hasSafeIterator(headers) ? headers : Object.entries(headers)
|
|
180
205
|
|
|
181
206
|
for (const [key, value] of entries) {
|
|
182
|
-
if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
|
|
207
|
+
if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
|
|
183
208
|
ret.push(key, value)
|
|
184
209
|
}
|
|
185
210
|
}
|
|
@@ -68,6 +68,8 @@ class RetryHandler {
|
|
|
68
68
|
this.start = 0
|
|
69
69
|
this.end = null
|
|
70
70
|
this.etag = null
|
|
71
|
+
this.statusCode = null
|
|
72
|
+
this.headers = null
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
onResponseStartWithRetry (controller, statusCode, headers, statusMessage, err) {
|
|
@@ -183,6 +185,8 @@ class RetryHandler {
|
|
|
183
185
|
onResponseStart (controller, statusCode, headers, statusMessage) {
|
|
184
186
|
this.error = null
|
|
185
187
|
this.retryCount += 1
|
|
188
|
+
this.statusCode = statusCode
|
|
189
|
+
this.headers = headers
|
|
186
190
|
|
|
187
191
|
if (statusCode >= 300) {
|
|
188
192
|
const err = new RequestRetryError('Request failed', statusCode, {
|
|
@@ -320,6 +324,16 @@ class RetryHandler {
|
|
|
320
324
|
}
|
|
321
325
|
|
|
322
326
|
if (!this.error) {
|
|
327
|
+
// Verify that the received body length matches the expected range
|
|
328
|
+
// when we have a finite end position (from Content-Length or Content-Range)
|
|
329
|
+
if (this.end != null && Number.isFinite(this.end)) {
|
|
330
|
+
if (this.start !== this.end + 1) {
|
|
331
|
+
throw new RequestRetryError('Content-Range mismatch', this.statusCode, {
|
|
332
|
+
headers: this.headers,
|
|
333
|
+
data: { count: this.retryCount }
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
}
|
|
323
337
|
this.retryCount = 0
|
|
324
338
|
return this.handler.onResponseEnd?.(controller, trailers)
|
|
325
339
|
}
|
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
const RedirectHandler = require('../handler/redirect-handler')
|
|
4
4
|
|
|
5
|
-
function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirect: defaultThrowOnMaxRedirect } = {}) {
|
|
5
|
+
function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections, throwOnMaxRedirect: defaultThrowOnMaxRedirect, stripHeadersOnRedirect: defaultStripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect: defaultStripHeadersOnCrossOriginRedirect } = {}) {
|
|
6
6
|
return (dispatch) => {
|
|
7
7
|
return function Intercept (opts, handler) {
|
|
8
|
-
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirect = defaultThrowOnMaxRedirect, ...rest } = opts
|
|
8
|
+
const { maxRedirections = defaultMaxRedirections, throwOnMaxRedirect = defaultThrowOnMaxRedirect, stripHeadersOnRedirect = defaultStripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect = defaultStripHeadersOnCrossOriginRedirect, ...rest } = opts
|
|
9
9
|
|
|
10
10
|
if (maxRedirections == null || maxRedirections === 0) {
|
|
11
11
|
return dispatch(opts, handler)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const dispatchOpts = { ...rest, throwOnMaxRedirect } // Stop sub dispatcher from also redirecting.
|
|
14
|
+
const dispatchOpts = { ...rest, throwOnMaxRedirect, stripHeadersOnRedirect, stripHeadersOnCrossOriginRedirect } // Stop sub dispatcher from also redirecting.
|
|
15
15
|
const redirectHandler = new RedirectHandler(dispatch, maxRedirections, dispatchOpts, handler)
|
|
16
16
|
return dispatch(dispatchOpts, redirectHandler)
|
|
17
17
|
}
|
|
@@ -35,7 +35,7 @@ function buildAndValidateFilterCallsOptions (options = {}) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function makeFilterCalls (parameterName) {
|
|
38
|
-
return (parameterValue, logs) => {
|
|
38
|
+
return (parameterValue, logs = this.logs) => {
|
|
39
39
|
if (typeof parameterValue === 'string' || parameterValue == null) {
|
|
40
40
|
return logs.filter((log) => {
|
|
41
41
|
return log[parameterName] === parameterValue
|
package/lib/mock/mock-utils.js
CHANGED
|
@@ -424,7 +424,9 @@ function buildMockDispatch () {
|
|
|
424
424
|
throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)${interceptsMessage}`)
|
|
425
425
|
}
|
|
426
426
|
if (checkNetConnect(netConnect, origin)) {
|
|
427
|
-
originalDispatch.call(this, opts
|
|
427
|
+
originalDispatch.call(this, '__mockAgentBodyForDispatch' in opts
|
|
428
|
+
? { ...opts, body: opts.__mockAgentBodyForDispatch }
|
|
429
|
+
: opts, handler)
|
|
428
430
|
} else {
|
|
429
431
|
throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect is not enabled for this origin)${interceptsMessage}`)
|
|
430
432
|
}
|
|
@@ -55,7 +55,9 @@ class SnapshotAgent extends MockAgent {
|
|
|
55
55
|
ignoreHeaders: opts.ignoreHeaders,
|
|
56
56
|
excludeHeaders: opts.excludeHeaders,
|
|
57
57
|
matchBody: opts.matchBody,
|
|
58
|
+
normalizeBody: opts.normalizeBody,
|
|
58
59
|
matchQuery: opts.matchQuery,
|
|
60
|
+
normalizeQuery: opts.normalizeQuery,
|
|
59
61
|
caseSensitive: opts.caseSensitive,
|
|
60
62
|
shouldRecord: opts.shouldRecord,
|
|
61
63
|
shouldPlayback: opts.shouldPlayback,
|
|
@@ -352,7 +354,15 @@ class SnapshotAgent extends MockAgent {
|
|
|
352
354
|
* @returns {Promise<void>}
|
|
353
355
|
*/
|
|
354
356
|
async close () {
|
|
355
|
-
|
|
357
|
+
// In playback mode the recorder must not persist to disk. findSnapshot()
|
|
358
|
+
// mutates each matched snapshot's callCount, so saving on close would
|
|
359
|
+
// rewrite the snapshot file even though nothing new was recorded. Only
|
|
360
|
+
// record/update modes should write snapshots; playback just cleans up.
|
|
361
|
+
if (this[kSnapshotMode] === 'playback') {
|
|
362
|
+
this[kSnapshotRecorder].destroy()
|
|
363
|
+
} else {
|
|
364
|
+
await this[kSnapshotRecorder].close()
|
|
365
|
+
}
|
|
356
366
|
await this[kRealAgent]?.close()
|
|
357
367
|
await super.close()
|
|
358
368
|
}
|
|
@@ -46,7 +46,9 @@ const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } =
|
|
|
46
46
|
* @property {Array<string>} [ignoreHeaders=[]] - Headers to ignore for matching
|
|
47
47
|
* @property {Array<string>} [excludeHeaders=[]] - Headers to exclude from matching
|
|
48
48
|
* @property {boolean} [matchBody=true] - Whether to match request body
|
|
49
|
-
* @property {
|
|
49
|
+
* @property {(body: string|Buffer|null|undefined) => string} [normalizeBody] - Function to normalize the body before matching (e.g. strip timestamps)
|
|
50
|
+
* @property {boolean} [matchQuery=true] - Whether to match query parameters
|
|
51
|
+
* @property {(query: URLSearchParams) => string} [normalizeQuery] - Function to normalize query parameters before matching (e.g. strip volatile params)
|
|
50
52
|
* @property {boolean} [caseSensitive=false] - Whether header matching is case-sensitive
|
|
51
53
|
*/
|
|
52
54
|
|
|
@@ -79,6 +81,37 @@ const { hashId, isUrlExcludedFactory, normalizeHeaders, createHeaderFilters } =
|
|
|
79
81
|
* @property {string} timestamp - ISO timestamp of when the snapshot was created
|
|
80
82
|
*/
|
|
81
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Normalizes the URL string used for request matching.
|
|
86
|
+
*
|
|
87
|
+
* @param {URL} url - Parsed request URL
|
|
88
|
+
* @param {boolean} matchQuery - Whether to include query parameters in matching
|
|
89
|
+
* @param {((query: URLSearchParams) => string)|undefined} normalizeQuery - Optional normalization function
|
|
90
|
+
* @returns {string} - URL string for hashing
|
|
91
|
+
*/
|
|
92
|
+
function normalizeUrlForMatching (url, matchQuery, normalizeQuery) {
|
|
93
|
+
if (matchQuery === false) return `${url.origin}${url.pathname}`
|
|
94
|
+
if (normalizeQuery) {
|
|
95
|
+
const normalized = String(normalizeQuery(url.searchParams) ?? '')
|
|
96
|
+
return normalized ? `${url.origin}${url.pathname}?${normalized}` : `${url.origin}${url.pathname}`
|
|
97
|
+
}
|
|
98
|
+
return url.toString()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Normalizes the body value used for request matching.
|
|
103
|
+
*
|
|
104
|
+
* @param {string|Buffer|null|undefined} body - Raw request body
|
|
105
|
+
* @param {boolean} matchBody - Whether to include the body in matching
|
|
106
|
+
* @param {((body: string|Buffer|null|undefined) => string)|undefined} normalizeBody - Optional normalization function
|
|
107
|
+
* @returns {string} - Body string for hashing
|
|
108
|
+
*/
|
|
109
|
+
function normalizeBodyForMatching (body, matchBody, normalizeBody) {
|
|
110
|
+
if (matchBody === false) return ''
|
|
111
|
+
if (normalizeBody) return String(normalizeBody(body) ?? '')
|
|
112
|
+
return body ? String(body) : ''
|
|
113
|
+
}
|
|
114
|
+
|
|
82
115
|
/**
|
|
83
116
|
* Formats a request for consistent snapshot storage
|
|
84
117
|
* Caches normalized headers to avoid repeated processing
|
|
@@ -99,9 +132,9 @@ function formatRequestKey (opts, headerFilters, matchOptions = {}) {
|
|
|
99
132
|
|
|
100
133
|
return {
|
|
101
134
|
method: opts.method || 'GET',
|
|
102
|
-
url: matchOptions.matchQuery
|
|
135
|
+
url: normalizeUrlForMatching(url, matchOptions.matchQuery, matchOptions.normalizeQuery),
|
|
103
136
|
headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
|
|
104
|
-
body:
|
|
137
|
+
body: normalizeBodyForMatching(opts.body, matchOptions.matchBody, matchOptions.normalizeBody)
|
|
105
138
|
}
|
|
106
139
|
}
|
|
107
140
|
|
|
@@ -250,7 +283,9 @@ class SnapshotRecorder {
|
|
|
250
283
|
ignoreHeaders: options.ignoreHeaders || [],
|
|
251
284
|
excludeHeaders: options.excludeHeaders || [],
|
|
252
285
|
matchBody: options.matchBody !== false, // default: true
|
|
286
|
+
normalizeBody: options.normalizeBody || undefined,
|
|
253
287
|
matchQuery: options.matchQuery !== false, // default: true
|
|
288
|
+
normalizeQuery: options.normalizeQuery || undefined,
|
|
254
289
|
caseSensitive: options.caseSensitive || false
|
|
255
290
|
}
|
|
256
291
|
|
package/lib/web/fetch/body.js
CHANGED
|
@@ -7,7 +7,7 @@ const {
|
|
|
7
7
|
fullyReadBody,
|
|
8
8
|
extractMimeType
|
|
9
9
|
} = require('./util')
|
|
10
|
-
const { FormData, setFormDataState } = require('./formdata')
|
|
10
|
+
const { FormData, setFormDataState, getFormDataBoundary } = require('./formdata')
|
|
11
11
|
const { webidl } = require('../webidl')
|
|
12
12
|
const assert = require('node:assert')
|
|
13
13
|
const { isErrored, isDisturbed } = require('node:stream')
|
|
@@ -16,11 +16,6 @@ const { serializeAMimeType } = require('./data-url')
|
|
|
16
16
|
const { multipartFormDataParser } = require('./formdata-parser')
|
|
17
17
|
const { parseJSONFromBytes } = require('../infra')
|
|
18
18
|
const { utf8DecodeBytes } = require('../../encoding')
|
|
19
|
-
const { runtimeFeatures } = require('../../util/runtime-features.js')
|
|
20
|
-
|
|
21
|
-
const random = runtimeFeatures.has('crypto')
|
|
22
|
-
? require('node:crypto').randomInt
|
|
23
|
-
: (max) => Math.floor(Math.random() * max)
|
|
24
19
|
|
|
25
20
|
const textEncoder = new TextEncoder()
|
|
26
21
|
function noop () {}
|
|
@@ -106,7 +101,7 @@ function extractBody (object, keepalive = false) {
|
|
|
106
101
|
// Set source to a copy of the bytes held by object.
|
|
107
102
|
source = webidl.util.getCopyOfBytesHeldByBufferSource(object)
|
|
108
103
|
} else if (webidl.is.FormData(object)) {
|
|
109
|
-
const boundary =
|
|
104
|
+
const boundary = getFormDataBoundary(object)
|
|
110
105
|
const prefix = `--${boundary}\r\nContent-Disposition: form-data`
|
|
111
106
|
|
|
112
107
|
/*! formdata-polyfill. MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
|
@@ -4,10 +4,16 @@ const { iteratorMixin } = require('./util')
|
|
|
4
4
|
const { kEnumerableProperty } = require('../../core/util')
|
|
5
5
|
const { webidl } = require('../webidl')
|
|
6
6
|
const nodeUtil = require('node:util')
|
|
7
|
+
const { runtimeFeatures } = require('../../util/runtime-features.js')
|
|
8
|
+
|
|
9
|
+
const random = runtimeFeatures.has('crypto')
|
|
10
|
+
? require('node:crypto').randomInt
|
|
11
|
+
: (max) => Math.floor(Math.random() * max)
|
|
7
12
|
|
|
8
13
|
// https://xhr.spec.whatwg.org/#formdata
|
|
9
14
|
class FormData {
|
|
10
15
|
#state = []
|
|
16
|
+
#boundary = null
|
|
11
17
|
|
|
12
18
|
constructor (form = undefined) {
|
|
13
19
|
webidl.util.markAsUncloneable(this)
|
|
@@ -192,11 +198,24 @@ class FormData {
|
|
|
192
198
|
static setFormDataState (formData, newState) {
|
|
193
199
|
formData.#state = newState
|
|
194
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* @param {FormData} formData
|
|
204
|
+
* @returns {string | null}
|
|
205
|
+
*/
|
|
206
|
+
static getFormDataBoundary (formData) {
|
|
207
|
+
const boundary = formData.#boundary
|
|
208
|
+
if (boundary != null) return boundary
|
|
209
|
+
|
|
210
|
+
// eslint-disable-next-line no-return-assign
|
|
211
|
+
return formData.#boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}`
|
|
212
|
+
}
|
|
195
213
|
}
|
|
196
214
|
|
|
197
|
-
const { getFormDataState, setFormDataState } = FormData
|
|
215
|
+
const { getFormDataState, setFormDataState, getFormDataBoundary } = FormData
|
|
198
216
|
Reflect.deleteProperty(FormData, 'getFormDataState')
|
|
199
217
|
Reflect.deleteProperty(FormData, 'setFormDataState')
|
|
218
|
+
Reflect.deleteProperty(FormData, 'getFormDataBoundary')
|
|
200
219
|
|
|
201
220
|
iteratorMixin('FormData', FormData, getFormDataState, 'name', 'value')
|
|
202
221
|
|
|
@@ -256,4 +275,4 @@ function makeEntry (name, value, filename) {
|
|
|
256
275
|
|
|
257
276
|
webidl.is.FormData = webidl.util.MakeTypeAssertion(FormData)
|
|
258
277
|
|
|
259
|
-
module.exports = { FormData, makeEntry, setFormDataState }
|
|
278
|
+
module.exports = { FormData, makeEntry, setFormDataState, getFormDataBoundary }
|