undici 8.0.1 → 8.0.3

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.
@@ -533,7 +533,7 @@ The `RequestOptions.method` property should not be value `'CONNECT'`.
533
533
 
534
534
  `body` contains the following additional extensions:
535
535
 
536
- - `dump({ limit: Integer })`, dump the response by reading up to `limit` bytes without killing the socket (optional) - Default: 262144.
536
+ - `dump({ limit: Integer })`, dump the response by reading up to `limit` bytes without killing the socket (optional) - Default: 131072.
537
537
 
538
538
  Note that body will still be a `Readable` even if it is empty, but attempting to deserialize it with `json()` will result in an exception. Recommended way to ensure there is a body to deserialize is to check if status code is not 204, and `content-type` header starts with `application/json`.
539
539
 
@@ -1031,7 +1031,7 @@ const client = new Client("http://service.example").compose(
1031
1031
  The `dump` interceptor enables you to dump the response body from a request upon a given limit.
1032
1032
 
1033
1033
  **Options**
1034
- - `maxSize` - The maximum size (in bytes) of the response body to dump. If the size of the request's body exceeds this value then the connection will be closed. Default: `1048576`.
1034
+ - `maxSize` - The maximum size (in bytes) of the response body to dump. If the size of the response's body exceeds this value then the connection will be closed. Default: `1048576`.
1035
1035
 
1036
1036
  > The `Dispatcher#options` also gets extended with the options `dumpMaxSize`, `abortOnDumped`, and `waitForTrailers` which can be used to configure the interceptor at a request-per-request basis.
1037
1037
 
@@ -0,0 +1,231 @@
1
+ # Migrating from Undici 7 to 8
2
+
3
+ This guide covers the changes you are most likely to hit when upgrading an
4
+ application or library from Undici v7 to v8.
5
+
6
+ ## Before you upgrade
7
+
8
+ - Make sure your runtime is Node.js `>= 22.19.0`.
9
+ - If you have custom dispatchers, interceptors, or handlers, review the
10
+ handler API changes before updating.
11
+ - If you rely on HTTP/1.1-only behavior, plan to set `allowH2: false`
12
+ explicitly.
13
+
14
+ ## 1. Update your Node.js version
15
+
16
+ Undici v8 requires Node.js `>= 22.19.0`.
17
+
18
+ If you are still on Node.js 20 or an older Node.js 22 release, upgrade Node.js
19
+ first:
20
+
21
+ ```bash
22
+ node -v
23
+ ```
24
+
25
+ If that command prints a version lower than `v22.19.0`, upgrade Node.js before
26
+ installing Undici v8.
27
+
28
+ ## 2. Migrate custom dispatcher handlers to the v2 API
29
+
30
+ Undici v8 uses the newer dispatcher handler API consistently.
31
+
32
+ If you implemented custom dispatchers, interceptors, or wrappers around
33
+ `dispatch()`, update legacy callbacks such as `onConnect`, `onHeaders`, and
34
+ `onComplete` to the newer callback names.
35
+
36
+ ### Old handler callbacks vs. v8 callbacks
37
+
38
+ | Undici 7 style | Undici 8 style |
39
+ |---|---|
40
+ | `onConnect(abort, context)` | `onRequestStart(controller, context)` |
41
+ | `onHeaders(statusCode, rawHeaders, resume, statusText)` | `onResponseStart(controller, statusCode, headers, statusText)` |
42
+ | `onData(chunk)` | `onResponseData(controller, chunk)` |
43
+ | `onComplete(trailers)` | `onResponseEnd(controller, trailers)` |
44
+ | `onError(err)` | `onResponseError(controller, err)` |
45
+ | `onUpgrade(statusCode, rawHeaders, socket)` | `onRequestUpgrade(controller, statusCode, headers, socket)` |
46
+
47
+ ### Example
48
+
49
+ Before:
50
+
51
+ ```js
52
+ client.dispatch(options, {
53
+ onConnect (abort) {
54
+ this.abort = abort
55
+ },
56
+ onHeaders (statusCode, headers, resume) {
57
+ this.resume = resume
58
+ return true
59
+ },
60
+ onData (chunk) {
61
+ chunks.push(chunk)
62
+ return true
63
+ },
64
+ onComplete (trailers) {
65
+ console.log(trailers)
66
+ },
67
+ onError (err) {
68
+ console.error(err)
69
+ }
70
+ })
71
+ ```
72
+
73
+ After:
74
+
75
+ ```js
76
+ client.dispatch(options, {
77
+ onRequestStart (controller) {
78
+ this.controller = controller
79
+ },
80
+ onResponseStart (controller, statusCode, headers, statusText) {
81
+ console.log(statusCode, statusText, headers)
82
+ },
83
+ onResponseData (controller, chunk) {
84
+ chunks.push(chunk)
85
+ },
86
+ onResponseEnd (controller, trailers) {
87
+ console.log(trailers)
88
+ },
89
+ onResponseError (controller, err) {
90
+ console.error(err)
91
+ }
92
+ })
93
+ ```
94
+
95
+ ### Pause, resume, and abort now go through the controller
96
+
97
+ In Undici v7, legacy handlers could return `false` or keep references to
98
+ `abort()` and `resume()` callbacks. In Undici v8, use the controller instead:
99
+
100
+ ```js
101
+ onRequestStart (controller) {
102
+ this.controller = controller
103
+ }
104
+
105
+ onResponseData (controller, chunk) {
106
+ controller.pause()
107
+ setImmediate(() => controller.resume())
108
+ }
109
+
110
+ onResponseError (controller, err) {
111
+ controller.abort(err)
112
+ }
113
+ ```
114
+
115
+ ### Raw headers and trailers moved to the controller
116
+
117
+ If you need the raw header arrays, read them from the controller:
118
+
119
+ - `controller.rawHeaders`
120
+ - `controller.rawTrailers`
121
+
122
+ ## 3. Update `onBodySent()` handlers
123
+
124
+ If you implemented `onBodySent()`, note that its signature changed.
125
+
126
+ Before, handlers received counters:
127
+
128
+ ```js
129
+ onBodySent (chunkSize, totalBytesSent) {}
130
+ ```
131
+
132
+ In Undici v8, handlers receive the actual chunk:
133
+
134
+ ```js
135
+ onBodySent (chunk) {}
136
+ ```
137
+
138
+ If you need a notification that the whole body has been sent, use
139
+ `onRequestSent()`:
140
+
141
+ ```js
142
+ onRequestSent () {
143
+ console.log('request body fully sent')
144
+ }
145
+ ```
146
+
147
+ ## 4. If you need HTTP/1.1 only, disable HTTP/2 explicitly
148
+
149
+ Undici v8 enables HTTP/2 by default when a TLS server negotiates it via ALPN.
150
+
151
+ If your application depends on HTTP/1.1-specific behavior, set `allowH2: false`
152
+ explicitly.
153
+
154
+ Before:
155
+
156
+ ```js
157
+ const client = new Client('https://example.com')
158
+ ```
159
+
160
+ After, to keep HTTP/1.1 only:
161
+
162
+ ```js
163
+ const client = new Client('https://example.com', {
164
+ allowH2: false
165
+ })
166
+ ```
167
+
168
+ The same applies when you configure an `Agent`:
169
+
170
+ ```js
171
+ const agent = new Agent({
172
+ allowH2: false
173
+ })
174
+ ```
175
+
176
+ ## 5. Use real `Blob` and `File` instances
177
+
178
+ Undici v8 no longer accepts fake Blob-like values that only imitate `Blob` or
179
+ `File` via properties such as `Symbol.toStringTag`.
180
+
181
+ If you were passing custom objects that looked like `Blob`s, replace them with
182
+ actual `Blob` or `File` instances:
183
+
184
+ ```js
185
+ const body = new Blob(['hello'])
186
+ ```
187
+
188
+ ## 6. Avoid depending on the internal global dispatcher symbol
189
+
190
+ `setGlobalDispatcher()` and `getGlobalDispatcher()` remain the public APIs and
191
+ should continue to be used.
192
+
193
+ Internally, Undici v8 stores its dispatcher under
194
+ `Symbol.for('undici.globalDispatcher.2')` and mirrors a v1-compatible wrapper
195
+ for legacy consumers such as Node.js built-in `fetch`.
196
+
197
+ If your code was reading or writing `Symbol.for('undici.globalDispatcher.1')`
198
+ directly, migrate to the public APIs instead:
199
+
200
+ ```js
201
+ import { setGlobalDispatcher, getGlobalDispatcher, Agent } from 'undici'
202
+
203
+ setGlobalDispatcher(new Agent())
204
+ const dispatcher = getGlobalDispatcher()
205
+ ```
206
+
207
+ If you must expose a dispatcher to legacy v1 handler consumers, wrap it with
208
+ `Dispatcher1Wrapper`:
209
+
210
+ ```js
211
+ import { Agent, Dispatcher1Wrapper } from 'undici'
212
+
213
+ const legacyCompatibleDispatcher = new Dispatcher1Wrapper(new Agent())
214
+ ```
215
+
216
+ ## 7. Verify the upgrade
217
+
218
+ After moving to Undici v8, it is worth checking these paths in your test suite:
219
+
220
+ - requests that use a custom `dispatcher`
221
+ - `setGlobalDispatcher()` behavior
222
+ - any custom interceptor or retry handler
223
+ - uploads that use `Blob`, `File`, or `FormData`
224
+ - integrations that depend on HTTP/1.1-only behavior
225
+
226
+ ## Related documentation
227
+
228
+ - [Dispatcher](/docs/api/Dispatcher.md)
229
+ - [Client](/docs/api/Client.md)
230
+ - [Global Installation](/docs/api/GlobalInstallation.md)
231
+ - [Undici Module vs. Node.js Built-in Fetch](/docs/best-practices/undici-vs-builtin-fetch.md)
package/index.js CHANGED
@@ -105,14 +105,14 @@ function makeDispatcher (fn) {
105
105
  url = util.parseURL(url)
106
106
  }
107
107
 
108
- const { agent, dispatcher = getGlobalDispatcher() } = opts
108
+ const { agent, dispatcher = getGlobalDispatcher(), ...restOpts } = opts
109
109
 
110
110
  if (agent) {
111
111
  throw new InvalidArgumentError('unsupported opts.agent. Did you mean opts.client?')
112
112
  }
113
113
 
114
114
  return fn.call(dispatcher, {
115
- ...opts,
115
+ ...restOpts,
116
116
  origin: url.origin,
117
117
  path: url.search ? `${url.pathname}${url.search}` : url.pathname,
118
118
  method: opts.method || (opts.body ? 'PUT' : 'GET')
package/lib/core/util.js CHANGED
@@ -12,8 +12,6 @@ const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
12
12
  const { headerNameLowerCasedRecord } = require('./constants')
13
13
  const { tree } = require('./tree')
14
14
 
15
- const [nodeMajor, nodeMinor] = process.versions.node.split('.', 2).map(v => Number(v))
16
-
17
15
  class BodyAsyncIterable {
18
16
  constructor (body) {
19
17
  this[kBody] = body
@@ -323,7 +321,7 @@ function isIterable (obj) {
323
321
  */
324
322
  function hasSafeIterator (obj) {
325
323
  const prototype = Object.getPrototypeOf(obj)
326
- const ownIterator = Object.prototype.hasOwnProperty.call(obj, Symbol.iterator)
324
+ const ownIterator = Object.hasOwn(obj, Symbol.iterator)
327
325
  return ownIterator || (prototype != null && prototype !== Object.prototype && typeof obj[Symbol.iterator] === 'function')
328
326
  }
329
327
 
@@ -989,8 +987,6 @@ module.exports = {
989
987
  normalizedMethodRecords,
990
988
  isValidPort,
991
989
  isHttpOrHttpsPrefixed,
992
- nodeMajor,
993
- nodeMinor,
994
990
  safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']),
995
991
  wrapRequestBody,
996
992
  setupConnectTimeout,
@@ -72,14 +72,17 @@ class Agent extends DispatcherBase {
72
72
  }
73
73
 
74
74
  [kDispatch] (opts, handler) {
75
- let key
75
+ let origin
76
76
  if (opts.origin && (typeof opts.origin === 'string' || opts.origin instanceof URL)) {
77
- key = String(opts.origin)
77
+ origin = String(opts.origin)
78
78
  } else {
79
79
  throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.')
80
80
  }
81
81
 
82
- if (this[kOrigins].size >= this[kOptions].maxOrigins && !this[kOrigins].has(key)) {
82
+ const allowH2 = opts.allowH2 ?? this[kOptions].allowH2
83
+ const key = allowH2 === false ? `${origin}#http1-only` : origin
84
+
85
+ if (this[kOrigins].size >= this[kOptions].maxOrigins && !this[kOrigins].has(origin)) {
83
86
  throw new MaxOriginsReachedError()
84
87
  }
85
88
 
@@ -96,10 +99,23 @@ class Agent extends DispatcherBase {
96
99
  result.dispatcher.close()
97
100
  }
98
101
  }
99
- this[kOrigins].delete(key)
102
+
103
+ let hasOrigin = false
104
+ for (const entry of this[kClients].values()) {
105
+ if (entry.origin === origin) {
106
+ hasOrigin = true
107
+ break
108
+ }
109
+ }
110
+
111
+ if (!hasOrigin) {
112
+ this[kOrigins].delete(origin)
113
+ }
100
114
  }
101
115
  }
102
- dispatcher = this[kFactory](opts.origin, this[kOptions])
116
+ dispatcher = this[kFactory](opts.origin, allowH2 === false
117
+ ? { ...this[kOptions], allowH2: false }
118
+ : this[kOptions])
103
119
  .on('drain', this[kOnDrain])
104
120
  .on('connect', (origin, targets) => {
105
121
  const result = this[kClients].get(key)
@@ -117,8 +133,8 @@ class Agent extends DispatcherBase {
117
133
  this[kOnConnectionError](origin, targets, err)
118
134
  })
119
135
 
120
- this[kClients].set(key, { count: 0, dispatcher })
121
- this[kOrigins].add(key)
136
+ this[kClients].set(key, { count: 0, dispatcher, origin })
137
+ this[kOrigins].add(origin)
122
138
  }
123
139
 
124
140
  return dispatcher.dispatch(opts, handler)
@@ -57,9 +57,6 @@ class BalancedPool extends PoolBase {
57
57
  super()
58
58
 
59
59
  this[kOptions] = { ...util.deepClone(opts) }
60
- this[kOptions].interceptors = opts.interceptors
61
- ? { ...opts.interceptors }
62
- : undefined
63
60
  this[kIndex] = -1
64
61
  this[kCurrentWeight] = 0
65
62
 
@@ -235,9 +235,13 @@ class Client extends DispatcherBase {
235
235
  ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
236
236
  ...connect
237
237
  })
238
- } else if (socketPath != null) {
238
+ } else {
239
239
  const customConnect = connect
240
- connect = (opts, callback) => customConnect({ ...opts, socketPath }, callback)
240
+ connect = (opts, callback) => customConnect({
241
+ ...opts,
242
+ ...(socketPath != null ? { socketPath } : null),
243
+ ...(allowH2 != null ? { allowH2 } : null)
244
+ }, callback)
241
245
  }
242
246
 
243
247
  this[kUrl] = util.parseOrigin(url)
@@ -138,6 +138,10 @@ class DispatcherBase extends Dispatcher {
138
138
  throw new InvalidArgumentError('opts must be an object.')
139
139
  }
140
140
 
141
+ if (opts.dispatcher) {
142
+ throw new InvalidArgumentError('opts.dispatcher is not supported by instance methods. Pass opts.dispatcher to the top-level undici functions or call the dispatcher instance method directly.')
143
+ }
144
+
141
145
  if (this[kDestroyed] || this[kOnDestroyed]) {
142
146
  throw new ClientDestroyedError()
143
147
  }
@@ -86,6 +86,12 @@ class Dispatcher1Wrapper extends Dispatcher {
86
86
  }
87
87
 
88
88
  dispatch (opts, handler) {
89
+ // Legacy (v1) consumers do not support HTTP/2, so force HTTP/1.1.
90
+ // See https://github.com/nodejs/undici/issues/4989
91
+ if (opts.allowH2 !== false) {
92
+ opts = { ...opts, allowH2: false }
93
+ }
94
+
89
95
  return this.#dispatcher.dispatch(opts, Dispatcher1Wrapper.wrapHandler(handler))
90
96
  }
91
97
 
@@ -10,22 +10,6 @@ const DEFAULT_PORTS = {
10
10
  'https:': 443
11
11
  }
12
12
 
13
- /**
14
- * Normalizes a proxy URL by prepending a scheme if one is missing.
15
- * This matches the behavior of curl and Go's httpproxy package, which
16
- * assume http:// for scheme-less proxy values.
17
- *
18
- * @param {string} proxyUrl - The proxy URL to normalize
19
- * @param {string} defaultScheme - The scheme to prepend if missing ('http' or 'https')
20
- * @returns {string} The normalized proxy URL
21
- */
22
- function normalizeProxyUrl (proxyUrl, defaultScheme) {
23
- if (!proxyUrl) return proxyUrl
24
- // If the value already contains a scheme (e.g. http://, https://, socks5://), return as-is
25
- if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(proxyUrl)) return proxyUrl
26
- return `${defaultScheme}://${proxyUrl}`
27
- }
28
-
29
13
  class EnvHttpProxyAgent extends DispatcherBase {
30
14
  #noProxyValue = null
31
15
  #noProxyEntries = null
@@ -39,20 +23,14 @@ class EnvHttpProxyAgent extends DispatcherBase {
39
23
 
40
24
  this[kNoProxyAgent] = new Agent(agentOpts)
41
25
 
42
- const HTTP_PROXY = normalizeProxyUrl(
43
- httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY,
44
- 'http'
45
- )
26
+ const HTTP_PROXY = httpProxy ?? process.env.http_proxy ?? process.env.HTTP_PROXY
46
27
  if (HTTP_PROXY) {
47
28
  this[kHttpProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTP_PROXY })
48
29
  } else {
49
30
  this[kHttpProxyAgent] = this[kNoProxyAgent]
50
31
  }
51
32
 
52
- const HTTPS_PROXY = normalizeProxyUrl(
53
- httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY,
54
- 'https'
55
- )
33
+ const HTTPS_PROXY = httpsProxy ?? process.env.https_proxy ?? process.env.HTTPS_PROXY
56
34
  if (HTTPS_PROXY) {
57
35
  this[kHttpsProxyAgent] = new ProxyAgent({ ...agentOpts, uri: HTTPS_PROXY })
58
36
  } else {
@@ -15,7 +15,7 @@ class H2CClient extends Client {
15
15
  )
16
16
  }
17
17
 
18
- const { connect, maxConcurrentStreams, pipelining, ...opts } =
18
+ const { maxConcurrentStreams, pipelining, ...opts } =
19
19
  clientOpts ?? {}
20
20
  let defaultMaxConcurrentStreams = 100
21
21
  let defaultPipelining = 100
@@ -68,9 +68,6 @@ class Pool extends PoolBase {
68
68
  this[kConnections] = connections || null
69
69
  this[kUrl] = util.parseOrigin(origin)
70
70
  this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl, socketPath }
71
- this[kOptions].interceptors = options.interceptors
72
- ? { ...options.interceptors }
73
- : undefined
74
71
  this[kFactory] = factory
75
72
 
76
73
  this.on('connect', (origin, targets) => {
@@ -16,6 +16,7 @@ const kProxyHeaders = Symbol('proxy headers')
16
16
  const kRequestTls = Symbol('request tls settings')
17
17
  const kProxyTls = Symbol('proxy tls settings')
18
18
  const kConnectEndpoint = Symbol('connect endpoint function')
19
+ const kConnectEndpointHTTP1 = Symbol('connect endpoint function (http/1.1 only)')
19
20
  const kTunnelProxy = Symbol('tunnel proxy')
20
21
 
21
22
  function defaultProtocolPort (protocol) {
@@ -103,7 +104,7 @@ class ProxyAgent extends DispatcherBase {
103
104
  throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
104
105
  }
105
106
 
106
- const { proxyTunnel = true } = opts
107
+ const { proxyTunnel = true, connectTimeout } = opts
107
108
 
108
109
  super()
109
110
 
@@ -127,8 +128,9 @@ class ProxyAgent extends DispatcherBase {
127
128
  this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
128
129
  }
129
130
 
130
- const connect = buildConnector({ ...opts.proxyTls })
131
- this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
131
+ const connect = buildConnector({ timeout: connectTimeout, ...opts.proxyTls })
132
+ this[kConnectEndpoint] = buildConnector({ timeout: connectTimeout, ...opts.requestTls })
133
+ this[kConnectEndpointHTTP1] = buildConnector({ timeout: connectTimeout, ...opts.requestTls, allowH2: false })
132
134
 
133
135
  const agentFactory = opts.factory || defaultAgentFactory
134
136
  const factory = (origin, options) => {
@@ -216,7 +218,11 @@ class ProxyAgent extends DispatcherBase {
216
218
  } else {
217
219
  servername = opts.servername
218
220
  }
219
- this[kConnectEndpoint]({ ...opts, servername, httpSocket: socket }, callback)
221
+ const connectEndpoint = opts.allowH2 === false
222
+ ? this[kConnectEndpointHTTP1]
223
+ : this[kConnectEndpoint]
224
+
225
+ connectEndpoint({ ...opts, servername, httpSocket: socket }, callback)
220
226
  } catch (err) {
221
227
  if (err.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
222
228
  // Throw a custom error to avoid loop in client.js#connect
@@ -69,9 +69,6 @@ class RoundRobinPool extends PoolBase {
69
69
  this[kConnections] = connections || null
70
70
  this[kUrl] = util.parseOrigin(origin)
71
71
  this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl, socketPath }
72
- this[kOptions].interceptors = options.interceptors
73
- ? { ...options.interceptors }
74
- : undefined
75
72
  this[kFactory] = factory
76
73
  this[kIndex] = -1
77
74