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.
Files changed (45) hide show
  1. package/README.md +67 -23
  2. package/docs/docs/api/Agent.md +3 -0
  3. package/docs/docs/api/Client.md +43 -5
  4. package/docs/docs/api/Connector.md +1 -0
  5. package/docs/docs/api/Dispatcher.md +7 -0
  6. package/docs/docs/api/Errors.md +12 -0
  7. package/docs/docs/api/EventSource.md +50 -3
  8. package/docs/docs/api/Fetch.md +3 -1
  9. package/docs/docs/api/GlobalInstallation.md +7 -5
  10. package/docs/docs/api/H2CClient.md +2 -2
  11. package/docs/docs/api/Pool.md +3 -0
  12. package/docs/docs/api/RedirectHandler.md +4 -1
  13. package/docs/docs/api/SnapshotAgent.md +23 -0
  14. package/lib/api/api-pipeline.js +4 -0
  15. package/lib/api/api-stream.js +51 -5
  16. package/lib/core/connect.js +29 -4
  17. package/lib/core/symbols.js +1 -0
  18. package/lib/core/util.js +10 -8
  19. package/lib/dispatcher/client-h1.js +59 -18
  20. package/lib/dispatcher/client-h2.js +418 -298
  21. package/lib/dispatcher/client.js +25 -4
  22. package/lib/dispatcher/pool-base.js +21 -3
  23. package/lib/dispatcher/pool.js +23 -0
  24. package/lib/dispatcher/proxy-agent.js +21 -4
  25. package/lib/dispatcher/round-robin-pool.js +26 -0
  26. package/lib/dispatcher/socks5-proxy-agent.js +19 -19
  27. package/lib/handler/redirect-handler.js +36 -11
  28. package/lib/handler/retry-handler.js +14 -0
  29. package/lib/interceptor/redirect.js +3 -3
  30. package/lib/mock/mock-call-history.js +1 -1
  31. package/lib/mock/mock-utils.js +3 -1
  32. package/lib/mock/snapshot-agent.js +11 -1
  33. package/lib/mock/snapshot-recorder.js +38 -3
  34. package/lib/web/fetch/body.js +2 -7
  35. package/lib/web/fetch/formdata.js +21 -2
  36. package/lib/web/fetch/index.js +19 -3
  37. package/lib/web/fetch/request.js +32 -3
  38. package/package.json +4 -4
  39. package/types/client.d.ts +7 -7
  40. package/types/connector.d.ts +1 -0
  41. package/types/dispatcher.d.ts +0 -2
  42. package/types/fetch.d.ts +4 -1
  43. package/types/formdata.d.ts +0 -6
  44. package/types/interceptors.d.ts +1 -1
  45. package/types/snapshot-agent.d.ts +4 -0
@@ -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[kUrl].hostname}${this[kUrl].port ? `:${this[kUrl].port}` : ''}\r\n`
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] >= (getPipelining(this) || 1)) ||
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] >= (getPipelining(client) || 1)) {
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[kGetDispatcher]()
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
  }
@@ -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 existProxyAuth = headers && Object.keys(headers)
320
- .find((key) => key.toLowerCase() === 'proxy-authorization')
321
- if (existProxyAuth) {
322
- throw new InvalidArgumentError('Proxy-Authorization should be sent in ProxyAgent constructor')
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
- const onSocketConnect = () => {
85
- socket.removeListener('error', onSocketError)
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.once('connect', onSocketConnect)
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
- async [kDispatch] (opts, handler) {
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.onError === 'function') {
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
- if (header.length === 4) {
157
- return util.headerNameToString(header) === 'host'
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 && util.headerNameToString(header).startsWith('content-')) {
165
+ if (removeContent && name.startsWith('content-')) {
160
166
  return true
161
167
  }
162
- if (unknownOrigin && (header.length === 13 || header.length === 6 || header.length === 19)) {
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 cleanRequestHeaders (headers, removeContent, unknownOrigin) {
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
@@ -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, handler)
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
- await this[kSnapshotRecorder].close()
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 {boolean} [matchQuery=true] - Whether to match query properties
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 !== false ? url.toString() : `${url.origin}${url.pathname}`,
135
+ url: normalizeUrlForMatching(url, matchOptions.matchQuery, matchOptions.normalizeQuery),
103
136
  headers: filterHeadersForMatching(normalized, headerFilters, matchOptions),
104
- body: matchOptions.matchBody !== false && opts.body ? String(opts.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
 
@@ -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 = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}`
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 }