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
@@ -11,7 +11,7 @@ const {
11
11
  getResponseState
12
12
  } = require('./response')
13
13
  const { HeadersList } = require('./headers')
14
- const { Request, cloneRequest, getRequestDispatcher, getRequestState } = require('./request')
14
+ const { Request, cloneRequest, getRequestDispatcher, getRequestState, removeRequestAbortListener } = require('./request')
15
15
  const zlib = require('node:zlib')
16
16
  const {
17
17
  makePolicyContainer,
@@ -208,7 +208,7 @@ function fetch (input, init = undefined) {
208
208
  let controller = null
209
209
 
210
210
  // 11. Add the following abort steps to requestObject’s signal:
211
- addAbortListener(
211
+ const removeAbortListener = addAbortListener(
212
212
  requestObject.signal,
213
213
  () => {
214
214
  // 1. Set locallyAborted to true.
@@ -228,6 +228,15 @@ function fetch (input, init = undefined) {
228
228
  }
229
229
  )
230
230
 
231
+ // Remove the `abort` listeners registered above and in the Request
232
+ // constructor once the fetch has settled. Without this, reusing a single
233
+ // signal across many requests leaks listeners and Node.js emits a
234
+ // MaxListenersExceededWarning. See https://github.com/nodejs/undici/issues/5285
235
+ const cleanupAbortListeners = () => {
236
+ removeAbortListener()
237
+ removeRequestAbortListener(requestObject)
238
+ }
239
+
231
240
  // 12. Let handleFetchDone given response response be to finalize and
232
241
  // report timing with response, globalObject, and "fetch".
233
242
  // see function handleFetchDone
@@ -252,6 +261,7 @@ function fetch (input, init = undefined) {
252
261
  // deserializedError.
253
262
 
254
263
  abortFetch(p, request, responseObject, controller.serializedAbortReason, controller.controller)
264
+ cleanupAbortListeners()
255
265
  return
256
266
  }
257
267
 
@@ -259,6 +269,7 @@ function fetch (input, init = undefined) {
259
269
  // and terminate these substeps.
260
270
  if (response.type === 'error') {
261
271
  p.reject(new TypeError('fetch failed', { cause: response.error }))
272
+ cleanupAbortListeners()
262
273
  return
263
274
  }
264
275
 
@@ -273,7 +284,10 @@ function fetch (input, init = undefined) {
273
284
 
274
285
  controller = fetching({
275
286
  request,
276
- processResponseEndOfBody: handleFetchDone,
287
+ processResponseEndOfBody: (response) => {
288
+ handleFetchDone(response)
289
+ cleanupAbortListeners()
290
+ },
277
291
  processResponse,
278
292
  dispatcher: getRequestDispatcher(requestObject), // undici
279
293
  // Keep requestObject alive to prevent its AbortController from being GC'd
@@ -2184,6 +2198,8 @@ async function httpNetworkFetch (
2184
2198
  origin: url.origin,
2185
2199
  method: request.method,
2186
2200
  body: agent.isMockActive ? request.body && (request.body.source || request.body.stream) : body,
2201
+ // Preserve the serialized fetch body for MockAgent net-connect fallthroughs.
2202
+ __mockAgentBodyForDispatch: body,
2187
2203
  headers: request.headersList.entries,
2188
2204
  maxRedirections: 0,
2189
2205
  upgrade: request.mode === 'websocket' ? 'websocket' : undefined,
@@ -97,6 +97,13 @@ class Request {
97
97
 
98
98
  #state
99
99
 
100
+ /**
101
+ * Removes the `abort` listener that makes this request's signal follow the
102
+ * passed signal. `null` when no such listener was registered.
103
+ * @type {(() => void) | null}
104
+ */
105
+ #abortCleanup = null
106
+
100
107
  // https://fetch.spec.whatwg.org/#dom-request
101
108
  constructor (input, init = undefined) {
102
109
  webidl.util.markAsUncloneable(this)
@@ -436,12 +443,23 @@ class Request {
436
443
  setMaxListeners(1500, signal)
437
444
  }
438
445
 
439
- util.addAbortListener(signal, abort)
446
+ const removeAbortListener = util.addAbortListener(signal, abort)
440
447
  // The third argument must be a registry key to be unregistered.
441
448
  // Without it, you cannot unregister.
442
449
  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
443
450
  // abort is used as the unregister key. (because it is unique)
444
451
  requestFinalizer.register(ac, { signal, abort }, abort)
452
+
453
+ // Allow the listener to be removed deterministically once the fetch
454
+ // that owns this request has settled, instead of relying solely on the
455
+ // FinalizationRegistry (i.e. garbage collection). Reusing a single
456
+ // signal across many requests would otherwise leak listeners.
457
+ // See https://github.com/nodejs/undici/issues/5285
458
+ this.#abortCleanup = () => {
459
+ requestFinalizer.unregister(abort)
460
+ removeAbortListener()
461
+ this.#abortCleanup = null
462
+ }
445
463
  }
446
464
  }
447
465
 
@@ -868,15 +886,25 @@ class Request {
868
886
  static setRequestState (request, newState) {
869
887
  request.#state = newState
870
888
  }
889
+
890
+ /**
891
+ * Removes the `abort` listener that makes this request's signal follow the
892
+ * signal passed to its constructor, if any. Idempotent.
893
+ * @param {Request} request
894
+ */
895
+ static removeRequestAbortListener (request) {
896
+ request.#abortCleanup?.()
897
+ }
871
898
  }
872
899
 
873
- const { setRequestSignal, getRequestDispatcher, setRequestDispatcher, setRequestHeaders, getRequestState, setRequestState } = Request
900
+ const { setRequestSignal, getRequestDispatcher, setRequestDispatcher, setRequestHeaders, getRequestState, setRequestState, removeRequestAbortListener } = Request
874
901
  Reflect.deleteProperty(Request, 'setRequestSignal')
875
902
  Reflect.deleteProperty(Request, 'getRequestDispatcher')
876
903
  Reflect.deleteProperty(Request, 'setRequestDispatcher')
877
904
  Reflect.deleteProperty(Request, 'setRequestHeaders')
878
905
  Reflect.deleteProperty(Request, 'getRequestState')
879
906
  Reflect.deleteProperty(Request, 'setRequestState')
907
+ Reflect.deleteProperty(Request, 'removeRequestAbortListener')
880
908
 
881
909
  mixinBody(Request, getRequestState)
882
910
 
@@ -1111,5 +1139,6 @@ module.exports = {
1111
1139
  fromInnerRequest,
1112
1140
  cloneRequest,
1113
1141
  getRequestDispatcher,
1114
- getRequestState
1142
+ getRequestState,
1143
+ removeRequestAbortListener
1115
1144
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "8.2.0",
3
+ "version": "8.4.0",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -115,8 +115,8 @@
115
115
  "@sinonjs/fake-timers": "^12.0.0",
116
116
  "@types/node": "^22.0.0",
117
117
  "abort-controller": "^3.0.0",
118
- "borp": "^0.20.0",
119
- "c8": "^10.0.0",
118
+ "borp": "^1.0.0",
119
+ "c8": "^11.0.0",
120
120
  "cross-env": "^10.0.0",
121
121
  "dns-packet": "^5.4.0",
122
122
  "esbuild": "^0.28.0",
@@ -125,7 +125,7 @@
125
125
  "husky": "^9.0.7",
126
126
  "jest": "^30.0.5",
127
127
  "jsondiffpatch": "^0.7.3",
128
- "neostandard": "^0.12.0",
128
+ "neostandard": "^0.13.0",
129
129
  "node-forge": "^1.3.1",
130
130
  "proxy": "^4.0.0",
131
131
  "tsd": "^0.33.0",
package/types/client.d.ts CHANGED
@@ -3,7 +3,7 @@ import Dispatcher from './dispatcher'
3
3
  import buildConnector from './connector'
4
4
  import TClientStats from './client-stats'
5
5
 
6
- type ClientConnectOptions = Omit<Dispatcher.ConnectOptions, 'origin'>
6
+ type ClientConnectOptions<TOpaque = null> = Omit<Dispatcher.ConnectOptions<TOpaque>, 'origin'>
7
7
 
8
8
  /**
9
9
  * A basic HTTP/1.1 client, mapped on top a single TCP/TLS connection. Pipelining is disabled by default.
@@ -20,12 +20,12 @@ export class Client extends Dispatcher {
20
20
  readonly stats: TClientStats
21
21
 
22
22
  // Override dispatcher APIs.
23
- override connect (
24
- options: ClientConnectOptions
25
- ): Promise<Dispatcher.ConnectData>
26
- override connect (
27
- options: ClientConnectOptions,
28
- callback: (err: Error | null, data: Dispatcher.ConnectData) => void
23
+ override connect<TOpaque = null> (
24
+ options: ClientConnectOptions<TOpaque>
25
+ ): Promise<Dispatcher.ConnectData<TOpaque>>
26
+ override connect<TOpaque = null> (
27
+ options: ClientConnectOptions<TOpaque>,
28
+ callback: (err: Error | null, data: Dispatcher.ConnectData<TOpaque>) => void
29
29
  ): void
30
30
  }
31
31
 
@@ -7,6 +7,7 @@ declare function buildConnector (options?: buildConnector.BuildOptions): buildCo
7
7
  declare namespace buildConnector {
8
8
  export type BuildOptions = (ConnectionOptions | TcpNetConnectOpts | IpcNetConnectOpts) & {
9
9
  allowH2?: boolean;
10
+ preferH2?: boolean;
10
11
  maxCachedSessions?: number | null;
11
12
  socketPath?: string | null;
12
13
  timeout?: number | null;
@@ -121,8 +121,6 @@ declare namespace Dispatcher {
121
121
  bodyTimeout?: number | null;
122
122
  /** Whether the request should stablish a keep-alive or not. Default `false` */
123
123
  reset?: boolean;
124
- /** Whether Undici should throw an error upon receiving a 4xx or 5xx response from the server. Defaults to false */
125
- throwOnError?: boolean;
126
124
  /** For H2, it appends the expect: 100-continue header, and halts the request body until a 100-continue is received from the remote server */
127
125
  expectContinue?: boolean;
128
126
  }
package/types/fetch.d.ts CHANGED
@@ -36,7 +36,10 @@ export class BodyMixin {
36
36
  readonly bytes: () => Promise<Uint8Array>
37
37
  /**
38
38
  * @deprecated This method is not recommended for parsing multipart/form-data bodies in server environments.
39
- * It is recommended to use a library such as [@fastify/busboy](https://www.npmjs.com/package/@fastify/busboy) as follows:
39
+ * Calling body.formData() buffers and parses the entire body. Since this is dictated by the spec,
40
+ * this method must only be called on responses from trusted servers.
41
+ * For responses from untrusted or user-controlled servers, use a dedicated streaming parser such as
42
+ * [@fastify/busboy](https://www.npmjs.com/package/@fastify/busboy) and apply application-specific limits as follows:
40
43
  *
41
44
  * @example
42
45
  * ```js
@@ -4,12 +4,6 @@
4
4
  import { File } from 'node:buffer'
5
5
  import { SpecIterableIterator } from './fetch'
6
6
 
7
- declare module 'node:buffer' {
8
- interface File {
9
- readonly [Symbol.toStringTag]: string
10
- }
11
- }
12
-
13
7
  /**
14
8
  * A `string` or `File` that represents a single value from a set of `FormData` key-value pairs.
15
9
  */
@@ -8,7 +8,7 @@ export default Interceptors
8
8
  declare namespace Interceptors {
9
9
  export type DumpInterceptorOpts = { maxSize?: number }
10
10
  export type RetryInterceptorOpts = RetryHandler.RetryOptions
11
- export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirect?: boolean }
11
+ export type RedirectInterceptorOpts = { maxRedirections?: number, throwOnMaxRedirect?: boolean, stripHeadersOnRedirect?: string[], stripHeadersOnCrossOriginRedirect?: string[] }
12
12
  export type DecompressInterceptorOpts = {
13
13
  skipErrorResponses?: boolean
14
14
  skipStatusCodes?: number[]
@@ -30,7 +30,9 @@ declare namespace SnapshotRecorder {
30
30
  ignoreHeaders?: string[]
31
31
  excludeHeaders?: string[]
32
32
  matchBody?: boolean
33
+ normalizeBody?: (body: string | Buffer | null | undefined) => string
33
34
  matchQuery?: boolean
35
+ normalizeQuery?: (query: URLSearchParams) => string
34
36
  caseSensitive?: boolean
35
37
  shouldRecord?: (requestOpts: any) => boolean
36
38
  shouldPlayback?: (requestOpts: any) => boolean
@@ -98,7 +100,9 @@ declare namespace SnapshotAgent {
98
100
  ignoreHeaders?: string[]
99
101
  excludeHeaders?: string[]
100
102
  matchBody?: boolean
103
+ normalizeBody?: (body: string | Buffer | null | undefined) => string
101
104
  matchQuery?: boolean
105
+ normalizeQuery?: (query: URLSearchParams) => string
102
106
  caseSensitive?: boolean
103
107
  shouldRecord?: (requestOpts: any) => boolean
104
108
  shouldPlayback?: (requestOpts: any) => boolean