undici 8.6.0 → 8.8.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.
package/README.md CHANGED
@@ -408,6 +408,11 @@ For more information about their behavior, please reference the body mixin from
408
408
 
409
409
  This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site.
410
410
 
411
+ For the top-level APIs below, the `url` argument supplies the request origin and
412
+ path. Do not pass `origin` or `path` in the second `options` argument. The linked
413
+ `Dispatcher` option types include those fields because dispatcher methods are
414
+ lower-level APIs that do not receive a separate `url` argument.
415
+
411
416
  ### `undici.request([url, options]): Promise`
412
417
 
413
418
  Arguments:
@@ -49,6 +49,10 @@ added: v1.0.0
49
49
  * `headersTimeout` {number|null} The timeout, in milliseconds, the parser
50
50
  waits to receive the complete HTTP headers before the request times out. Use
51
51
  `0` to disable it entirely. **Default:** `300e3`.
52
+ HTTP/1.1 headers/body parser timeouts are not guaranteed to fire with exact
53
+ millisecond precision: delays up to 1000ms use native timers, while larger
54
+ delays use undici's lower-overhead fast timers with a target resolution
55
+ around 500ms.
52
56
  * `connectTimeout` {number|null} The timeout, in milliseconds, for
53
57
  establishing a socket connection. Use `0` to disable it entirely.
54
58
  **Default:** `10e3`.
@@ -228,6 +228,10 @@ added: v4.0.0
228
228
  * `bodyTimeout` {number|null} The time, in milliseconds, after which the request
229
229
  times out while receiving body data. Monitors the time between body chunks.
230
230
  Use `0` to disable it entirely. Defaults to 300 seconds.
231
+ HTTP/1.1 headers/body parser timeouts are not guaranteed to fire with exact
232
+ millisecond precision: delays up to 1000ms use native timers, while larger
233
+ delays use undici's lower-overhead fast timers with a target resolution
234
+ around 500ms.
231
235
  * `reset` {boolean} Whether the request should establish a keep-alive
232
236
  connection. **Default:** `false`.
233
237
  * `expectContinue` {boolean} For HTTP/2, appends the `expect: 100-continue`
@@ -729,6 +733,8 @@ await client.request({ path: '/', method: 'GET' })
729
733
  ```
730
734
 
731
735
  For the full list of built-in interceptors provided by undici, see [Interceptors](Interceptors.md).
736
+ For an example of a custom interceptor that wraps handler callbacks, see
737
+ [Custom interceptors](Interceptors.md#custom-interceptors).
732
738
 
733
739
  ### Event: `'connect'`
734
740
 
@@ -32,6 +32,65 @@ const client = new Client('https://example.com').compose(
32
32
 
33
33
  ---
34
34
 
35
+ ## Custom interceptors
36
+
37
+ Custom interceptors use the same shape as
38
+ [`dispatcher.compose()`](./Dispatcher.md#dispatchercomposeinterceptors-interceptor):
39
+ an interceptor takes a `dispatch` function and returns another dispatch-like
40
+ function with the same `(options, handler)` signature.
41
+
42
+ When an interceptor wraps the handler, forward the callbacks that it does not
43
+ handle itself. The complete handler callback list is documented under
44
+ [`dispatcher.dispatch(options, handler)`](./Dispatcher.md#dispatcherdispatchoptions-handler).
45
+
46
+ ```js
47
+ import { Agent } from 'undici'
48
+
49
+ const timingInterceptor = dispatch => {
50
+ return (options, handler) => {
51
+ const started = performance.now()
52
+
53
+ return dispatch(options, {
54
+ ...handler,
55
+ onResponseStart (controller, statusCode, headers, statusMessage) {
56
+ const duration = Math.round(performance.now() - started)
57
+ const method = options.method ?? 'GET'
58
+ const origin = options.origin ?? ''
59
+
60
+ console.log(`${method} ${origin}${options.path} -> ${statusCode} in ${duration}ms`)
61
+
62
+ return handler.onResponseStart?.(
63
+ controller,
64
+ statusCode,
65
+ headers,
66
+ statusMessage
67
+ )
68
+ },
69
+ onResponseError (controller, error) {
70
+ const duration = Math.round(performance.now() - started)
71
+
72
+ console.error(`request failed after ${duration}ms`, error)
73
+
74
+ return handler.onResponseError?.(controller, error)
75
+ }
76
+ })
77
+ }
78
+ }
79
+
80
+ const dispatcher = new Agent().compose(timingInterceptor)
81
+
82
+ const { body } = await dispatcher.request({
83
+ origin: 'https://example.com',
84
+ path: '/',
85
+ method: 'GET'
86
+ })
87
+
88
+ await body.dump()
89
+ await dispatcher.close()
90
+ ```
91
+
92
+ ---
93
+
35
94
  ## `interceptors.dump([opts])`
36
95
 
37
96
  Reads and discards the response body up to a configurable size limit. Useful
@@ -17,6 +17,13 @@ registered on the {MockClient} or {MockPool} instances returned by
17
17
  `MockAgent` is set as the dispatcher (for example through
18
18
  [`setGlobalDispatcher()`][] or a per-request `dispatcher` option).
19
19
 
20
+ > [!NOTE]
21
+ > [`setGlobalDispatcher()`][] only affects undici APIs that use the global
22
+ > dispatcher, such as `request()` and `fetch()`. It does not replace or
23
+ > monkeypatch {Pool} or {Client} instances that were created separately. To test
24
+ > code that accepts or creates a pool/client directly, pass the {MockPool} or
25
+ > {MockClient} returned by [`mockAgent.get(origin)`][] into that code.
26
+
20
27
  ```mjs
21
28
  import { MockAgent } from 'undici'
22
29
 
@@ -139,6 +146,26 @@ for await (const data of body) {
139
146
  }
140
147
  ```
141
148
 
149
+ ```mjs displayName="Testing code that accepts a pool"
150
+ import { MockAgent } from 'undici'
151
+
152
+ async function getStatus (pool) {
153
+ const { statusCode } = await pool.request({
154
+ path: '/foo',
155
+ method: 'GET'
156
+ })
157
+
158
+ return statusCode
159
+ }
160
+
161
+ const mockAgent = new MockAgent()
162
+ const mockPool = mockAgent.get('http://localhost:3000')
163
+
164
+ mockPool.intercept({ path: '/foo', method: 'GET' }).reply(200)
165
+
166
+ console.log(await getStatus(mockPool)) // 200
167
+ ```
168
+
142
169
  ```mjs displayName="Returning a MockClient"
143
170
  import { MockAgent, request } from 'undici'
144
171
 
@@ -136,7 +136,8 @@ The reply behaviour of a matching request is defined through the returned
136
136
  computing all reply options dynamically rather than just the body.
137
137
  * `callback` {Function} A `(opts: MockResponseCallbackOptions) =>
138
138
  { statusCode, data, responseOptions }` function invoked with the incoming
139
- request.
139
+ request. The callback may be asynchronous; a returned promise is awaited
140
+ and must resolve to the same shape.
140
141
  * Returns: {MockScope}
141
142
  * `replyWithError(error)` {Function} Defines an error for a matching request to
142
143
  throw.
@@ -263,6 +264,32 @@ for await (const data of body) {
263
264
  }
264
265
  ```
265
266
 
267
+ ```mjs displayName="Reply with an asynchronous options callback"
268
+ import { readFile } from 'node:fs/promises'
269
+ import { MockAgent, setGlobalDispatcher, request } from 'undici'
270
+
271
+ const mockAgent = new MockAgent()
272
+ setGlobalDispatcher(mockAgent)
273
+
274
+ const mockPool = mockAgent.get('http://localhost:3000')
275
+
276
+ mockPool.intercept({
277
+ path: '/fixture',
278
+ method: 'GET'
279
+ }).reply(async ({ path }) => ({
280
+ statusCode: 200,
281
+ data: await readFile(new URL('./fixture.json', import.meta.url))
282
+ }))
283
+
284
+ const { statusCode, body } = await request('http://localhost:3000/fixture')
285
+
286
+ console.log('response received', statusCode) // response received 200
287
+
288
+ for await (const data of body) {
289
+ console.log('data', data.toString('utf8')) // contents of fixture.json
290
+ }
291
+ ```
292
+
266
293
  ```mjs displayName="Multiple intercepts"
267
294
  import { MockAgent, setGlobalDispatcher, request } from 'undici'
268
295
 
@@ -62,12 +62,19 @@ added: v4.8.2
62
62
  * `origin` {URL} The proxy origin.
63
63
  * `opts` {Object} The resolved options for the dispatcher.
64
64
  * Returns: {Dispatcher}
65
- * `proxyTunnel` {boolean} Forces tunneling through the proxy. When `false`,
66
- requests where both the proxy and the endpoint use the insecure `http:`
67
- protocol are sent directly to the proxy with the absolute request URI rather
68
- than through a `CONNECT` tunnel, matching `curl` behavior. Secure
69
- connections always use a tunnel regardless of this option. **Default:**
70
- `true`.
65
+ * `proxyTunnel` {boolean} Forces tunneling through the proxy. By default,
66
+ Undici detects tunneling based on the request protocol. If the target
67
+ endpoint uses HTTPS, Undici establishes a `CONNECT` tunnel through the proxy
68
+ (after the TLS handshake to the proxy itself when the proxy URL is HTTPS).
69
+ If the target endpoint uses plain HTTP, Undici forwards the request to the
70
+ proxy using an HTTP/1.1 absolute-form request target (over TLS when the
71
+ proxy URL is HTTPS), as required by
72
+ [RFC 9112 §3.2.2](https://www.rfc-editor.org/rfc/rfc9112.html#name-absolute-form).
73
+ This non-tunneled forwarding path does not negotiate HTTP/2 with the proxy.
74
+ Set `proxyTunnel` to `true` to force tunneling for plain HTTP requests as
75
+ well. Currently, there is no way to facilitate HTTP/1.1 IP tunneling as
76
+ described in
77
+ [RFC 9484](https://www.rfc-editor.org/rfc/rfc9484.html#name-http-11-request).
71
78
 
72
79
  Throws an {InvalidArgumentError} when no proxy URI is provided, when
73
80
  `clientFactory` is not a function, or when both `auth` and `token` are supplied.
@@ -102,7 +102,7 @@ setGlobalDispatcher(mockAgent)
102
102
  // this call is made (not intercepted)
103
103
  await fetch(`http://localhost:3000/endpoint?query='hello'`, {
104
104
  method: 'POST',
105
- headers: { 'content-type': 'application/json' }
105
+ headers: { 'content-type': 'application/json' },
106
106
  body: JSON.stringify({ data: '' })
107
107
  })
108
108
 
@@ -410,6 +410,11 @@ For more information about their behavior, please reference the body mixin from
410
410
 
411
411
  This section documents our most commonly used API methods. Additional APIs are documented in their own files within the [docs](./docs/) folder and are accessible via the navigation list on the left side of the docs site.
412
412
 
413
+ For the top-level APIs below, the `url` argument supplies the request origin and
414
+ path. Do not pass `origin` or `path` in the second `options` argument. The linked
415
+ `Dispatcher` option types include those fields because dispatcher methods are
416
+ lower-level APIs that do not receive a separate `url` argument.
417
+
413
418
  ### `undici.request([url, options])`
414
419
 
415
420
  * `url` {string|URL|UrlObject}
@@ -587,6 +587,10 @@ function consumeEnd (consume, encoding) {
587
587
  * @returns {void}
588
588
  */
589
589
  function consumePush (consume, chunk) {
590
+ if (consume.body === null) {
591
+ return
592
+ }
593
+
590
594
  consume.length += chunk.length
591
595
  consume.body.push(chunk)
592
596
  }
@@ -173,7 +173,7 @@ class Request {
173
173
 
174
174
  this.method = method
175
175
 
176
- this.typeOfService = typeOfService ?? 0
176
+ this.typeOfService = typeOfService
177
177
 
178
178
  this.abort = null
179
179
 
package/lib/core/util.js CHANGED
@@ -370,7 +370,12 @@ function destroy (stream, err) {
370
370
  stream.socket = null
371
371
  }
372
372
 
373
- stream.destroy(err)
373
+ try {
374
+ stream.destroy(err)
375
+ } catch {
376
+ // stream.destroy may throw on managed sockets (e.g., http2).
377
+ // Silently ignore — the socket lifecycle is handled by the subsystem.
378
+ }
374
379
  } else if (err) {
375
380
  queueMicrotask(() => {
376
381
  stream.emit('error', err)
@@ -919,11 +924,32 @@ function onConnectTimeout (socket, opts) {
919
924
  destroy(socket, new ConnectTimeoutError(message))
920
925
  }
921
926
 
927
+ let lastUrlString = null
928
+ let lastProtocol = null
929
+
922
930
  /**
923
931
  * @param {string} urlString
924
932
  * @returns {string}
925
933
  */
926
934
  function getProtocolFromUrlString (urlString) {
935
+ // Requests are typically dispatched against the same origin over and over,
936
+ // so cache the last (urlString, protocol) pair to skip re-parsing.
937
+ if (urlString === lastUrlString) {
938
+ return lastProtocol
939
+ }
940
+
941
+ const protocol = getProtocolFromUrlStringSlow(urlString)
942
+ lastUrlString = urlString
943
+ lastProtocol = protocol
944
+
945
+ return protocol
946
+ }
947
+
948
+ /**
949
+ * @param {string} urlString
950
+ * @returns {string}
951
+ */
952
+ function getProtocolFromUrlStringSlow (urlString) {
927
953
  if (
928
954
  urlString[0] === 'h' &&
929
955
  urlString[1] === 't' &&
@@ -107,15 +107,15 @@ class Agent extends DispatcherBase {
107
107
  }
108
108
 
109
109
  let hasOrigin = false
110
- for (const client of this[kClients].values()) {
111
- if (client[kUrl].origin === dispatcher[kUrl].origin) {
110
+ for (const k of this[kClients].keys()) {
111
+ if (k === origin || k === `${origin}#http1-only`) {
112
112
  hasOrigin = true
113
113
  break
114
114
  }
115
115
  }
116
116
 
117
117
  if (!hasOrigin) {
118
- this[kOrigins].delete(dispatcher[kUrl].origin)
118
+ this[kOrigins].delete(origin)
119
119
  }
120
120
  }
121
121
 
@@ -60,6 +60,7 @@ const removeAllListeners = util.removeAllListeners
60
60
  const kIdleSocketValidation = Symbol('kIdleSocketValidation')
61
61
  const kIdleSocketValidationTimeout = Symbol('kIdleSocketValidationTimeout')
62
62
  const kSocketUsed = Symbol('kSocketUsed')
63
+ const kTypeOfService = Symbol('kTypeOfService')
63
64
 
64
65
  let extractBody
65
66
 
@@ -1050,7 +1051,7 @@ function onSocketClose () {
1050
1051
 
1051
1052
  function clearIdleSocketValidation (socket) {
1052
1053
  if (socket[kIdleSocketValidationTimeout]) {
1053
- clearTimeout(socket[kIdleSocketValidationTimeout])
1054
+ clearImmediate(socket[kIdleSocketValidationTimeout])
1054
1055
  socket[kIdleSocketValidationTimeout] = null
1055
1056
  }
1056
1057
 
@@ -1059,14 +1060,14 @@ function clearIdleSocketValidation (socket) {
1059
1060
 
1060
1061
  function scheduleIdleSocketValidation (client, socket) {
1061
1062
  socket[kIdleSocketValidation] = 1
1062
- socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1063
+ socket[kIdleSocketValidationTimeout] = setImmediate(() => {
1063
1064
  socket[kIdleSocketValidationTimeout] = null
1064
1065
  socket[kIdleSocketValidation] = 2
1065
1066
 
1066
1067
  if (client[kSocket] === socket && !socket.destroyed) {
1067
1068
  client[kResume]()
1068
1069
  }
1069
- }, 0)
1070
+ })
1070
1071
  socket[kIdleSocketValidationTimeout].unref?.()
1071
1072
  }
1072
1073
 
@@ -1134,6 +1135,32 @@ function shouldSendContentLength (method) {
1134
1135
  return method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && method !== 'TRACE' && method !== 'CONNECT'
1135
1136
  }
1136
1137
 
1138
+ function setTypeOfService (socket, request) {
1139
+ if (typeof socket.setTypeOfService !== 'function') {
1140
+ return
1141
+ }
1142
+
1143
+ const typeOfService = request.typeOfService
1144
+
1145
+ if (typeOfService === undefined) {
1146
+ return
1147
+ }
1148
+
1149
+ const currentTypeOfService = socket[kTypeOfService]
1150
+
1151
+ if (currentTypeOfService === typeOfService) {
1152
+ return
1153
+ }
1154
+
1155
+ try {
1156
+ socket.setTypeOfService(typeOfService)
1157
+ socket[kTypeOfService] = typeOfService
1158
+ } catch {
1159
+ // QoS marking is best-effort. setTypeOfService() can throw synchronously on
1160
+ // some platforms depending on socket state, but that must not abort the request.
1161
+ }
1162
+ }
1163
+
1137
1164
  /**
1138
1165
  * @param {import('./client.js')} client
1139
1166
  * @param {import('../core/request.js')} request
@@ -1265,9 +1292,7 @@ function writeH1 (client, request) {
1265
1292
  socket[kBlocking] = true
1266
1293
  }
1267
1294
 
1268
- if (socket.setTypeOfService) {
1269
- socket.setTypeOfService(request.typeOfService)
1270
- }
1295
+ setTypeOfService(socket, request)
1271
1296
 
1272
1297
  let header = `${method} ${path} HTTP/1.1\r\n`
1273
1298