undici 7.11.0 → 7.13.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 (47) hide show
  1. package/README.md +15 -11
  2. package/docs/docs/api/DiagnosticsChannel.md +7 -4
  3. package/docs/docs/api/Dispatcher.md +2 -2
  4. package/docs/docs/api/ProxyAgent.md +1 -1
  5. package/docs/docs/api/SnapshotAgent.md +616 -0
  6. package/docs/docs/api/WebSocket.md +27 -0
  7. package/index.js +5 -1
  8. package/lib/api/readable.js +49 -29
  9. package/lib/core/request.js +6 -1
  10. package/lib/core/tree.js +1 -1
  11. package/lib/core/util.js +0 -1
  12. package/lib/dispatcher/client-h1.js +8 -17
  13. package/lib/dispatcher/proxy-agent.js +67 -71
  14. package/lib/handler/cache-handler.js +4 -1
  15. package/lib/handler/redirect-handler.js +12 -2
  16. package/lib/interceptor/cache.js +2 -2
  17. package/lib/interceptor/dump.js +2 -1
  18. package/lib/interceptor/redirect.js +1 -1
  19. package/lib/mock/mock-agent.js +10 -4
  20. package/lib/mock/snapshot-agent.js +333 -0
  21. package/lib/mock/snapshot-recorder.js +517 -0
  22. package/lib/util/cache.js +1 -1
  23. package/lib/util/promise.js +28 -0
  24. package/lib/web/cache/cache.js +10 -8
  25. package/lib/web/fetch/body.js +35 -24
  26. package/lib/web/fetch/formdata-parser.js +0 -3
  27. package/lib/web/fetch/formdata.js +0 -4
  28. package/lib/web/fetch/index.js +221 -225
  29. package/lib/web/fetch/request.js +15 -7
  30. package/lib/web/fetch/response.js +5 -3
  31. package/lib/web/fetch/util.js +21 -23
  32. package/lib/web/webidl/index.js +1 -1
  33. package/lib/web/websocket/connection.js +0 -9
  34. package/lib/web/websocket/receiver.js +2 -12
  35. package/lib/web/websocket/stream/websocketstream.js +7 -4
  36. package/lib/web/websocket/websocket.js +57 -1
  37. package/package.json +2 -2
  38. package/types/agent.d.ts +0 -4
  39. package/types/client.d.ts +0 -2
  40. package/types/dispatcher.d.ts +0 -6
  41. package/types/h2c-client.d.ts +0 -2
  42. package/types/index.d.ts +3 -1
  43. package/types/mock-interceptor.d.ts +0 -1
  44. package/types/snapshot-agent.d.ts +107 -0
  45. package/types/webidl.d.ts +10 -0
  46. package/types/websocket.d.ts +2 -0
  47. package/lib/web/fetch/dispatcher-weakref.js +0 -5
@@ -1,5 +1,3 @@
1
- // Ported from https://github.com/nodejs/undici/pull/907
2
-
3
1
  'use strict'
4
2
 
5
3
  const assert = require('node:assert')
@@ -50,23 +48,32 @@ class BodyReadable extends Readable {
50
48
 
51
49
  this[kAbort] = abort
52
50
 
53
- /**
54
- * @type {Consume | null}
55
- */
51
+ /** @type {Consume | null} */
56
52
  this[kConsume] = null
53
+
54
+ /** @type {number} */
57
55
  this[kBytesRead] = 0
58
- /**
59
- * @type {ReadableStream|null}
60
- */
56
+
57
+ /** @type {ReadableStream|null} */
61
58
  this[kBody] = null
59
+
60
+ /** @type {boolean} */
62
61
  this[kUsed] = false
62
+
63
+ /** @type {string} */
63
64
  this[kContentType] = contentType
65
+
66
+ /** @type {number|null} */
64
67
  this[kContentLength] = Number.isFinite(contentLength) ? contentLength : null
65
68
 
66
- // Is stream being consumed through Readable API?
67
- // This is an optimization so that we avoid checking
68
- // for 'data' and 'readable' listeners in the hot path
69
- // inside push().
69
+ /**
70
+ * Is stream being consumed through Readable API?
71
+ * This is an optimization so that we avoid checking
72
+ * for 'data' and 'readable' listeners in the hot path
73
+ * inside push().
74
+ *
75
+ * @type {boolean}
76
+ */
70
77
  this[kReading] = false
71
78
  }
72
79
 
@@ -89,16 +96,14 @@ class BodyReadable extends Readable {
89
96
  // promise (i.e micro tick) for installing an 'error' listener will
90
97
  // never get a chance and will always encounter an unhandled exception.
91
98
  if (!this[kUsed]) {
92
- setImmediate(() => {
93
- callback(err)
94
- })
99
+ setImmediate(callback, err)
95
100
  } else {
96
101
  callback(err)
97
102
  }
98
103
  }
99
104
 
100
105
  /**
101
- * @param {string} event
106
+ * @param {string|symbol} event
102
107
  * @param {(...args: any[]) => void} listener
103
108
  * @returns {this}
104
109
  */
@@ -111,7 +116,7 @@ class BodyReadable extends Readable {
111
116
  }
112
117
 
113
118
  /**
114
- * @param {string} event
119
+ * @param {string|symbol} event
115
120
  * @param {(...args: any[]) => void} listener
116
121
  * @returns {this}
117
122
  */
@@ -149,12 +154,14 @@ class BodyReadable extends Readable {
149
154
  * @returns {boolean}
150
155
  */
151
156
  push (chunk) {
152
- this[kBytesRead] += chunk ? chunk.length : 0
153
-
154
- if (this[kConsume] && chunk !== null) {
155
- consumePush(this[kConsume], chunk)
156
- return this[kReading] ? super.push(chunk) : true
157
+ if (chunk) {
158
+ this[kBytesRead] += chunk.length
159
+ if (this[kConsume]) {
160
+ consumePush(this[kConsume], chunk)
161
+ return this[kReading] ? super.push(chunk) : true
162
+ }
157
163
  }
164
+
158
165
  return super.push(chunk)
159
166
  }
160
167
 
@@ -340,9 +347,23 @@ function isUnusable (bodyReadable) {
340
347
  return util.isDisturbed(bodyReadable) || isLocked(bodyReadable)
341
348
  }
342
349
 
350
+ /**
351
+ * @typedef {'text' | 'json' | 'blob' | 'bytes' | 'arrayBuffer'} ConsumeType
352
+ */
353
+
354
+ /**
355
+ * @template {ConsumeType} T
356
+ * @typedef {T extends 'text' ? string :
357
+ * T extends 'json' ? unknown :
358
+ * T extends 'blob' ? Blob :
359
+ * T extends 'arrayBuffer' ? ArrayBuffer :
360
+ * T extends 'bytes' ? Uint8Array :
361
+ * never
362
+ * } ConsumeReturnType
363
+ */
343
364
  /**
344
365
  * @typedef {object} Consume
345
- * @property {string} type
366
+ * @property {ConsumeType} type
346
367
  * @property {BodyReadable} stream
347
368
  * @property {((value?: any) => void)} resolve
348
369
  * @property {((err: Error) => void)} reject
@@ -351,9 +372,10 @@ function isUnusable (bodyReadable) {
351
372
  */
352
373
 
353
374
  /**
375
+ * @template {ConsumeType} T
354
376
  * @param {BodyReadable} stream
355
- * @param {string} type
356
- * @returns {Promise<any>}
377
+ * @param {T} type
378
+ * @returns {Promise<ConsumeReturnType<T>>}
357
379
  */
358
380
  function consume (stream, type) {
359
381
  assert(!stream[kConsume])
@@ -363,9 +385,7 @@ function consume (stream, type) {
363
385
  const rState = stream._readableState
364
386
  if (rState.destroyed && rState.closeEmitted === false) {
365
387
  stream
366
- .on('error', err => {
367
- reject(err)
368
- })
388
+ .on('error', reject)
369
389
  .on('close', () => {
370
390
  reject(new TypeError('unusable'))
371
391
  })
@@ -440,7 +460,7 @@ function consumeStart (consume) {
440
460
  /**
441
461
  * @param {Buffer[]} chunks
442
462
  * @param {number} length
443
- * @param {BufferEncoding} encoding
463
+ * @param {BufferEncoding} [encoding='utf8']
444
464
  * @returns {string}
445
465
  */
446
466
  function chunksDecode (chunks, length, encoding) {
@@ -42,7 +42,8 @@ class Request {
42
42
  reset,
43
43
  expectContinue,
44
44
  servername,
45
- throwOnError
45
+ throwOnError,
46
+ maxRedirections
46
47
  }, handler) {
47
48
  if (typeof path !== 'string') {
48
49
  throw new InvalidArgumentError('path must be a string')
@@ -86,6 +87,10 @@ class Request {
86
87
  throw new InvalidArgumentError('invalid throwOnError')
87
88
  }
88
89
 
90
+ if (maxRedirections != null && maxRedirections !== 0) {
91
+ throw new InvalidArgumentError('maxRedirections is not supported, use the redirect interceptor')
92
+ }
93
+
89
94
  this.headersTimeout = headersTimeout
90
95
 
91
96
  this.bodyTimeout = bodyTimeout
package/lib/core/tree.js CHANGED
@@ -86,7 +86,7 @@ class TstNode {
86
86
 
87
87
  /**
88
88
  * @param {Uint8Array} key
89
- * @return {TstNode | null}
89
+ * @returns {TstNode | null}
90
90
  */
91
91
  search (key) {
92
92
  const keylength = key.length
package/lib/core/util.js CHANGED
@@ -5,7 +5,6 @@ const { kDestroyed, kBodyUsed, kListeners, kBody } = require('./symbols')
5
5
  const { IncomingMessage } = require('node:http')
6
6
  const stream = require('node:stream')
7
7
  const net = require('node:net')
8
- const { Blob } = require('node:buffer')
9
8
  const { stringify } = require('node:querystring')
10
9
  const { EventEmitter: EE } = require('node:events')
11
10
  const timers = require('../util/timers')
@@ -60,12 +60,12 @@ const removeAllListeners = util.removeAllListeners
60
60
 
61
61
  let extractBody
62
62
 
63
- async function lazyllhttp () {
63
+ function lazyllhttp () {
64
64
  const llhttpWasmData = process.env.JEST_WORKER_ID ? require('../llhttp/llhttp-wasm.js') : undefined
65
65
 
66
66
  let mod
67
67
  try {
68
- mod = await WebAssembly.compile(require('../llhttp/llhttp_simd-wasm.js'))
68
+ mod = new WebAssembly.Module(require('../llhttp/llhttp_simd-wasm.js'))
69
69
  } catch (e) {
70
70
  /* istanbul ignore next */
71
71
 
@@ -73,10 +73,10 @@ async function lazyllhttp () {
73
73
  // being enabled, but the occurring of this other error
74
74
  // * https://github.com/emscripten-core/emscripten/issues/11495
75
75
  // got me to remove that check to avoid breaking Node 12.
76
- mod = await WebAssembly.compile(llhttpWasmData || require('../llhttp/llhttp-wasm.js'))
76
+ mod = new WebAssembly.Module(llhttpWasmData || require('../llhttp/llhttp-wasm.js'))
77
77
  }
78
78
 
79
- return await WebAssembly.instantiate(mod, {
79
+ return new WebAssembly.Instance(mod, {
80
80
  env: {
81
81
  /**
82
82
  * @param {number} p
@@ -165,11 +165,6 @@ async function lazyllhttp () {
165
165
  }
166
166
 
167
167
  let llhttpInstance = null
168
- /**
169
- * @type {Promise<WebAssembly.Instance>|null}
170
- */
171
- let llhttpPromise = lazyllhttp()
172
- llhttpPromise.catch()
173
168
 
174
169
  /**
175
170
  * @type {Parser|null}
@@ -732,7 +727,7 @@ class Parser {
732
727
  // We must wait a full event loop cycle to reuse this socket to make sure
733
728
  // that non-spec compliant servers are not closing the connection even if they
734
729
  // said they won't.
735
- setImmediate(() => client[kResume]())
730
+ setImmediate(client[kResume])
736
731
  } else {
737
732
  client[kResume]()
738
733
  }
@@ -769,11 +764,7 @@ async function connectH1 (client, socket) {
769
764
  client[kSocket] = socket
770
765
 
771
766
  if (!llhttpInstance) {
772
- const noop = () => {}
773
- socket.on('error', noop)
774
- llhttpInstance = await llhttpPromise
775
- llhttpPromise = null
776
- socket.off('error', noop)
767
+ llhttpInstance = lazyllhttp()
777
768
  }
778
769
 
779
770
  if (socket.errored) {
@@ -1297,9 +1288,9 @@ function writeStream (abort, body, client, request, socket, contentLength, heade
1297
1288
  .on('error', onFinished)
1298
1289
 
1299
1290
  if (body.errorEmitted ?? body.errored) {
1300
- setImmediate(() => onFinished(body.errored))
1291
+ setImmediate(onFinished, body.errored)
1301
1292
  } else if (body.endEmitted ?? body.readableEnded) {
1302
- setImmediate(() => onFinished(null))
1293
+ setImmediate(onFinished, null)
1303
1294
  }
1304
1295
 
1305
1296
  if (body.closeEmitted ?? body.closed) {
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const { kProxy, kClose, kDestroy, kDispatch, kConnector } = require('../core/symbols')
3
+ const { kProxy, kClose, kDestroy, kDispatch } = require('../core/symbols')
4
4
  const { URL } = require('node:url')
5
5
  const Agent = require('./agent')
6
6
  const Pool = require('./pool')
@@ -27,61 +27,69 @@ function defaultFactory (origin, opts) {
27
27
 
28
28
  const noop = () => {}
29
29
 
30
- class ProxyClient extends DispatcherBase {
31
- #client = null
32
- constructor (origin, opts) {
33
- if (typeof origin === 'string') {
34
- origin = new URL(origin)
35
- }
30
+ function defaultAgentFactory (origin, opts) {
31
+ if (opts.connections === 1) {
32
+ return new Client(origin, opts)
33
+ }
34
+ return new Pool(origin, opts)
35
+ }
36
36
 
37
- if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
38
- throw new InvalidArgumentError('ProxyClient only supports http and https protocols')
39
- }
37
+ class Http1ProxyWrapper extends DispatcherBase {
38
+ #client
40
39
 
40
+ constructor (proxyUrl, { headers = {}, connect, factory }) {
41
41
  super()
42
+ if (!proxyUrl) {
43
+ throw new InvalidArgumentError('Proxy URL is mandatory')
44
+ }
42
45
 
43
- this.#client = new Client(origin, opts)
44
- }
45
-
46
- async [kClose] () {
47
- await this.#client.close()
48
- }
49
-
50
- async [kDestroy] () {
51
- await this.#client.destroy()
46
+ this[kProxyHeaders] = headers
47
+ if (factory) {
48
+ this.#client = factory(proxyUrl, { connect })
49
+ } else {
50
+ this.#client = new Client(proxyUrl, { connect })
51
+ }
52
52
  }
53
53
 
54
- async [kDispatch] (opts, handler) {
55
- const { method, origin } = opts
56
- if (method === 'CONNECT') {
57
- this.#client[kConnector]({
58
- origin,
59
- port: opts.port || defaultProtocolPort(opts.protocol),
60
- path: opts.host,
61
- signal: opts.signal,
62
- headers: {
63
- ...this[kProxyHeaders],
64
- host: opts.host
65
- },
66
- servername: this[kProxyTls]?.servername || opts.servername
67
- },
68
- (err, socket) => {
69
- if (err) {
70
- handler.callback(err)
71
- } else {
72
- handler.callback(null, { socket, statusCode: 200 })
54
+ [kDispatch] (opts, handler) {
55
+ const onHeaders = handler.onHeaders
56
+ handler.onHeaders = function (statusCode, data, resume) {
57
+ if (statusCode === 407) {
58
+ if (typeof handler.onError === 'function') {
59
+ handler.onError(new InvalidArgumentError('Proxy Authentication Required (407)'))
73
60
  }
61
+ return
74
62
  }
75
- )
76
- return
63
+ if (onHeaders) onHeaders.call(this, statusCode, data, resume)
77
64
  }
78
- if (typeof origin === 'string') {
79
- opts.origin = new URL(origin)
65
+
66
+ // Rewrite request as an HTTP1 Proxy request, without tunneling.
67
+ const {
68
+ origin,
69
+ path = '/',
70
+ headers = {}
71
+ } = opts
72
+
73
+ opts.path = origin + path
74
+
75
+ if (!('host' in headers) && !('Host' in headers)) {
76
+ const { host } = new URL(origin)
77
+ headers.host = host
80
78
  }
79
+ opts.headers = { ...this[kProxyHeaders], ...headers }
80
+
81
+ return this.#client[kDispatch](opts, handler)
82
+ }
81
83
 
82
- return this.#client.dispatch(opts, handler)
84
+ async [kClose] () {
85
+ return this.#client.close()
86
+ }
87
+
88
+ async [kDestroy] (err) {
89
+ return this.#client.destroy(err)
83
90
  }
84
91
  }
92
+
85
93
  class ProxyAgent extends DispatcherBase {
86
94
  constructor (opts) {
87
95
  if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) {
@@ -104,6 +112,7 @@ class ProxyAgent extends DispatcherBase {
104
112
  this[kRequestTls] = opts.requestTls
105
113
  this[kProxyTls] = opts.proxyTls
106
114
  this[kProxyHeaders] = opts.headers || {}
115
+ this[kTunnelProxy] = proxyTunnel
107
116
 
108
117
  if (opts.auth && opts.token) {
109
118
  throw new InvalidArgumentError('opts.auth cannot be used in combination with opts.token')
@@ -116,21 +125,25 @@ class ProxyAgent extends DispatcherBase {
116
125
  this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
117
126
  }
118
127
 
119
- const factory = (!proxyTunnel && protocol === 'http:')
120
- ? (origin, options) => {
121
- if (origin.protocol === 'http:') {
122
- return new ProxyClient(origin, options)
123
- }
124
- return new Client(origin, options)
125
- }
126
- : undefined
127
-
128
128
  const connect = buildConnector({ ...opts.proxyTls })
129
129
  this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
130
- this[kClient] = clientFactory(url, { connect, factory })
131
- this[kTunnelProxy] = proxyTunnel
130
+
131
+ const agentFactory = opts.factory || defaultAgentFactory
132
+ const factory = (origin, options) => {
133
+ const { protocol } = new URL(origin)
134
+ if (!this[kTunnelProxy] && protocol === 'http:' && this[kProxy].protocol === 'http:') {
135
+ return new Http1ProxyWrapper(this[kProxy].uri, {
136
+ headers: this[kProxyHeaders],
137
+ connect,
138
+ factory: agentFactory
139
+ })
140
+ }
141
+ return agentFactory(origin, options)
142
+ }
143
+ this[kClient] = clientFactory(url, { connect })
132
144
  this[kAgent] = new Agent({
133
145
  ...opts,
146
+ factory,
134
147
  connect: async (opts, callback) => {
135
148
  let requestedPath = opts.host
136
149
  if (!opts.port) {
@@ -185,10 +198,6 @@ class ProxyAgent extends DispatcherBase {
185
198
  headers.host = host
186
199
  }
187
200
 
188
- if (!this.#shouldConnect(new URL(opts.origin))) {
189
- opts.path = opts.origin + opts.path
190
- }
191
-
192
201
  return this[kAgent].dispatch(
193
202
  {
194
203
  ...opts,
@@ -221,19 +230,6 @@ class ProxyAgent extends DispatcherBase {
221
230
  await this[kAgent].destroy()
222
231
  await this[kClient].destroy()
223
232
  }
224
-
225
- #shouldConnect (uri) {
226
- if (typeof uri === 'string') {
227
- uri = new URL(uri)
228
- }
229
- if (this[kTunnelProxy]) {
230
- return true
231
- }
232
- if (uri.protocol !== 'http:' || this[kProxy].protocol !== 'http:') {
233
- return true
234
- }
235
- return false
236
- }
237
233
  }
238
234
 
239
235
  /**
@@ -241,7 +241,10 @@ class CacheHandler {
241
241
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
242
242
  */
243
243
  function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives) {
244
- if (statusCode !== 200 && statusCode !== 307) {
244
+ // Allow caching for status codes 200 and 307 (original behavior)
245
+ // Also allow caching for other status codes that are heuristically cacheable
246
+ // when they have explicit cache directives
247
+ if (statusCode !== 200 && statusCode !== 307 && !HEURISTICALLY_CACHEABLE_STATUS_CODES.includes(statusCode)) {
245
248
  return false
246
249
  }
247
250
 
@@ -42,7 +42,8 @@ class RedirectHandler {
42
42
 
43
43
  this.dispatch = dispatch
44
44
  this.location = null
45
- this.opts = { ...opts, maxRedirections: 0 } // opts must be a copy
45
+ const { maxRedirections: _, ...cleanOpts } = opts
46
+ this.opts = cleanOpts // opts must be a copy, exclude maxRedirections
46
47
  this.maxRedirections = maxRedirections
47
48
  this.handler = handler
48
49
  this.history = []
@@ -132,13 +133,22 @@ class RedirectHandler {
132
133
  const { origin, pathname, search } = util.parseURL(new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)))
133
134
  const path = search ? `${pathname}${search}` : pathname
134
135
 
136
+ // Check for redirect loops by seeing if we've already visited this URL in our history
137
+ // This catches the case where Client/Pool try to handle cross-origin redirects but fail
138
+ // and keep redirecting to the same URL in an infinite loop
139
+ const redirectUrlString = `${origin}${path}`
140
+ for (const historyUrl of this.history) {
141
+ if (historyUrl.toString() === redirectUrlString) {
142
+ throw new InvalidArgumentError(`Redirect loop detected. Cannot redirect to ${origin}. This typically happens when using a Client or Pool with cross-origin redirects. Use an Agent for cross-origin redirects.`)
143
+ }
144
+ }
145
+
135
146
  // Remove headers referring to the original URL.
136
147
  // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
137
148
  // https://tools.ietf.org/html/rfc7231#section-6.4
138
149
  this.opts.headers = cleanRequestHeaders(this.opts.headers, statusCode === 303, this.opts.origin !== origin)
139
150
  this.opts.path = path
140
151
  this.opts.origin = origin
141
- this.opts.maxRedirections = 0
142
152
  this.opts.query = null
143
153
  }
144
154
 
@@ -301,11 +301,11 @@ module.exports = (opts = {}) => {
301
301
  assertCacheMethods(methods, 'opts.methods')
302
302
 
303
303
  if (typeof cacheByDefault !== 'undefined' && typeof cacheByDefault !== 'number') {
304
- throw new TypeError(`exepcted opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`)
304
+ throw new TypeError(`expected opts.cacheByDefault to be number or undefined, got ${typeof cacheByDefault}`)
305
305
  }
306
306
 
307
307
  if (typeof type !== 'undefined' && type !== 'shared' && type !== 'private') {
308
- throw new TypeError(`exepcted opts.type to be shared, private, or undefined, got ${typeof type}`)
308
+ throw new TypeError(`expected opts.type to be shared, private, or undefined, got ${typeof type}`)
309
309
  }
310
310
 
311
311
  const globalOpts = {
@@ -57,7 +57,8 @@ class DumpHandler extends DecoratorHandler {
57
57
  return
58
58
  }
59
59
 
60
- err = this.#controller.reason ?? err
60
+ // On network errors before connect, controller will be null
61
+ err = this.#controller?.reason ?? err
61
62
 
62
63
  super.onResponseError(controller, err)
63
64
  }
@@ -11,7 +11,7 @@ function createRedirectInterceptor ({ maxRedirections: defaultMaxRedirections }
11
11
  return dispatch(opts, handler)
12
12
  }
13
13
 
14
- const dispatchOpts = { ...rest, maxRedirections: 0 } // Stop sub dispatcher from also redirecting.
14
+ const dispatchOpts = { ...rest } // Stop sub dispatcher from also redirecting.
15
15
  const redirectHandler = new RedirectHandler(dispatch, maxRedirections, dispatchOpts, handler)
16
16
  return dispatch(dispatchOpts, redirectHandler)
17
17
  }
@@ -17,7 +17,8 @@ const {
17
17
  kMockAgentAddCallHistoryLog,
18
18
  kMockAgentMockCallHistoryInstance,
19
19
  kMockAgentAcceptsNonStandardSearchParameters,
20
- kMockCallHistoryAddLog
20
+ kMockCallHistoryAddLog,
21
+ kIgnoreTrailingSlash
21
22
  } = require('./mock-symbols')
22
23
  const MockClient = require('./mock-client')
23
24
  const MockPool = require('./mock-pool')
@@ -37,6 +38,7 @@ class MockAgent extends Dispatcher {
37
38
  this[kIsMockActive] = true
38
39
  this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false
39
40
  this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false
41
+ this[kIgnoreTrailingSlash] = mockOptions?.ignoreTrailingSlash ?? false
40
42
 
41
43
  // Instantiate Agent and encapsulate
42
44
  if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
@@ -54,11 +56,15 @@ class MockAgent extends Dispatcher {
54
56
  }
55
57
 
56
58
  get (origin) {
57
- let dispatcher = this[kMockAgentGet](origin)
59
+ const originKey = this[kIgnoreTrailingSlash]
60
+ ? origin.replace(/\/$/, '')
61
+ : origin
62
+
63
+ let dispatcher = this[kMockAgentGet](originKey)
58
64
 
59
65
  if (!dispatcher) {
60
- dispatcher = this[kFactory](origin)
61
- this[kMockAgentSet](origin, dispatcher)
66
+ dispatcher = this[kFactory](originKey)
67
+ this[kMockAgentSet](originKey, dispatcher)
62
68
  }
63
69
  return dispatcher
64
70
  }