undici 5.7.0 → 5.8.2

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.
@@ -177,6 +177,23 @@ for await (const data of result2.body) {
177
177
  console.log('data', data.toString('utf8')) // data hello
178
178
  }
179
179
  ```
180
+ #### Example - Mock different requests within the same file
181
+ ```js
182
+ const { MockAgent, setGlobalDispatcher } = require('undici');
183
+ const agent = new MockAgent();
184
+ agent.disableNetConnect();
185
+ setGlobalDispatcher(agent);
186
+ describe('Test', () => {
187
+ it('200', async () => {
188
+ const mockAgent = agent.get('http://test.com');
189
+ // your test
190
+ });
191
+ it('200', async () => {
192
+ const mockAgent = agent.get('http://testing.com');
193
+ // your test
194
+ });
195
+ });
196
+ ```
180
197
 
181
198
  #### Example - Mocked request with query body, headers and trailers
182
199
 
@@ -1,6 +1,6 @@
1
1
  # Mocking Request
2
2
 
3
- Undici have its own mocking [utility](../api/MockAgent.md). It allow us to intercept undici HTTP request and return mocked value instead. It can be useful for testing purposes.
3
+ Undici has its own mocking [utility](../api/MockAgent.md). It allow us to intercept undici HTTP requests and return mocked values instead. It can be useful for testing purposes.
4
4
 
5
5
  Example:
6
6
 
@@ -8,7 +8,7 @@ Example:
8
8
  // bank.mjs
9
9
  import { request } from 'undici'
10
10
 
11
- export async function bankTransfer(recepient, amount) {
11
+ export async function bankTransfer(recipient, amount) {
12
12
  const { body } = await request('http://localhost:3000/bank-transfer',
13
13
  {
14
14
  method: 'POST',
@@ -16,7 +16,7 @@ export async function bankTransfer(recepient, amount) {
16
16
  'X-TOKEN-SECRET': 'SuperSecretToken',
17
17
  },
18
18
  body: JSON.stringify({
19
- recepient,
19
+ recipient,
20
20
  amount
21
21
  })
22
22
  }
@@ -48,7 +48,7 @@ mockPool.intercept({
48
48
  'X-TOKEN-SECRET': 'SuperSecretToken',
49
49
  },
50
50
  body: JSON.stringify({
51
- recepient: '1234567890',
51
+ recipient: '1234567890',
52
52
  amount: '100'
53
53
  })
54
54
  }).reply(200, {
@@ -77,7 +77,7 @@ Explore other MockAgent functionality [here](../api/MockAgent.md)
77
77
 
78
78
  ## Debug Mock Value
79
79
 
80
- When the interceptor we wrote are not the same undici will automatically call real HTTP request. To debug our mock value use `mockAgent.disableNetConnect()`
80
+ When the interceptor and the request options are not the same, undici will automatically make a real HTTP request. To prevent real requests from being made, use `mockAgent.disableNetConnect()`:
81
81
 
82
82
  ```js
83
83
  const mockAgent = new MockAgent();
@@ -89,7 +89,7 @@ mockAgent.disableNetConnect()
89
89
  const mockPool = mockAgent.get('http://localhost:3000');
90
90
 
91
91
  mockPool.intercept({
92
- path: '/bank-tanfer',
92
+ path: '/bank-transfer',
93
93
  method: 'POST',
94
94
  }).reply(200, {
95
95
  message: 'transaction processed'
@@ -103,7 +103,7 @@ const badRequest = await bankTransfer('1234567890', '100')
103
103
 
104
104
  ## Reply with data based on request
105
105
 
106
- If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply`
106
+ If the mocked response needs to be dynamically derived from the request parameters, you can provide a function instead of an object to `reply`:
107
107
 
108
108
  ```js
109
109
  mockPool.intercept({
@@ -113,7 +113,7 @@ mockPool.intercept({
113
113
  'X-TOKEN-SECRET': 'SuperSecretToken',
114
114
  },
115
115
  body: JSON.stringify({
116
- recepient: '1234567890',
116
+ recipient: '1234567890',
117
117
  amount: '100'
118
118
  })
119
119
  }).reply(200, (opts) => {
@@ -129,7 +129,7 @@ in this case opts will be
129
129
  {
130
130
  method: 'POST',
131
131
  headers: { 'X-TOKEN-SECRET': 'SuperSecretToken' },
132
- body: '{"recepient":"1234567890","amount":"100"}',
132
+ body: '{"recipient":"1234567890","amount":"100"}',
133
133
  origin: 'http://localhost:3000',
134
134
  path: '/bank-transfer'
135
135
  }
@@ -20,10 +20,10 @@ import { createServer } from 'http'
20
20
  import proxy from 'proxy'
21
21
 
22
22
  const server = await buildServer()
23
- const proxy = await buildProxy()
23
+ const proxyServer = await buildProxy()
24
24
 
25
25
  const serverUrl = `http://localhost:${server.address().port}`
26
- const proxyUrl = `http://localhost:${proxy.address().port}`
26
+ const proxyUrl = `http://localhost:${proxyServer.address().port}`
27
27
 
28
28
  server.on('request', (req, res) => {
29
29
  console.log(req.url) // '/hello?foo=bar'
@@ -47,7 +47,7 @@ console.log(response.statusCode) // 200
47
47
  console.log(JSON.parse(data)) // { hello: 'world' }
48
48
 
49
49
  server.close()
50
- proxy.close()
50
+ proxyServer.close()
51
51
  client.close()
52
52
 
53
53
  function buildServer () {
@@ -73,12 +73,12 @@ import { createServer } from 'http'
73
73
  import proxy from 'proxy'
74
74
 
75
75
  const server = await buildServer()
76
- const proxy = await buildProxy()
76
+ const proxyServer = await buildProxy()
77
77
 
78
78
  const serverUrl = `http://localhost:${server.address().port}`
79
- const proxyUrl = `http://localhost:${proxy.address().port}`
79
+ const proxyUrl = `http://localhost:${proxyServer.address().port}`
80
80
 
81
- proxy.authenticate = function (req, fn) {
81
+ proxyServer.authenticate = function (req, fn) {
82
82
  fn(null, req.headers['proxy-authorization'] === `Basic ${Buffer.from('user:pass').toString('base64')}`)
83
83
  }
84
84
 
@@ -107,7 +107,7 @@ console.log(response.statusCode) // 200
107
107
  console.log(JSON.parse(data)) // { hello: 'world' }
108
108
 
109
109
  server.close()
110
- proxy.close()
110
+ proxyServer.close()
111
111
  client.close()
112
112
 
113
113
  function buildServer () {
@@ -124,3 +124,4 @@ function buildProxy () {
124
124
  })
125
125
  }
126
126
  ```
127
+
package/index.js CHANGED
@@ -53,7 +53,12 @@ function makeDispatcher (fn) {
53
53
  throw new InvalidArgumentError('invalid opts.path')
54
54
  }
55
55
 
56
- url = new URL(opts.path, util.parseOrigin(url))
56
+ let path = opts.path
57
+ if (!opts.path.startsWith('/')) {
58
+ path = `/${path}`
59
+ }
60
+
61
+ url = new URL(util.parseOrigin(url).origin + path)
57
62
  } else {
58
63
  if (!opts) {
59
64
  opts = typeof url === 'object' ? url : {}
@@ -18,6 +18,17 @@ const { parseOrigin } = require('./core/util')
18
18
  const kFactory = Symbol('factory')
19
19
 
20
20
  const kOptions = Symbol('options')
21
+ const kGreatestCommonDivisor = Symbol('kGreatestCommonDivisor')
22
+ const kCurrentWeight = Symbol('kCurrentWeight')
23
+ const kIndex = Symbol('kIndex')
24
+ const kWeight = Symbol('kWeight')
25
+ const kMaxWeightPerServer = Symbol('kMaxWeightPerServer')
26
+ const kErrorPenalty = Symbol('kErrorPenalty')
27
+
28
+ function getGreatestCommonDivisor (a, b) {
29
+ if (b === 0) return a
30
+ return getGreatestCommonDivisor(b, a % b)
31
+ }
21
32
 
22
33
  function defaultFactory (origin, opts) {
23
34
  return new Pool(origin, opts)
@@ -28,6 +39,11 @@ class BalancedPool extends PoolBase {
28
39
  super()
29
40
 
30
41
  this[kOptions] = opts
42
+ this[kIndex] = -1
43
+ this[kCurrentWeight] = 0
44
+
45
+ this[kMaxWeightPerServer] = this[kOptions].maxWeightPerServer || 100
46
+ this[kErrorPenalty] = this[kOptions].errorPenalty || 15
31
47
 
32
48
  if (!Array.isArray(upstreams)) {
33
49
  upstreams = [upstreams]
@@ -42,6 +58,7 @@ class BalancedPool extends PoolBase {
42
58
  for (const upstream of upstreams) {
43
59
  this.addUpstream(upstream)
44
60
  }
61
+ this._updateBalancedPoolStats()
45
62
  }
46
63
 
47
64
  addUpstream (upstream) {
@@ -54,12 +71,40 @@ class BalancedPool extends PoolBase {
54
71
  ))) {
55
72
  return this
56
73
  }
74
+ const pool = this[kFactory](upstreamOrigin, Object.assign({}, this[kOptions]))
75
+
76
+ this[kAddClient](pool)
77
+ pool.on('connect', () => {
78
+ pool[kWeight] = Math.min(this[kMaxWeightPerServer], pool[kWeight] + this[kErrorPenalty])
79
+ })
80
+
81
+ pool.on('connectionError', () => {
82
+ pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty])
83
+ this._updateBalancedPoolStats()
84
+ })
85
+
86
+ pool.on('disconnect', (...args) => {
87
+ const err = args[2]
88
+ if (err && err.code === 'UND_ERR_SOCKET') {
89
+ // decrease the weight of the pool.
90
+ pool[kWeight] = Math.max(1, pool[kWeight] - this[kErrorPenalty])
91
+ this._updateBalancedPoolStats()
92
+ }
93
+ })
94
+
95
+ for (const client of this[kClients]) {
96
+ client[kWeight] = this[kMaxWeightPerServer]
97
+ }
57
98
 
58
- this[kAddClient](this[kFactory](upstreamOrigin, Object.assign({}, this[kOptions])))
99
+ this._updateBalancedPoolStats()
59
100
 
60
101
  return this
61
102
  }
62
103
 
104
+ _updateBalancedPoolStats () {
105
+ this[kGreatestCommonDivisor] = this[kClients].map(p => p[kWeight]).reduce(getGreatestCommonDivisor, 0)
106
+ }
107
+
63
108
  removeUpstream (upstream) {
64
109
  const upstreamOrigin = parseOrigin(upstream).origin
65
110
 
@@ -100,10 +145,42 @@ class BalancedPool extends PoolBase {
100
145
  return
101
146
  }
102
147
 
103
- this[kClients].splice(this[kClients].indexOf(dispatcher), 1)
104
- this[kClients].push(dispatcher)
148
+ const allClientsBusy = this[kClients].map(pool => pool[kNeedDrain]).reduce((a, b) => a && b, true)
149
+
150
+ if (allClientsBusy) {
151
+ return
152
+ }
153
+
154
+ let counter = 0
155
+
156
+ let maxWeightIndex = this[kClients].findIndex(pool => !pool[kNeedDrain])
157
+
158
+ while (counter++ < this[kClients].length) {
159
+ this[kIndex] = (this[kIndex] + 1) % this[kClients].length
160
+ const pool = this[kClients][this[kIndex]]
161
+
162
+ // find pool index with the largest weight
163
+ if (pool[kWeight] > this[kClients][maxWeightIndex][kWeight] && !pool[kNeedDrain]) {
164
+ maxWeightIndex = this[kIndex]
165
+ }
166
+
167
+ // decrease the current weight every `this[kClients].length`.
168
+ if (this[kIndex] === 0) {
169
+ // Set the current weight to the next lower weight.
170
+ this[kCurrentWeight] = this[kCurrentWeight] - this[kGreatestCommonDivisor]
171
+
172
+ if (this[kCurrentWeight] <= 0) {
173
+ this[kCurrentWeight] = this[kMaxWeightPerServer]
174
+ }
175
+ }
176
+ if (pool[kWeight] >= this[kCurrentWeight] && (!pool[kNeedDrain])) {
177
+ return pool
178
+ }
179
+ }
105
180
 
106
- return dispatcher
181
+ this[kCurrentWeight] = this[kClients][maxWeightIndex][kWeight]
182
+ this[kIndex] = maxWeightIndex
183
+ return this[kClients][maxWeightIndex]
107
184
  }
108
185
  }
109
186
 
@@ -75,14 +75,12 @@ function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
75
75
  })
76
76
  }
77
77
 
78
- const timeoutId = timeout
79
- ? setTimeout(onConnectTimeout, timeout, socket)
80
- : null
78
+ const cancelTimeout = setupTimeout(() => onConnectTimeout(socket), timeout)
81
79
 
82
80
  socket
83
81
  .setNoDelay(true)
84
82
  .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
85
- clearTimeout(timeoutId)
83
+ cancelTimeout()
86
84
 
87
85
  if (callback) {
88
86
  const cb = callback
@@ -91,7 +89,7 @@ function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
91
89
  }
92
90
  })
93
91
  .on('error', function (err) {
94
- clearTimeout(timeoutId)
92
+ cancelTimeout()
95
93
 
96
94
  if (callback) {
97
95
  const cb = callback
@@ -104,6 +102,31 @@ function buildConnector ({ maxCachedSessions, socketPath, timeout, ...opts }) {
104
102
  }
105
103
  }
106
104
 
105
+ function setupTimeout (onConnectTimeout, timeout) {
106
+ if (!timeout) {
107
+ return () => {}
108
+ }
109
+
110
+ let s1 = null
111
+ let s2 = null
112
+ const timeoutId = setTimeout(() => {
113
+ // setImmediate is added to make sure that we priotorise socket error events over timeouts
114
+ s1 = setImmediate(() => {
115
+ if (process.platform === 'win32') {
116
+ // Windows needs an extra setImmediate probably due to implementation differences in the socket logic
117
+ s2 = setImmediate(() => onConnectTimeout())
118
+ } else {
119
+ onConnectTimeout()
120
+ }
121
+ })
122
+ }, timeout)
123
+ return () => {
124
+ clearTimeout(timeoutId)
125
+ clearImmediate(s1)
126
+ clearImmediate(s2)
127
+ }
128
+ }
129
+
107
130
  function onConnectTimeout (socket) {
108
131
  util.destroy(socket, new ConnectTimeoutError())
109
132
  }
@@ -7,6 +7,27 @@ const {
7
7
  const assert = require('assert')
8
8
  const util = require('./util')
9
9
 
10
+ // tokenRegExp and headerCharRegex have been lifted from
11
+ // https://github.com/nodejs/node/blob/main/lib/_http_common.js
12
+
13
+ /**
14
+ * Verifies that the given val is a valid HTTP token
15
+ * per the rules defined in RFC 7230
16
+ * See https://tools.ietf.org/html/rfc7230#section-3.2.6
17
+ */
18
+ const tokenRegExp = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/
19
+
20
+ /**
21
+ * Matches if val contains an invalid field-vchar
22
+ * field-value = *( field-content / obs-fold )
23
+ * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
24
+ * field-vchar = VCHAR / obs-text
25
+ */
26
+ const headerCharRegex = /[^\t\x20-\x7e\x80-\xff]/
27
+
28
+ // Verifies that a given path is valid does not contain control chars \x00 to \x20
29
+ const invalidPathRegex = /[^\u0021-\u00ff]/
30
+
10
31
  const kHandler = Symbol('handler')
11
32
 
12
33
  const channels = {}
@@ -54,10 +75,14 @@ class Request {
54
75
  method !== 'CONNECT'
55
76
  ) {
56
77
  throw new InvalidArgumentError('path must be an absolute URL or start with a slash')
78
+ } else if (invalidPathRegex.exec(path) !== null) {
79
+ throw new InvalidArgumentError('invalid request path')
57
80
  }
58
81
 
59
82
  if (typeof method !== 'string') {
60
83
  throw new InvalidArgumentError('method must be a string')
84
+ } else if (tokenRegExp.exec(method) === null) {
85
+ throw new InvalidArgumentError('invalid request method')
61
86
  }
62
87
 
63
88
  if (upgrade && typeof upgrade !== 'string') {
@@ -272,7 +297,8 @@ function processHeader (request, key, val) {
272
297
  } else if (
273
298
  request.contentType === null &&
274
299
  key.length === 12 &&
275
- key.toLowerCase() === 'content-type'
300
+ key.toLowerCase() === 'content-type' &&
301
+ headerCharRegex.exec(val) === null
276
302
  ) {
277
303
  request.contentType = val
278
304
  request.headers += `${key}: ${val}\r\n`
@@ -301,6 +327,10 @@ function processHeader (request, key, val) {
301
327
  key.toLowerCase() === 'expect'
302
328
  ) {
303
329
  throw new NotSupportedError('expect header not supported')
330
+ } else if (tokenRegExp.exec(key) === null) {
331
+ throw new InvalidArgumentError('invalid header key')
332
+ } else if (headerCharRegex.exec(val) !== null) {
333
+ throw new InvalidArgumentError(`invalid ${key} header`)
304
334
  } else {
305
335
  request.headers += `${key}: ${val}\r\n`
306
336
  }
package/lib/core/util.js CHANGED
@@ -108,14 +108,25 @@ function parseURL (url) {
108
108
  const port = url.port != null
109
109
  ? url.port
110
110
  : (url.protocol === 'https:' ? 443 : 80)
111
- const origin = url.origin != null
111
+ let origin = url.origin != null
112
112
  ? url.origin
113
113
  : `${url.protocol}//${url.hostname}:${port}`
114
- const path = url.path != null
114
+ let path = url.path != null
115
115
  ? url.path
116
116
  : `${url.pathname || ''}${url.search || ''}`
117
117
 
118
- url = new URL(path, origin)
118
+ if (origin.endsWith('/')) {
119
+ origin = origin.substring(0, origin.length - 1)
120
+ }
121
+
122
+ if (path && !path.startsWith('/')) {
123
+ path = `/${path}`
124
+ }
125
+ // new URL(path, origin) is unsafe when `path` contains an absolute URL
126
+ // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
127
+ // If first parameter is a relative URL, second param is required, and will be used as the base URL.
128
+ // If first parameter is an absolute URL, a given second param will be ignored.
129
+ url = new URL(origin + path)
119
130
  }
120
131
 
121
132
  return url
package/lib/fetch/body.js CHANGED
@@ -291,6 +291,10 @@ function bodyMixinMethods (instance) {
291
291
  const chunks = []
292
292
 
293
293
  for await (const chunk of consumeBody(this[kState].body)) {
294
+ if (!isUint8Array(chunk)) {
295
+ throw new TypeError('Expected Uint8Array chunk')
296
+ }
297
+
294
298
  // Assemble one final large blob with Uint8Array's can exhaust memory.
295
299
  // That's why we create create multiple blob's and using references
296
300
  chunks.push(new Blob([chunk]))
@@ -314,6 +318,10 @@ function bodyMixinMethods (instance) {
314
318
  let offset = 0
315
319
 
316
320
  for await (const chunk of consumeBody(this[kState].body)) {
321
+ if (!isUint8Array(chunk)) {
322
+ throw new TypeError('Expected Uint8Array chunk')
323
+ }
324
+
317
325
  buffer.set(chunk, offset)
318
326
  offset += chunk.length
319
327
  }
@@ -331,6 +339,10 @@ function bodyMixinMethods (instance) {
331
339
  let size = 0
332
340
 
333
341
  for await (const chunk of consumeBody(this[kState].body)) {
342
+ if (!isUint8Array(chunk)) {
343
+ throw new TypeError('Expected Uint8Array chunk')
344
+ }
345
+
334
346
  chunks.push(chunk)
335
347
  size += chunk.byteLength
336
348
  }
@@ -355,6 +367,10 @@ function bodyMixinMethods (instance) {
355
367
  const textDecoder = new TextDecoder()
356
368
 
357
369
  for await (const chunk of consumeBody(this[kState].body)) {
370
+ if (!isUint8Array(chunk)) {
371
+ throw new TypeError('Expected Uint8Array chunk')
372
+ }
373
+
358
374
  result += textDecoder.decode(chunk, { stream: true })
359
375
  }
360
376
 
@@ -388,7 +404,18 @@ function bodyMixinMethods (instance) {
388
404
  // 1. Let entries be the result of parsing bytes.
389
405
  let entries
390
406
  try {
391
- entries = new URLSearchParams(await this.text())
407
+ let text = ''
408
+ // application/x-www-form-urlencoded parser will keep the BOM.
409
+ // https://url.spec.whatwg.org/#concept-urlencoded-parser
410
+ const textDecoder = new TextDecoder('utf-8', { ignoreBOM: true })
411
+ for await (const chunk of consumeBody(this[kState].body)) {
412
+ if (!isUint8Array(chunk)) {
413
+ throw new TypeError('Expected Uint8Array chunk')
414
+ }
415
+ text += textDecoder.decode(chunk, { stream: true })
416
+ }
417
+ text += textDecoder.decode()
418
+ entries = new URLSearchParams(text)
392
419
  } catch (err) {
393
420
  // istanbul ignore next: Unclear when new URLSearchParams can fail on a string.
394
421
  // 2. If entries is failure, then throw a TypeError.
@@ -255,7 +255,7 @@ function percentDecode (input) {
255
255
  }
256
256
 
257
257
  // 3. Return output.
258
- return Uint8Array.of(...output)
258
+ return Uint8Array.from(output)
259
259
  }
260
260
 
261
261
  // https://mimesniff.spec.whatwg.org/#parse-a-mime-type
@@ -33,7 +33,8 @@ const {
33
33
  isBlobLike,
34
34
  sameOrigin,
35
35
  isCancelled,
36
- isAborted
36
+ isAborted,
37
+ isErrorLike
37
38
  } = require('./util')
38
39
  const { kState, kHeaders, kGuard, kRealm } = require('./symbols')
39
40
  const assert = require('assert')
@@ -1854,7 +1855,7 @@ async function httpNetworkFetch (
1854
1855
  timingInfo.decodedBodySize += bytes?.byteLength ?? 0
1855
1856
 
1856
1857
  // 6. If bytes is failure, then terminate fetchParams’s controller.
1857
- if (bytes instanceof Error) {
1858
+ if (isErrorLike(bytes)) {
1858
1859
  fetchParams.controller.terminate(bytes)
1859
1860
  return
1860
1861
  }
@@ -1894,7 +1895,7 @@ async function httpNetworkFetch (
1894
1895
  // 3. Otherwise, if stream is readable, error stream with a TypeError.
1895
1896
  if (isReadable(stream)) {
1896
1897
  fetchParams.controller.controller.error(new TypeError('terminated', {
1897
- cause: reason instanceof Error ? reason : undefined
1898
+ cause: isErrorLike(reason) ? reason : undefined
1898
1899
  }))
1899
1900
  }
1900
1901
  }
@@ -1942,14 +1943,17 @@ async function httpNetworkFetch (
1942
1943
  }
1943
1944
 
1944
1945
  let codings = []
1946
+ let location = ''
1945
1947
 
1946
1948
  const headers = new Headers()
1947
1949
  for (let n = 0; n < headersList.length; n += 2) {
1948
- const key = headersList[n + 0].toString()
1949
- const val = headersList[n + 1].toString()
1950
+ const key = headersList[n + 0].toString('latin1')
1951
+ const val = headersList[n + 1].toString('latin1')
1950
1952
 
1951
1953
  if (key.toLowerCase() === 'content-encoding') {
1952
1954
  codings = val.split(',').map((x) => x.trim())
1955
+ } else if (key.toLowerCase() === 'location') {
1956
+ location = val
1953
1957
  }
1954
1958
 
1955
1959
  headers.append(key, val)
@@ -1960,7 +1964,7 @@ async function httpNetworkFetch (
1960
1964
  const decoders = []
1961
1965
 
1962
1966
  // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
1963
- if (request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status)) {
1967
+ if (request.method !== 'HEAD' && request.method !== 'CONNECT' && !nullBodyStatus.includes(status) && !(request.redirect === 'follow' && location)) {
1964
1968
  for (const coding of codings) {
1965
1969
  if (/(x-)?gzip/.test(coding)) {
1966
1970
  decoders.push(zlib.createGunzip())
@@ -1980,7 +1984,7 @@ async function httpNetworkFetch (
1980
1984
  statusText,
1981
1985
  headersList: headers[kHeadersList],
1982
1986
  body: decoders.length
1983
- ? pipeline(this.body, ...decoders, () => {})
1987
+ ? pipeline(this.body, ...decoders, () => { })
1984
1988
  : this.body.on('error', () => {})
1985
1989
  })
1986
1990
 
@@ -367,9 +367,9 @@ class Request {
367
367
  }
368
368
 
369
369
  if (signal.aborted) {
370
- ac.abort()
370
+ ac.abort(signal.reason)
371
371
  } else {
372
- const abort = () => ac.abort()
372
+ const abort = () => ac.abort(signal.reason)
373
373
  signal.addEventListener('abort', abort, { once: true })
374
374
  requestFinalizer.register(this, { signal, abort })
375
375
  }
@@ -726,12 +726,12 @@ class Request {
726
726
  // 4. Make clonedRequestObject’s signal follow this’s signal.
727
727
  const ac = new AbortController()
728
728
  if (this.signal.aborted) {
729
- ac.abort()
729
+ ac.abort(this.signal.reason)
730
730
  } else {
731
731
  this.signal.addEventListener(
732
732
  'abort',
733
- function () {
734
- ac.abort()
733
+ () => {
734
+ ac.abort(this.signal.reason)
735
735
  },
736
736
  { once: true }
737
737
  )
@@ -10,7 +10,8 @@ const {
10
10
  isCancelled,
11
11
  isAborted,
12
12
  isBlobLike,
13
- serializeJavascriptValueToJSONString
13
+ serializeJavascriptValueToJSONString,
14
+ isErrorLike
14
15
  } = require('./util')
15
16
  const {
16
17
  redirectStatus,
@@ -347,15 +348,15 @@ function makeResponse (init) {
347
348
  }
348
349
 
349
350
  function makeNetworkError (reason) {
351
+ const isError = isErrorLike(reason)
350
352
  return makeResponse({
351
353
  type: 'error',
352
354
  status: 0,
353
- error:
354
- reason instanceof Error
355
- ? reason
356
- : new Error(reason ? String(reason) : reason, {
357
- cause: reason instanceof Error ? reason : undefined
358
- }),
355
+ error: isError
356
+ ? reason
357
+ : new Error(reason ? String(reason) : reason, {
358
+ cause: isError ? reason : undefined
359
+ }),
359
360
  aborted: reason && reason.name === 'AbortError'
360
361
  })
361
362
  }
package/lib/fetch/util.js CHANGED
@@ -82,6 +82,13 @@ function isFileLike (object) {
82
82
  )
83
83
  }
84
84
 
85
+ function isErrorLike (object) {
86
+ return object instanceof Error || (
87
+ object?.constructor?.name === 'Error' ||
88
+ object?.constructor?.name === 'DOMException'
89
+ )
90
+ }
91
+
85
92
  // Check whether |statusText| is a ByteString and
86
93
  // matches the Reason-Phrase token production.
87
94
  // RFC 2616: https://tools.ietf.org/html/rfc2616
@@ -469,5 +476,6 @@ module.exports = {
469
476
  makeIterator,
470
477
  isValidHeaderName,
471
478
  isValidHeaderValue,
472
- hasOwn
479
+ hasOwn,
480
+ isErrorLike
473
481
  }
@@ -388,9 +388,6 @@ webidl.converters.DOMString = function (V, opts = {}) {
388
388
  return String(V)
389
389
  }
390
390
 
391
- // eslint-disable-next-line no-control-regex
392
- const isNotLatin1 = /[^\u0000-\u00ff]/
393
-
394
391
  // https://webidl.spec.whatwg.org/#es-ByteString
395
392
  webidl.converters.ByteString = function (V) {
396
393
  // 1. Let x be ? ToString(V).
@@ -399,8 +396,15 @@ webidl.converters.ByteString = function (V) {
399
396
 
400
397
  // 2. If the value of any element of x is greater than
401
398
  // 255, then throw a TypeError.
402
- if (isNotLatin1.test(x)) {
403
- throw new TypeError('Argument is not a ByteString')
399
+ for (let index = 0; index < x.length; index++) {
400
+ const charCode = x.charCodeAt(index)
401
+
402
+ if (charCode > 255) {
403
+ throw new TypeError(
404
+ 'Cannot convert argument to a ByteString because the character at' +
405
+ `index ${index} has a value of ${charCode} which is greater than 255.`
406
+ )
407
+ }
404
408
  }
405
409
 
406
410
  // 3. Return an IDL ByteString value whose length is the
@@ -186,7 +186,8 @@ function shouldRemoveHeader (header, removeContent, unknownOrigin) {
186
186
  return (
187
187
  (header.length === 4 && header.toString().toLowerCase() === 'host') ||
188
188
  (removeContent && header.toString().toLowerCase().indexOf('content-') === 0) ||
189
- (unknownOrigin && header.length === 13 && header.toString().toLowerCase() === 'authorization')
189
+ (unknownOrigin && header.length === 13 && header.toString().toLowerCase() === 'authorization') ||
190
+ (unknownOrigin && header.length === 6 && header.toString().toLowerCase() === 'cookie')
190
191
  )
191
192
  }
192
193
 
@@ -8,7 +8,7 @@ const {
8
8
  kOrigin,
9
9
  kGetNetConnect
10
10
  } = require('./mock-symbols')
11
- const { buildURL } = require('../core/util')
11
+ const { buildURL, nop } = require('../core/util')
12
12
 
13
13
  function matchValue (match, value) {
14
14
  if (typeof match === 'string') {
@@ -38,7 +38,7 @@ function lowerCaseEntries (headers) {
38
38
  function getHeaderByName (headers, key) {
39
39
  if (Array.isArray(headers)) {
40
40
  for (let i = 0; i < headers.length; i += 2) {
41
- if (headers[i] === key) {
41
+ if (headers[i].toLocaleLowerCase() === key.toLocaleLowerCase()) {
42
42
  return headers[i + 1]
43
43
  }
44
44
  }
@@ -47,19 +47,24 @@ function getHeaderByName (headers, key) {
47
47
  } else if (typeof headers.get === 'function') {
48
48
  return headers.get(key)
49
49
  } else {
50
- return headers[key]
50
+ return lowerCaseEntries(headers)[key.toLocaleLowerCase()]
51
51
  }
52
52
  }
53
53
 
54
+ /** @param {string[]} headers */
55
+ function buildHeadersFromArray (headers) { // fetch HeadersList
56
+ const clone = headers.slice()
57
+ const entries = []
58
+ for (let index = 0; index < clone.length; index += 2) {
59
+ entries.push([clone[index], clone[index + 1]])
60
+ }
61
+ return Object.fromEntries(entries)
62
+ }
63
+
54
64
  function matchHeaders (mockDispatch, headers) {
55
65
  if (typeof mockDispatch.headers === 'function') {
56
66
  if (Array.isArray(headers)) { // fetch HeadersList
57
- const clone = headers.slice()
58
- const entries = []
59
- for (let index = 0; index < clone.length; index += 2) {
60
- entries.push([clone[index], clone[index + 1]])
61
- }
62
- headers = Object.fromEntries(entries)
67
+ headers = buildHeadersFromArray(headers)
63
68
  }
64
69
  return mockDispatch.headers(headers ? lowerCaseEntries(headers) : {})
65
70
  }
@@ -284,10 +289,17 @@ function mockDispatch (opts, handler) {
284
289
  }
285
290
 
286
291
  function handleReply (mockDispatches) {
287
- const responseData = getResponseData(typeof data === 'function' ? data(opts) : data)
292
+ // fetch's HeadersList is a 1D string array
293
+ const optsHeaders = Array.isArray(opts.headers)
294
+ ? buildHeadersFromArray(opts.headers)
295
+ : opts.headers
296
+ const responseData = getResponseData(
297
+ typeof data === 'function' ? data({ ...opts, headers: optsHeaders }) : data
298
+ )
288
299
  const responseHeaders = generateKeyValues(headers)
289
300
  const responseTrailers = generateKeyValues(trailers)
290
301
 
302
+ handler.abort = nop
291
303
  handler.onHeaders(statusCode, responseHeaders, resume, getStatusText(statusCode))
292
304
  handler.onData(Buffer.from(responseData))
293
305
  handler.onComplete(responseTrailers)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "5.7.0",
3
+ "version": "5.8.2",
4
4
  "description": "An HTTP/1.1 client, written from scratch for Node.js",
5
5
  "homepage": "https://undici.nodejs.org",
6
6
  "bugs": {
@@ -67,6 +67,7 @@
67
67
  "@sinonjs/fake-timers": "^9.1.2",
68
68
  "@types/node": "^17.0.29",
69
69
  "abort-controller": "^3.0.0",
70
+ "atomic-sleep": "^1.0.0",
70
71
  "busboy": "^1.6.0",
71
72
  "chai": "^4.3.4",
72
73
  "chai-as-promised": "^7.1.1",
@@ -74,7 +74,7 @@ declare namespace MockInterceptor {
74
74
  origin: string;
75
75
  method: string;
76
76
  body?: BodyInit | Dispatcher.DispatchOptions['body'];
77
- headers: Headers;
77
+ headers: Headers | Record<string, string>;
78
78
  maxRedirections: number;
79
79
  }
80
80
 
@@ -0,0 +1,19 @@
1
+ import Pool = require("./pool")
2
+
3
+ export = PoolStats
4
+
5
+ declare class PoolStats {
6
+ constructor(pool: Pool);
7
+ /** Number of open socket connections in this pool. */
8
+ connected: number;
9
+ /** Number of open socket connections in this pool that do not have an active request. */
10
+ free: number;
11
+ /** Number of pending requests across all clients in this pool. */
12
+ pending: number;
13
+ /** Number of queued requests across all clients in this pool. */
14
+ queued: number;
15
+ /** Number of currently active requests across all clients in this pool. */
16
+ running: number;
17
+ /** Number of active, pending, or queued requests across all clients in this pool. */
18
+ size: number;
19
+ }
package/types/pool.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import Client = require('./client')
2
2
  import Dispatcher = require('./dispatcher')
3
+ import TPoolStats = require('./pool-stats')
3
4
  import { URL } from 'url'
4
5
 
5
6
  export = Pool
@@ -10,9 +11,12 @@ declare class Pool extends Dispatcher {
10
11
  closed: boolean;
11
12
  /** `true` after `pool.destroyed()` has been called or `pool.close()` has been called and the pool shutdown has completed. */
12
13
  destroyed: boolean;
14
+ /** Aggregate stats for a Pool. */
15
+ readonly stats: TPoolStats;
13
16
  }
14
17
 
15
18
  declare namespace Pool {
19
+ export type PoolStats = TPoolStats;
16
20
  export interface Options extends Client.Options {
17
21
  /** Default: `(origin, opts) => new Client(origin, opts)`. */
18
22
  factory?(origin: URL, opts: object): Dispatcher;