undici 7.15.0 → 7.17.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 (93) hide show
  1. package/README.md +48 -2
  2. package/docs/docs/api/Agent.md +1 -0
  3. package/docs/docs/api/Client.md +1 -0
  4. package/docs/docs/api/DiagnosticsChannel.md +57 -0
  5. package/docs/docs/api/Dispatcher.md +86 -0
  6. package/docs/docs/api/Errors.md +0 -1
  7. package/docs/docs/api/RoundRobinPool.md +145 -0
  8. package/docs/docs/api/WebSocket.md +21 -0
  9. package/docs/docs/best-practices/crawling.md +58 -0
  10. package/index-fetch.js +2 -2
  11. package/index.js +8 -9
  12. package/lib/api/api-request.js +22 -8
  13. package/lib/api/api-upgrade.js +2 -1
  14. package/lib/api/readable.js +7 -5
  15. package/lib/core/connect.js +4 -1
  16. package/lib/core/diagnostics.js +28 -1
  17. package/lib/core/errors.js +217 -13
  18. package/lib/core/request.js +5 -1
  19. package/lib/core/symbols.js +3 -0
  20. package/lib/core/util.js +61 -41
  21. package/lib/dispatcher/agent.js +19 -7
  22. package/lib/dispatcher/balanced-pool.js +10 -0
  23. package/lib/dispatcher/client-h1.js +18 -23
  24. package/lib/dispatcher/client-h2.js +166 -26
  25. package/lib/dispatcher/client.js +64 -59
  26. package/lib/dispatcher/dispatcher-base.js +20 -16
  27. package/lib/dispatcher/env-http-proxy-agent.js +12 -16
  28. package/lib/dispatcher/fixed-queue.js +15 -39
  29. package/lib/dispatcher/h2c-client.js +7 -78
  30. package/lib/dispatcher/pool-base.js +60 -43
  31. package/lib/dispatcher/pool.js +2 -2
  32. package/lib/dispatcher/proxy-agent.js +27 -11
  33. package/lib/dispatcher/round-robin-pool.js +137 -0
  34. package/lib/encoding/index.js +33 -0
  35. package/lib/global.js +19 -1
  36. package/lib/handler/cache-handler.js +84 -27
  37. package/lib/handler/deduplication-handler.js +216 -0
  38. package/lib/handler/retry-handler.js +0 -2
  39. package/lib/interceptor/cache.js +94 -15
  40. package/lib/interceptor/decompress.js +2 -1
  41. package/lib/interceptor/deduplicate.js +109 -0
  42. package/lib/interceptor/dns.js +55 -13
  43. package/lib/mock/mock-agent.js +4 -4
  44. package/lib/mock/mock-errors.js +10 -0
  45. package/lib/mock/mock-utils.js +13 -12
  46. package/lib/mock/snapshot-agent.js +11 -5
  47. package/lib/mock/snapshot-recorder.js +12 -4
  48. package/lib/mock/snapshot-utils.js +4 -4
  49. package/lib/util/cache.js +29 -1
  50. package/lib/util/date.js +534 -140
  51. package/lib/util/runtime-features.js +124 -0
  52. package/lib/web/cookies/index.js +1 -1
  53. package/lib/web/cookies/parse.js +1 -1
  54. package/lib/web/eventsource/eventsource-stream.js +2 -2
  55. package/lib/web/eventsource/eventsource.js +34 -29
  56. package/lib/web/eventsource/util.js +1 -9
  57. package/lib/web/fetch/body.js +45 -61
  58. package/lib/web/fetch/data-url.js +12 -160
  59. package/lib/web/fetch/formdata-parser.js +204 -127
  60. package/lib/web/fetch/index.js +21 -19
  61. package/lib/web/fetch/request.js +6 -0
  62. package/lib/web/fetch/response.js +4 -7
  63. package/lib/web/fetch/util.js +10 -79
  64. package/lib/web/infra/index.js +229 -0
  65. package/lib/web/subresource-integrity/subresource-integrity.js +6 -5
  66. package/lib/web/webidl/index.js +207 -44
  67. package/lib/web/websocket/connection.js +33 -22
  68. package/lib/web/websocket/events.js +1 -1
  69. package/lib/web/websocket/frame.js +9 -15
  70. package/lib/web/websocket/stream/websocketerror.js +22 -1
  71. package/lib/web/websocket/stream/websocketstream.js +17 -8
  72. package/lib/web/websocket/util.js +2 -1
  73. package/lib/web/websocket/websocket.js +32 -42
  74. package/package.json +9 -7
  75. package/types/agent.d.ts +2 -1
  76. package/types/api.d.ts +2 -2
  77. package/types/balanced-pool.d.ts +2 -1
  78. package/types/cache-interceptor.d.ts +1 -0
  79. package/types/client.d.ts +1 -1
  80. package/types/connector.d.ts +2 -2
  81. package/types/diagnostics-channel.d.ts +2 -2
  82. package/types/dispatcher.d.ts +12 -12
  83. package/types/errors.d.ts +5 -15
  84. package/types/fetch.d.ts +4 -4
  85. package/types/formdata.d.ts +1 -1
  86. package/types/h2c-client.d.ts +1 -1
  87. package/types/index.d.ts +9 -1
  88. package/types/interceptors.d.ts +36 -2
  89. package/types/pool.d.ts +1 -1
  90. package/types/readable.d.ts +2 -2
  91. package/types/round-robin-pool.d.ts +41 -0
  92. package/types/webidl.d.ts +82 -21
  93. package/types/websocket.d.ts +9 -9
@@ -29,16 +29,16 @@ const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
29
29
  const { MockCallHistory } = require('./mock-call-history')
30
30
 
31
31
  class MockAgent extends Dispatcher {
32
- constructor (opts) {
32
+ constructor (opts = {}) {
33
33
  super(opts)
34
34
 
35
35
  const mockOptions = buildAndValidateMockOptions(opts)
36
36
 
37
37
  this[kNetConnect] = true
38
38
  this[kIsMockActive] = true
39
- this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false
40
- this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false
41
- this[kIgnoreTrailingSlash] = mockOptions?.ignoreTrailingSlash ?? false
39
+ this[kMockAgentIsCallHistoryEnabled] = mockOptions.enableCallHistory ?? false
40
+ this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions.acceptNonStandardSearchParameters ?? false
41
+ this[kIgnoreTrailingSlash] = mockOptions.ignoreTrailingSlash ?? false
42
42
 
43
43
  // Instantiate Agent and encapsulate
44
44
  if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { UndiciError } = require('../core/errors')
4
4
 
5
+ const kMockNotMatchedError = Symbol.for('undici.error.UND_MOCK_ERR_MOCK_NOT_MATCHED')
6
+
5
7
  /**
6
8
  * The request does not match any registered mock dispatches.
7
9
  */
@@ -12,6 +14,14 @@ class MockNotMatchedError extends UndiciError {
12
14
  this.message = message || 'The request does not match any registered mock dispatches'
13
15
  this.code = 'UND_MOCK_ERR_MOCK_NOT_MATCHED'
14
16
  }
17
+
18
+ static [Symbol.hasInstance] (instance) {
19
+ return instance && instance[kMockNotMatchedError] === true
20
+ }
21
+
22
+ get [kMockNotMatchedError] () {
23
+ return true
24
+ }
15
25
  }
16
26
 
17
27
  module.exports = {
@@ -337,8 +337,7 @@ function mockDispatch (opts, handler) {
337
337
  // synchronously throw the error, which breaks some tests.
338
338
  // Rather, we wait for the callback to resolve if it is a
339
339
  // promise, and then re-run handleReply with the new body.
340
- body.then((newData) => handleReply(mockDispatches, newData))
341
- return
340
+ return body.then((newData) => handleReply(mockDispatches, newData))
342
341
  }
343
342
 
344
343
  const responseData = getResponseData(body)
@@ -367,7 +366,7 @@ function buildMockDispatch () {
367
366
  try {
368
367
  mockDispatch.call(this, opts, handler)
369
368
  } catch (error) {
370
- if (error instanceof MockNotMatchedError) {
369
+ if (error.code === 'UND_MOCK_ERR_MOCK_NOT_MATCHED') {
371
370
  const netConnect = agent[kGetNetConnect]()
372
371
  if (netConnect === false) {
373
372
  throw new MockNotMatchedError(`${error.message}: subsequent request to origin ${origin} was not allowed (net.connect disabled)`)
@@ -398,19 +397,21 @@ function checkNetConnect (netConnect, origin) {
398
397
  }
399
398
 
400
399
  function buildAndValidateMockOptions (opts) {
401
- if (opts) {
402
- const { agent, ...mockOptions } = opts
400
+ const { agent, ...mockOptions } = opts
403
401
 
404
- if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') {
405
- throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
406
- }
402
+ if ('enableCallHistory' in mockOptions && typeof mockOptions.enableCallHistory !== 'boolean') {
403
+ throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
404
+ }
407
405
 
408
- if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
409
- throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
410
- }
406
+ if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
407
+ throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
408
+ }
411
409
 
412
- return mockOptions
410
+ if ('ignoreTrailingSlash' in mockOptions && typeof mockOptions.ignoreTrailingSlash !== 'boolean') {
411
+ throw new InvalidArgumentError('options.ignoreTrailingSlash must to be a boolean')
413
412
  }
413
+
414
+ return mockOptions
414
415
  }
415
416
 
416
417
  module.exports = {
@@ -64,7 +64,9 @@ class SnapshotAgent extends MockAgent {
64
64
  this[kSnapshotLoaded] = false
65
65
 
66
66
  // For recording/update mode, we need a real agent to make actual requests
67
- if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update') {
67
+ // For playback mode, we need a real agent if there are excluded URLs
68
+ if (this[kSnapshotMode] === 'record' || this[kSnapshotMode] === 'update' ||
69
+ (this[kSnapshotMode] === 'playback' && opts.excludeUrls && opts.excludeUrls.length > 0)) {
68
70
  this[kRealAgent] = new Agent(opts)
69
71
  }
70
72
 
@@ -80,6 +82,12 @@ class SnapshotAgent extends MockAgent {
80
82
  handler = WrapHandler.wrap(handler)
81
83
  const mode = this[kSnapshotMode]
82
84
 
85
+ // Check if URL should be excluded (pass through without mocking/recording)
86
+ if (this[kSnapshotRecorder].isUrlExcluded(opts)) {
87
+ // Real agent is guaranteed by constructor when excludeUrls is configured
88
+ return this[kRealAgent].dispatch(opts, handler)
89
+ }
90
+
83
91
  if (mode === 'playback' || mode === 'update') {
84
92
  // Ensure snapshots are loaded
85
93
  if (!this[kSnapshotLoaded]) {
@@ -162,11 +170,9 @@ class SnapshotAgent extends MockAgent {
162
170
  headers: responseData.headers,
163
171
  body: responseBody,
164
172
  trailers: responseData.trailers
165
- }).then(() => {
166
- handler.onResponseEnd(controller, trailers)
167
- }).catch((error) => {
168
- handler.onResponseError(controller, error)
169
173
  })
174
+ .then(() => handler.onResponseEnd(controller, trailers))
175
+ .catch((error) => handler.onResponseError(controller, error))
170
176
  }
171
177
  }
172
178
 
@@ -283,8 +283,7 @@ class SnapshotRecorder {
283
283
  }
284
284
 
285
285
  // Check URL exclusion patterns
286
- const url = new URL(requestOpts.path, requestOpts.origin).toString()
287
- if (this.#isUrlExcluded(url)) {
286
+ if (this.isUrlExcluded(requestOpts)) {
288
287
  return // Skip recording
289
288
  }
290
289
 
@@ -330,6 +329,16 @@ class SnapshotRecorder {
330
329
  }
331
330
  }
332
331
 
332
+ /**
333
+ * Checks if a URL should be excluded from recording/playback
334
+ * @param {SnapshotRequestOptions} requestOpts - Request options to check
335
+ * @returns {boolean} - True if URL is excluded
336
+ */
337
+ isUrlExcluded (requestOpts) {
338
+ const url = new URL(requestOpts.path, requestOpts.origin).toString()
339
+ return this.#isUrlExcluded(url)
340
+ }
341
+
333
342
  /**
334
343
  * Finds a matching snapshot for the given request
335
344
  * Returns the appropriate response based on call count for sequential responses
@@ -344,8 +353,7 @@ class SnapshotRecorder {
344
353
  }
345
354
 
346
355
  // Check URL exclusion patterns
347
- const url = new URL(requestOpts.path, requestOpts.origin).toString()
348
- if (this.#isUrlExcluded(url)) {
356
+ if (this.isUrlExcluded(requestOpts)) {
349
357
  return undefined // Skip playback
350
358
  }
351
359
 
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { InvalidArgumentError } = require('../core/errors')
4
+ const { runtimeFeatures } = require('../util/runtime-features.js')
4
5
 
5
6
  /**
6
7
  * @typedef {Object} HeaderFilters
@@ -25,10 +26,9 @@ function createHeaderFilters (matchOptions = {}) {
25
26
  }
26
27
  }
27
28
 
28
- let crypto
29
- try {
30
- crypto = require('node:crypto')
31
- } catch { /* Fallback if crypto is not available */ }
29
+ const crypto = runtimeFeatures.has('crypto')
30
+ ? require('node:crypto')
31
+ : null
32
32
 
33
33
  /**
34
34
  * @callback HashIdFunction
package/lib/util/cache.js CHANGED
@@ -364,6 +364,33 @@ function assertCacheMethods (methods, name = 'CacheMethods') {
364
364
  }
365
365
  }
366
366
 
367
+ /**
368
+ * Creates a string key for request deduplication purposes.
369
+ * This key is used to identify in-flight requests that can be shared.
370
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} cacheKey
371
+ * @param {Set<string>} [excludeHeaders] Set of lowercase header names to exclude from the key
372
+ * @returns {string}
373
+ */
374
+ function makeDeduplicationKey (cacheKey, excludeHeaders) {
375
+ // Create a deterministic string key from the cache key
376
+ // Include origin, method, path, and sorted headers
377
+ let key = `${cacheKey.origin}:${cacheKey.method}:${cacheKey.path}`
378
+
379
+ if (cacheKey.headers) {
380
+ const sortedHeaders = Object.keys(cacheKey.headers).sort()
381
+ for (const header of sortedHeaders) {
382
+ // Skip excluded headers
383
+ if (excludeHeaders?.has(header.toLowerCase())) {
384
+ continue
385
+ }
386
+ const value = cacheKey.headers[header]
387
+ key += `:${header}=${Array.isArray(value) ? value.join(',') : value}`
388
+ }
389
+ }
390
+
391
+ return key
392
+ }
393
+
367
394
  module.exports = {
368
395
  makeCacheKey,
369
396
  normalizeHeaders,
@@ -373,5 +400,6 @@ module.exports = {
373
400
  parseVaryHeader,
374
401
  isEtagUsable,
375
402
  assertCacheMethods,
376
- assertCacheStore
403
+ assertCacheStore,
404
+ makeDeduplicationKey
377
405
  }