undici 5.27.2 → 5.28.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.
@@ -33,7 +33,7 @@ Returns: `Client`
33
33
  * **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version.
34
34
  * **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details.
35
35
  * **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.
36
- * **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overriden by a SETTINGS remote frame.
36
+ * **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
37
37
 
38
38
  #### Parameter: `ConnectOptions`
39
39
 
@@ -35,8 +35,7 @@ const mockPool = mockAgent.get('http://localhost:3000')
35
35
 
36
36
  ### `MockPool.intercept(options)`
37
37
 
38
- This method defines the interception rules for matching against requests for a MockPool or MockPool. We can intercept multiple times on a single instance, but each intercept is only used once.
39
- For example if you expect to make 2 requests inside a test, you need to call `intercept()` twice. Assuming you use `disableNetConnect()` you will get `MockNotMatchedError` on the second request when you only call `intercept()` once.
38
+ This method defines the interception rules for matching against requests for a MockPool or MockPool. We can intercept multiple times on a single instance, but each intercept is only used once. For example if you expect to make 2 requests inside a test, you need to call `intercept()` twice. Assuming you use `disableNetConnect()` you will get `MockNotMatchedError` on the second request when you only call `intercept()` once.
40
39
 
41
40
  When defining interception rules, all the rules must pass for a request to be intercepted. If a request is not intercepted, a real request will be attempted.
42
41
 
@@ -54,11 +53,11 @@ Returns: `MockInterceptor` corresponding to the input options.
54
53
 
55
54
  ### Parameter: `MockPoolInterceptOptions`
56
55
 
57
- * **path** `string | RegExp | (path: string) => boolean` - a matcher for the HTTP request path.
56
+ * **path** `string | RegExp | (path: string) => boolean` - a matcher for the HTTP request path. When a `RegExp` or callback is used, it will match against the request path including all query parameters in alphabetical order. When a `string` is provided, the query parameters can be conveniently specified through the `MockPoolInterceptOptions.query` setting.
58
57
  * **method** `string | RegExp | (method: string) => boolean` - (optional) - a matcher for the HTTP request method. Defaults to `GET`.
59
58
  * **body** `string | RegExp | (body: string) => boolean` - (optional) - a matcher for the HTTP request body.
60
59
  * **headers** `Record<string, string | RegExp | (body: string) => boolean`> - (optional) - a matcher for the HTTP request headers. To be intercepted, a request must match all defined headers. Extra headers not defined here may (or may not) be included in the request and do not affect the interception in any way.
61
- * **query** `Record<string, any> | null` - (optional) - a matcher for the HTTP request query string params.
60
+ * **query** `Record<string, any> | null` - (optional) - a matcher for the HTTP request query string params. Only applies when a `string` was provided for `MockPoolInterceptOptions.path`.
62
61
 
63
62
  ### Return: `MockInterceptor`
64
63
 
@@ -458,6 +457,41 @@ const result3 = await request('http://localhost:3000/foo')
458
457
  // Will not match and make attempt a real request
459
458
  ```
460
459
 
460
+ #### Example - Mocked request with path callback
461
+
462
+ ```js
463
+ import { MockAgent, setGlobalDispatcher, request } from 'undici'
464
+ import querystring from 'querystring'
465
+
466
+ const mockAgent = new MockAgent()
467
+ setGlobalDispatcher(mockAgent)
468
+
469
+ const mockPool = mockAgent.get('http://localhost:3000')
470
+
471
+ const matchPath = requestPath => {
472
+ const [pathname, search] = requestPath.split('?')
473
+ const requestQuery = querystring.parse(search)
474
+
475
+ if (!pathname.startsWith('/foo')) {
476
+ return false
477
+ }
478
+
479
+ if (!Object.keys(requestQuery).includes('foo') || requestQuery.foo !== 'bar') {
480
+ return false
481
+ }
482
+
483
+ return true
484
+ }
485
+
486
+ mockPool.intercept({
487
+ path: matchPath,
488
+ method: 'GET'
489
+ }).reply(200, 'foo')
490
+
491
+ const result = await request('http://localhost:3000/foo?foo=bar')
492
+ // Will match and return mocked data
493
+ ```
494
+
461
495
  ### `MockPool.close()`
462
496
 
463
497
  Closes the mock pool and de-registers from associated MockAgent.
@@ -0,0 +1,108 @@
1
+ # Class: RetryHandler
2
+
3
+ Extends: `undici.DispatcherHandlers`
4
+
5
+ A handler class that implements the retry logic for a request.
6
+
7
+ ## `new RetryHandler(dispatchOptions, retryHandlers, [retryOptions])`
8
+
9
+ Arguments:
10
+
11
+ - **options** `Dispatch.DispatchOptions & RetryOptions` (required) - It is an intersection of `Dispatcher.DispatchOptions` and `RetryOptions`.
12
+ - **retryHandlers** `RetryHandlers` (required) - Object containing the `dispatch` to be used on every retry, and `handler` for handling the `dispatch` lifecycle.
13
+
14
+ Returns: `retryHandler`
15
+
16
+ ### Parameter: `Dispatch.DispatchOptions & RetryOptions`
17
+
18
+ Extends: [`Dispatch.DispatchOptions`](Dispatcher.md#parameter-dispatchoptions).
19
+
20
+ #### `RetryOptions`
21
+
22
+ - **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
23
+ - **maxRetries** `number` (optional) - Maximum number of retries. Default: `5`
24
+ - **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds)
25
+ - **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second)
26
+ - **timeoutFactor** `number` (optional) - Factor to multiply the timeout by for each retry attempt. Default: `2`
27
+ - **retryAfter** `boolean` (optional) - It enables automatic retry after the `Retry-After` header is received. Default: `true`
28
+ -
29
+ - **methods** `string[]` (optional) - Array of HTTP methods to retry. Default: `['GET', 'PUT', 'HEAD', 'OPTIONS', 'DELETE']`
30
+ - **statusCodes** `number[]` (optional) - Array of HTTP status codes to retry. Default: `[429, 500, 502, 503, 504]`
31
+ - **errorCodes** `string[]` (optional) - Array of Error codes to retry. Default: `['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'ENETDOWN','ENETUNREACH', 'EHOSTDOWN',
32
+
33
+ **`RetryContext`**
34
+
35
+ - `state`: `RetryState` - Current retry state. It can be mutated.
36
+ - `opts`: `Dispatch.DispatchOptions & RetryOptions` - Options passed to the retry handler.
37
+
38
+ ### Parameter `RetryHandlers`
39
+
40
+ - **dispatch** `(options: Dispatch.DispatchOptions, handlers: Dispatch.DispatchHandlers) => Promise<Dispatch.DispatchResponse>` (required) - Dispatch function to be called after every retry.
41
+ - **handler** Extends [`Dispatch.DispatchHandlers`](Dispatcher.md#dispatcherdispatchoptions-handler) (required) - Handler function to be called after the request is successful or the retries are exhausted.
42
+
43
+ Examples:
44
+
45
+ ```js
46
+ const client = new Client(`http://localhost:${server.address().port}`);
47
+ const chunks = [];
48
+ const handler = new RetryHandler(
49
+ {
50
+ ...dispatchOptions,
51
+ retryOptions: {
52
+ // custom retry function
53
+ retry: function (err, state, callback) {
54
+ counter++;
55
+
56
+ if (err.code && err.code === "UND_ERR_DESTROYED") {
57
+ callback(err);
58
+ return;
59
+ }
60
+
61
+ if (err.statusCode === 206) {
62
+ callback(err);
63
+ return;
64
+ }
65
+
66
+ setTimeout(() => callback(null), 1000);
67
+ },
68
+ },
69
+ },
70
+ {
71
+ dispatch: (...args) => {
72
+ return client.dispatch(...args);
73
+ },
74
+ handler: {
75
+ onConnect() {},
76
+ onBodySent() {},
77
+ onHeaders(status, _rawHeaders, resume, _statusMessage) {
78
+ // do something with headers
79
+ },
80
+ onData(chunk) {
81
+ chunks.push(chunk);
82
+ return true;
83
+ },
84
+ onComplete() {},
85
+ onError() {
86
+ // handle error properly
87
+ },
88
+ },
89
+ }
90
+ );
91
+ ```
92
+
93
+ #### Example - Basic RetryHandler with defaults
94
+
95
+ ```js
96
+ const client = new Client(`http://localhost:${server.address().port}`);
97
+ const handler = new RetryHandler(dispatchOptions, {
98
+ dispatch: client.dispatch.bind(client),
99
+ handler: {
100
+ onConnect() {},
101
+ onBodySent() {},
102
+ onHeaders(status, _rawHeaders, resume, _statusMessage) {},
103
+ onData(chunk) {},
104
+ onComplete() {},
105
+ onError(err) {},
106
+ },
107
+ });
108
+ ```
package/index.js CHANGED
@@ -15,6 +15,7 @@ const MockAgent = require('./lib/mock/mock-agent')
15
15
  const MockPool = require('./lib/mock/mock-pool')
16
16
  const mockErrors = require('./lib/mock/mock-errors')
17
17
  const ProxyAgent = require('./lib/proxy-agent')
18
+ const RetryHandler = require('./lib/handler/RetryHandler')
18
19
  const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
19
20
  const DecoratorHandler = require('./lib/handler/DecoratorHandler')
20
21
  const RedirectHandler = require('./lib/handler/RedirectHandler')
@@ -36,6 +37,7 @@ module.exports.Pool = Pool
36
37
  module.exports.BalancedPool = BalancedPool
37
38
  module.exports.Agent = Agent
38
39
  module.exports.ProxyAgent = ProxyAgent
40
+ module.exports.RetryHandler = RetryHandler
39
41
 
40
42
  module.exports.DecoratorHandler = DecoratorHandler
41
43
  module.exports.RedirectHandler = RedirectHandler
@@ -16,6 +16,8 @@ const kBody = Symbol('kBody')
16
16
  const kAbort = Symbol('abort')
17
17
  const kContentType = Symbol('kContentType')
18
18
 
19
+ const noop = () => {}
20
+
19
21
  module.exports = class BodyReadable extends Readable {
20
22
  constructor ({
21
23
  resume,
@@ -149,37 +151,50 @@ module.exports = class BodyReadable extends Readable {
149
151
  return this[kBody]
150
152
  }
151
153
 
152
- async dump (opts) {
154
+ dump (opts) {
153
155
  let limit = opts && Number.isFinite(opts.limit) ? opts.limit : 262144
154
156
  const signal = opts && opts.signal
155
- const abortFn = () => {
156
- this.destroy()
157
- }
158
- let signalListenerCleanup
157
+
159
158
  if (signal) {
160
- if (typeof signal !== 'object' || !('aborted' in signal)) {
161
- throw new InvalidArgumentError('signal must be an AbortSignal')
162
- }
163
- util.throwIfAborted(signal)
164
- signalListenerCleanup = util.addAbortListener(signal, abortFn)
165
- }
166
- try {
167
- for await (const chunk of this) {
168
- util.throwIfAborted(signal)
169
- limit -= Buffer.byteLength(chunk)
170
- if (limit < 0) {
171
- return
159
+ try {
160
+ if (typeof signal !== 'object' || !('aborted' in signal)) {
161
+ throw new InvalidArgumentError('signal must be an AbortSignal')
172
162
  }
163
+ util.throwIfAborted(signal)
164
+ } catch (err) {
165
+ return Promise.reject(err)
173
166
  }
174
- } catch {
175
- util.throwIfAborted(signal)
176
- } finally {
177
- if (typeof signalListenerCleanup === 'function') {
178
- signalListenerCleanup()
179
- } else if (signalListenerCleanup) {
180
- signalListenerCleanup[Symbol.dispose]()
181
- }
182
167
  }
168
+
169
+ if (this.closed) {
170
+ return Promise.resolve(null)
171
+ }
172
+
173
+ return new Promise((resolve, reject) => {
174
+ const signalListenerCleanup = signal
175
+ ? util.addAbortListener(signal, () => {
176
+ this.destroy()
177
+ })
178
+ : noop
179
+
180
+ this
181
+ .on('close', function () {
182
+ signalListenerCleanup()
183
+ if (signal?.aborted) {
184
+ reject(signal.reason || Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }))
185
+ } else {
186
+ resolve(null)
187
+ }
188
+ })
189
+ .on('error', noop)
190
+ .on('data', function (chunk) {
191
+ limit -= chunk.length
192
+ if (limit <= 0) {
193
+ this.destroy()
194
+ }
195
+ })
196
+ .resume()
197
+ })
183
198
  }
184
199
  }
185
200
 
package/lib/client.js CHANGED
@@ -1183,7 +1183,7 @@ async function connect (client) {
1183
1183
  const idx = hostname.indexOf(']')
1184
1184
 
1185
1185
  assert(idx !== -1)
1186
- const ip = hostname.substr(1, idx - 1)
1186
+ const ip = hostname.substring(1, idx)
1187
1187
 
1188
1188
  assert(net.isIP(ip))
1189
1189
  hostname = ip
@@ -1682,6 +1682,7 @@ function writeH2 (client, session, request) {
1682
1682
  return false
1683
1683
  }
1684
1684
 
1685
+ /** @type {import('node:http2').ClientHttp2Stream} */
1685
1686
  let stream
1686
1687
  const h2State = client[kHTTP2SessionState]
1687
1688
 
@@ -1777,14 +1778,10 @@ function writeH2 (client, session, request) {
1777
1778
  const shouldEndStream = method === 'GET' || method === 'HEAD'
1778
1779
  if (expectContinue) {
1779
1780
  headers[HTTP2_HEADER_EXPECT] = '100-continue'
1780
- /**
1781
- * @type {import('node:http2').ClientHttp2Stream}
1782
- */
1783
1781
  stream = session.request(headers, { endStream: shouldEndStream, signal })
1784
1782
 
1785
1783
  stream.once('continue', writeBodyH2)
1786
1784
  } else {
1787
- /** @type {import('node:http2').ClientHttp2Stream} */
1788
1785
  stream = session.request(headers, {
1789
1786
  endStream: shouldEndStream,
1790
1787
  signal
@@ -1796,7 +1793,9 @@ function writeH2 (client, session, request) {
1796
1793
  ++h2State.openStreams
1797
1794
 
1798
1795
  stream.once('response', headers => {
1799
- if (request.onHeaders(Number(headers[HTTP2_HEADER_STATUS]), headers, stream.resume.bind(stream), '') === false) {
1796
+ const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
1797
+
1798
+ if (request.onHeaders(Number(statusCode), realHeaders, stream.resume.bind(stream), '') === false) {
1800
1799
  stream.pause()
1801
1800
  }
1802
1801
  })
@@ -1972,7 +1971,11 @@ function writeStream ({ h2stream, body, client, request, socket, contentLength,
1972
1971
  }
1973
1972
  }
1974
1973
  const onAbort = function () {
1975
- onFinished(new RequestAbortedError())
1974
+ if (finished) {
1975
+ return
1976
+ }
1977
+ const err = new RequestAbortedError()
1978
+ queueMicrotask(() => onFinished(err))
1976
1979
  }
1977
1980
  const onFinished = function (err) {
1978
1981
  if (finished) {
@@ -193,6 +193,19 @@ class ResponseExceededMaxSizeError extends UndiciError {
193
193
  }
194
194
  }
195
195
 
196
+ class RequestRetryError extends UndiciError {
197
+ constructor (message, code, { headers, data }) {
198
+ super(message)
199
+ Error.captureStackTrace(this, RequestRetryError)
200
+ this.name = 'RequestRetryError'
201
+ this.message = message || 'Request retry error'
202
+ this.code = 'UND_ERR_REQ_RETRY'
203
+ this.statusCode = code
204
+ this.data = data
205
+ this.headers = headers
206
+ }
207
+ }
208
+
196
209
  module.exports = {
197
210
  HTTPParserError,
198
211
  UndiciError,
@@ -212,5 +225,6 @@ module.exports = {
212
225
  NotSupportedError,
213
226
  ResponseContentLengthMismatchError,
214
227
  BalancedPoolMissingUpstreamError,
215
- ResponseExceededMaxSizeError
228
+ ResponseExceededMaxSizeError,
229
+ RequestRetryError
216
230
  }
@@ -229,11 +229,7 @@ class Request {
229
229
 
230
230
  onBodySent (chunk) {
231
231
  if (this[kHandler].onBodySent) {
232
- try {
233
- this[kHandler].onBodySent(chunk)
234
- } catch (err) {
235
- this.onError(err)
236
- }
232
+ return this[kHandler].onBodySent(chunk)
237
233
  }
238
234
  }
239
235
 
@@ -243,11 +239,7 @@ class Request {
243
239
  }
244
240
 
245
241
  if (this[kHandler].onRequestSent) {
246
- try {
247
- this[kHandler].onRequestSent()
248
- } catch (err) {
249
- this.onError(err)
250
- }
242
+ return this[kHandler].onRequestSent()
251
243
  }
252
244
  }
253
245
 
@@ -57,5 +57,6 @@ module.exports = {
57
57
  kHTTP2BuildRequest: Symbol('http2 build request'),
58
58
  kHTTP1BuildRequest: Symbol('http1 build request'),
59
59
  kHTTP2CopyHeaders: Symbol('http2 copy headers'),
60
- kHTTPConnVersion: Symbol('http connection version')
60
+ kHTTPConnVersion: Symbol('http connection version'),
61
+ kRetryHandlerDefaultRetry: Symbol('retry agent default retry')
61
62
  }
package/lib/core/util.js CHANGED
@@ -125,13 +125,13 @@ function getHostname (host) {
125
125
  const idx = host.indexOf(']')
126
126
 
127
127
  assert(idx !== -1)
128
- return host.substr(1, idx - 1)
128
+ return host.substring(1, idx)
129
129
  }
130
130
 
131
131
  const idx = host.indexOf(':')
132
132
  if (idx === -1) return host
133
133
 
134
- return host.substr(0, idx)
134
+ return host.substring(0, idx)
135
135
  }
136
136
 
137
137
  // IP addresses are not valid server names per RFC6066
@@ -228,7 +228,7 @@ function parseHeaders (headers, obj = {}) {
228
228
 
229
229
  if (!val) {
230
230
  if (Array.isArray(headers[i + 1])) {
231
- obj[key] = headers[i + 1]
231
+ obj[key] = headers[i + 1].map(x => x.toString('utf8'))
232
232
  } else {
233
233
  obj[key] = headers[i + 1].toString('utf8')
234
234
  }
@@ -431,16 +431,7 @@ function throwIfAborted (signal) {
431
431
  }
432
432
  }
433
433
 
434
- let events
435
434
  function addAbortListener (signal, listener) {
436
- if (typeof Symbol.dispose === 'symbol') {
437
- if (!events) {
438
- events = require('events')
439
- }
440
- if (typeof events.addAbortListener === 'function' && 'aborted' in signal) {
441
- return events.addAbortListener(signal, listener)
442
- }
443
- }
444
435
  if ('addEventListener' in signal) {
445
436
  signal.addEventListener('abort', listener, { once: true })
446
437
  return () => signal.removeEventListener('abort', listener)
@@ -464,6 +455,21 @@ function toUSVString (val) {
464
455
  return `${val}`
465
456
  }
466
457
 
458
+ // Parsed accordingly to RFC 9110
459
+ // https://www.rfc-editor.org/rfc/rfc9110#field.content-range
460
+ function parseRangeHeader (range) {
461
+ if (range == null || range === '') return { start: 0, end: null, size: null }
462
+
463
+ const m = range ? range.match(/^bytes (\d+)-(\d+)\/(\d+)?$/) : null
464
+ return m
465
+ ? {
466
+ start: parseInt(m[1]),
467
+ end: m[2] ? parseInt(m[2]) : null,
468
+ size: m[3] ? parseInt(m[3]) : null
469
+ }
470
+ : null
471
+ }
472
+
467
473
  const kEnumerableProperty = Object.create(null)
468
474
  kEnumerableProperty.enumerable = true
469
475
 
@@ -497,7 +503,9 @@ module.exports = {
497
503
  buildURL,
498
504
  throwIfAborted,
499
505
  addAbortListener,
506
+ parseRangeHeader,
500
507
  nodeMajor,
501
508
  nodeMinor,
502
- nodeHasAutoSelectFamily: nodeMajor > 18 || (nodeMajor === 18 && nodeMinor >= 13)
509
+ nodeHasAutoSelectFamily: nodeMajor > 18 || (nodeMajor === 18 && nodeMinor >= 13),
510
+ safeHTTPMethods: ['GET', 'HEAD', 'OPTIONS', 'TRACE']
503
511
  }