undici 7.8.0 → 7.10.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
@@ -261,12 +261,22 @@ const readableWebStream = response.body
261
261
  const readableNodeStream = Readable.fromWeb(readableWebStream)
262
262
  ```
263
263
 
264
- #### Specification Compliance
264
+ ## Specification Compliance
265
265
 
266
- This section documents parts of the [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does
266
+ This section documents parts of the [HTTP/1.1](https://www.rfc-editor.org/rfc/rfc9110.html) and [Fetch Standard](https://fetch.spec.whatwg.org) that Undici does
267
267
  not support or does not fully implement.
268
268
 
269
- ##### Garbage Collection
269
+ #### CORS
270
+
271
+ Unlike browsers, Undici does not implement CORS (Cross-Origin Resource Sharing) checks by default. This means:
272
+
273
+ - No preflight requests are automatically sent for cross-origin requests
274
+ - No validation of `Access-Control-Allow-Origin` headers is performed
275
+ - Requests to any origin are allowed regardless of the source
276
+
277
+ This behavior is intentional for server-side environments where CORS restrictions are typically unnecessary. If your application requires CORS-like protections, you will need to implement these checks manually.
278
+
279
+ #### Garbage Collection
270
280
 
271
281
  * https://fetch.spec.whatwg.org/#garbage-collection
272
282
 
@@ -307,7 +317,7 @@ const headers = await fetch(url, { method: 'HEAD' })
307
317
  .then(res => res.headers)
308
318
  ```
309
319
 
310
- ##### Forbidden and Safelisted Header Names
320
+ #### Forbidden and Safelisted Header Names
311
321
 
312
322
  * https://fetch.spec.whatwg.org/#cors-safelisted-response-header-name
313
323
  * https://fetch.spec.whatwg.org/#forbidden-header-name
@@ -316,7 +326,7 @@ const headers = await fetch(url, { method: 'HEAD' })
316
326
 
317
327
  The [Fetch Standard](https://fetch.spec.whatwg.org) requires implementations to exclude certain headers from requests and responses. In browser environments, some headers are forbidden so the user agent remains in full control over them. In Undici, these constraints are removed to give more control to the user.
318
328
 
319
- ### `undici.upgrade([url, options]): Promise`
329
+ #### `undici.upgrade([url, options]): Promise`
320
330
 
321
331
  Upgrade to a different protocol. See [MDN - HTTP - Protocol upgrade mechanism](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism) for more details.
322
332
 
@@ -378,12 +388,7 @@ Returns: `URL`
378
388
  * **protocol** `string` (optional)
379
389
  * **search** `string` (optional)
380
390
 
381
- ## Specification Compliance
382
-
383
- This section documents parts of the HTTP/1.1 specification that Undici does
384
- not support or does not fully implement.
385
-
386
- ### Expect
391
+ #### Expect
387
392
 
388
393
  Undici does not support the `Expect` request header field. The request
389
394
  body is always immediately sent and the `100 Continue` response will be
@@ -391,7 +396,7 @@ ignored.
391
396
 
392
397
  Refs: https://tools.ietf.org/html/rfc7231#section-5.1.1
393
398
 
394
- ### Pipelining
399
+ #### Pipelining
395
400
 
396
401
  Undici will only use pipelining if configured with a `pipelining` factor
397
402
  greater than `1`. Also it is important to pass `blocking: false` to the
@@ -412,7 +417,7 @@ aborted.
412
417
  * Refs: https://tools.ietf.org/html/rfc2616#section-8.1.2.2
413
418
  * Refs: https://tools.ietf.org/html/rfc7230#section-6.3.2
414
419
 
415
- ### Manual Redirect
420
+ #### Manual Redirect
416
421
 
417
422
  Since it is not possible to manually follow an HTTP redirect on the server-side,
418
423
  Undici returns the actual response instead of an `opaqueredirect` filtered one
@@ -421,9 +426,9 @@ implementations in Deno and Cloudflare Workers.
421
426
 
422
427
  Refs: https://fetch.spec.whatwg.org/#atomic-http-redirect-handling
423
428
 
424
- ## Workarounds
429
+ ### Workarounds
425
430
 
426
- ### Network address family autoselection.
431
+ #### Network address family autoselection.
427
432
 
428
433
  If you experience problem when connecting to a remote server that is resolved by your DNS servers to a IPv6 (AAAA record)
429
434
  first, there are chances that your local router or ISP might have problem connecting to IPv6 networks. In that case
@@ -75,3 +75,9 @@ See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatche
75
75
  ### `Agent.upgrade(options[, callback])`
76
76
 
77
77
  See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback).
78
+
79
+ ### `Agent.stats()`
80
+
81
+ Returns an object of stats by origin in the format of `Record<string, TClientStats | TPoolStats>`
82
+
83
+ See [`PoolStats`](/docs/docs/api/PoolStats.md) and [`ClientStats`](/docs/docs/api/ClientStats.md).
@@ -13,8 +13,28 @@ The `MemoryCacheStore` stores the responses in-memory.
13
13
 
14
14
  **Options**
15
15
 
16
+ - `maxSize` - The maximum total size in bytes of all stored responses. Default `Infinity`.
16
17
  - `maxCount` - The maximum amount of responses to store. Default `Infinity`.
17
- - `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached.
18
+ - `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`.
19
+
20
+ ### Getters
21
+
22
+ #### `MemoryCacheStore.size`
23
+
24
+ Returns the current total size in bytes of all stored responses.
25
+
26
+ ### Methods
27
+
28
+ #### `MemoryCacheStore.isFull()`
29
+
30
+ Returns a boolean indicating whether the cache has reached its maximum size or count.
31
+
32
+ ### Events
33
+
34
+ #### `'maxSizeExceeded'`
35
+
36
+ Emitted when the cache exceeds its maximum size or count limits. The event payload contains `size`, `maxSize`, `count`, and `maxCount` properties.
37
+
18
38
 
19
39
  ### `SqliteCacheStore`
20
40
 
@@ -26,7 +46,7 @@ The `SqliteCacheStore` is only exposed if the `node:sqlite` api is present.
26
46
 
27
47
  - `location` - The location of the SQLite database to use. Default `:memory:`.
28
48
  - `maxCount` - The maximum number of entries to store in the database. Default `Infinity`.
29
- - `maxEntrySize` - The maximum size in bytes that a resposne's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`.
49
+ - `maxEntrySize` - The maximum size in bytes that a response's body can be. If a response's body is greater than or equal to this, the response will not be cached. Default `Infinity`.
30
50
 
31
51
  ## Defining a Custom Cache Store
32
52
 
@@ -0,0 +1,27 @@
1
+ # Class: ClientStats
2
+
3
+ Stats for a [Client](/docs/docs/api/Client.md).
4
+
5
+ ## `new ClientStats(client)`
6
+
7
+ Arguments:
8
+
9
+ * **client** `Client` - Client from which to return stats.
10
+
11
+ ## Instance Properties
12
+
13
+ ### `ClientStats.connected`
14
+
15
+ Boolean if socket as open connection by this client.
16
+
17
+ ### `ClientStats.pending`
18
+
19
+ Number of pending requests of this client.
20
+
21
+ ### `ClientStats.running`
22
+
23
+ Number of currently active requests across this client.
24
+
25
+ ### `ClientStats.size`
26
+
27
+ Number of active, pending, or queued requests of this clients.
@@ -19,7 +19,7 @@ diagnosticsChannel.channel('undici:request:create').subscribe(({ request }) => {
19
19
  console.log('completed', request.completed)
20
20
  console.log('method', request.method)
21
21
  console.log('path', request.path)
22
- console.log('headers') // array of strings, e.g: ['foo', 'bar']
22
+ console.log('headers', request.headers) // array of strings, e.g: ['foo', 'bar']
23
23
  request.addHeader('hello', 'world')
24
24
  console.log('headers', request.headers) // e.g. ['foo', 'bar', 'hello', 'world']
25
25
  })
@@ -20,6 +20,8 @@ Extends: [`AgentOptions`](/docs/docs/api/Agent.md#parameter-agentoptions)
20
20
 
21
21
  * **ignoreTrailingSlash** `boolean` (optional) - Default: `false` - set the default value for `ignoreTrailingSlash` for interceptors.
22
22
 
23
+ * **acceptNonStandardSearchParameters** `boolean` (optional) - Default: `false` - set to `true` if the matcher should also accept non standard search parameters such as multi-value items specified with `[]` (e.g. `param[]=1&param[]=2&param[]=3`) and multi-value items which values are comma separated (e.g. `param=1,2,3`).
24
+
23
25
  ### Example - Basic MockAgent instantiation
24
26
 
25
27
  This will instantiate the MockAgent. It will not do anything until registered as the agent to use with requests and mock interceptions are added.
@@ -19,6 +19,7 @@ Extends: [`ClientOptions`](/docs/docs/api/Client.md#parameter-clientoptions)
19
19
 
20
20
  * **factory** `(origin: URL, opts: Object) => Dispatcher` - Default: `(origin, opts) => new Client(origin, opts)`
21
21
  * **connections** `number | null` (optional) - Default: `null` - The number of `Client` instances to create. When set to `null`, the `Pool` instance will create an unlimited amount of `Client` instances.
22
+ * **clientTtl** `number | null` (optional) - Default: `null` - The amount of time before a `Client` instance is removed from the `Pool` and closed. When set to `null`, `Client` instances will not be removed or closed based on age.
22
23
 
23
24
  ## Instance Properties
24
25
 
@@ -25,6 +25,7 @@ For detailed information on the parsing process and potential validation errors,
25
25
  * **clientFactory** `(origin: URL, opts: Object) => Dispatcher` (optional) - Default: `(origin, opts) => new Pool(origin, opts)`
26
26
  * **requestTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the request. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions).
27
27
  * **proxyTls** `BuildOptions` (optional) - Options object passed when creating the underlying socket via the connector builder for the proxy server. It extends from [`Client#ConnectOptions`](/docs/docs/api/Client.md#parameter-connectoptions).
28
+ * **proxyTunnel** `boolean` (optional) - By default, ProxyAgent will request that the Proxy facilitate a tunnel between the endpoint and the agent. Setting `proxyTunnel` to false avoids issuing a CONNECT extension, and includes the endpoint domain and path in each request.
28
29
 
29
30
  Examples:
30
31
 
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { Writable } = require('node:stream')
4
+ const { EventEmitter } = require('node:events')
4
5
  const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
5
6
 
6
7
  /**
@@ -12,8 +13,9 @@ const { assertCacheKey, assertCacheValue } = require('../util/cache.js')
12
13
 
13
14
  /**
14
15
  * @implements {CacheStore}
16
+ * @extends {EventEmitter}
15
17
  */
16
- class MemoryCacheStore {
18
+ class MemoryCacheStore extends EventEmitter {
17
19
  #maxCount = Infinity
18
20
  #maxSize = Infinity
19
21
  #maxEntrySize = Infinity
@@ -21,11 +23,13 @@ class MemoryCacheStore {
21
23
  #size = 0
22
24
  #count = 0
23
25
  #entries = new Map()
26
+ #hasEmittedMaxSizeEvent = false
24
27
 
25
28
  /**
26
29
  * @param {import('../../types/cache-interceptor.d.ts').default.MemoryCacheStoreOpts | undefined} [opts]
27
30
  */
28
31
  constructor (opts) {
32
+ super()
29
33
  if (opts) {
30
34
  if (typeof opts !== 'object') {
31
35
  throw new TypeError('MemoryCacheStore options must be an object')
@@ -66,6 +70,22 @@ class MemoryCacheStore {
66
70
  }
67
71
  }
68
72
 
73
+ /**
74
+ * Get the current size of the cache in bytes
75
+ * @returns {number} The current size of the cache in bytes
76
+ */
77
+ get size () {
78
+ return this.#size
79
+ }
80
+
81
+ /**
82
+ * Check if the cache is full (either max size or max count reached)
83
+ * @returns {boolean} True if the cache is full, false otherwise
84
+ */
85
+ isFull () {
86
+ return this.#size >= this.#maxSize || this.#count >= this.#maxCount
87
+ }
88
+
69
89
  /**
70
90
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheKey} req
71
91
  * @returns {import('../../types/cache-interceptor.d.ts').default.GetResult | undefined}
@@ -76,17 +96,9 @@ class MemoryCacheStore {
76
96
  const topLevelKey = `${key.origin}:${key.path}`
77
97
 
78
98
  const now = Date.now()
79
- const entry = this.#entries.get(topLevelKey)?.find((entry) => (
80
- entry.deleteAt > now &&
81
- entry.method === key.method &&
82
- (entry.vary == null || Object.keys(entry.vary).every(headerName => {
83
- if (entry.vary[headerName] === null) {
84
- return key.headers[headerName] === undefined
85
- }
99
+ const entries = this.#entries.get(topLevelKey)
86
100
 
87
- return entry.vary[headerName] === key.headers[headerName]
88
- }))
89
- ))
101
+ const entry = entries ? findEntry(key, entries, now) : null
90
102
 
91
103
  return entry == null
92
104
  ? undefined
@@ -140,12 +152,32 @@ class MemoryCacheStore {
140
152
  entries = []
141
153
  store.#entries.set(topLevelKey, entries)
142
154
  }
143
- entries.push(entry)
155
+ const previousEntry = findEntry(key, entries, Date.now())
156
+ if (previousEntry) {
157
+ const index = entries.indexOf(previousEntry)
158
+ entries.splice(index, 1, entry)
159
+ store.#size -= previousEntry.size
160
+ } else {
161
+ entries.push(entry)
162
+ store.#count += 1
163
+ }
144
164
 
145
165
  store.#size += entry.size
146
- store.#count += 1
147
166
 
167
+ // Check if cache is full and emit event if needed
148
168
  if (store.#size > store.#maxSize || store.#count > store.#maxCount) {
169
+ // Emit maxSizeExceeded event if we haven't already
170
+ if (!store.#hasEmittedMaxSizeEvent) {
171
+ store.emit('maxSizeExceeded', {
172
+ size: store.#size,
173
+ maxSize: store.#maxSize,
174
+ count: store.#count,
175
+ maxCount: store.#maxCount
176
+ })
177
+ store.#hasEmittedMaxSizeEvent = true
178
+ }
179
+
180
+ // Perform eviction
149
181
  for (const [key, entries] of store.#entries) {
150
182
  for (const entry of entries.splice(0, entries.length / 2)) {
151
183
  store.#size -= entry.size
@@ -155,6 +187,11 @@ class MemoryCacheStore {
155
187
  store.#entries.delete(key)
156
188
  }
157
189
  }
190
+
191
+ // Reset the event flag after eviction
192
+ if (store.#size < store.#maxSize && store.#count < store.#maxCount) {
193
+ store.#hasEmittedMaxSizeEvent = false
194
+ }
158
195
  }
159
196
 
160
197
  callback(null)
@@ -180,4 +217,18 @@ class MemoryCacheStore {
180
217
  }
181
218
  }
182
219
 
220
+ function findEntry (key, entries, now) {
221
+ return entries.find((entry) => (
222
+ entry.deleteAt > now &&
223
+ entry.method === key.method &&
224
+ (entry.vary == null || Object.keys(entry.vary).every(headerName => {
225
+ if (entry.vary[headerName] === null) {
226
+ return key.headers[headerName] === undefined
227
+ }
228
+
229
+ return entry.vary[headerName] === key.headers[headerName]
230
+ }))
231
+ ))
232
+ }
233
+
183
234
  module.exports = MemoryCacheStore
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { InvalidArgumentError } = require('../core/errors')
4
- const { kClients, kRunning, kClose, kDestroy, kDispatch } = require('../core/symbols')
4
+ const { kClients, kRunning, kClose, kDestroy, kDispatch, kUrl } = require('../core/symbols')
5
5
  const DispatcherBase = require('./dispatcher-base')
6
6
  const Pool = require('./pool')
7
7
  const Client = require('./client')
@@ -45,22 +45,35 @@ class Agent extends DispatcherBase {
45
45
  }
46
46
 
47
47
  this[kOnConnect] = (origin, targets) => {
48
+ const result = this[kClients].get(origin)
49
+ if (result) {
50
+ result.count += 1
51
+ }
48
52
  this.emit('connect', origin, [this, ...targets])
49
53
  }
50
54
 
51
55
  this[kOnDisconnect] = (origin, targets, err) => {
56
+ const result = this[kClients].get(origin)
57
+ if (result) {
58
+ result.count -= 1
59
+ if (result.count <= 0) {
60
+ this[kClients].delete(origin)
61
+ result.dispatcher.destroy()
62
+ }
63
+ }
52
64
  this.emit('disconnect', origin, [this, ...targets], err)
53
65
  }
54
66
 
55
67
  this[kOnConnectionError] = (origin, targets, err) => {
68
+ // TODO: should this decrement result.count here?
56
69
  this.emit('connectionError', origin, [this, ...targets], err)
57
70
  }
58
71
  }
59
72
 
60
73
  get [kRunning] () {
61
74
  let ret = 0
62
- for (const client of this[kClients].values()) {
63
- ret += client[kRunning]
75
+ for (const { dispatcher } of this[kClients].values()) {
76
+ ret += dispatcher[kRunning]
64
77
  }
65
78
  return ret
66
79
  }
@@ -73,8 +86,8 @@ class Agent extends DispatcherBase {
73
86
  throw new InvalidArgumentError('opts.origin must be a non-empty string or URL.')
74
87
  }
75
88
 
76
- let dispatcher = this[kClients].get(key)
77
-
89
+ const result = this[kClients].get(key)
90
+ let dispatcher = result && result.dispatcher
78
91
  if (!dispatcher) {
79
92
  dispatcher = this[kFactory](opts.origin, this[kOptions])
80
93
  .on('drain', this[kOnDrain])
@@ -82,10 +95,7 @@ class Agent extends DispatcherBase {
82
95
  .on('disconnect', this[kOnDisconnect])
83
96
  .on('connectionError', this[kOnConnectionError])
84
97
 
85
- // This introduces a tiny memory leak, as dispatchers are never removed from the map.
86
- // TODO(mcollina): remove te timer when the client/pool do not have any more
87
- // active connections.
88
- this[kClients].set(key, dispatcher)
98
+ this[kClients].set(key, { count: 0, dispatcher })
89
99
  }
90
100
 
91
101
  return dispatcher.dispatch(opts, handler)
@@ -93,8 +103,8 @@ class Agent extends DispatcherBase {
93
103
 
94
104
  async [kClose] () {
95
105
  const closePromises = []
96
- for (const client of this[kClients].values()) {
97
- closePromises.push(client.close())
106
+ for (const { dispatcher } of this[kClients].values()) {
107
+ closePromises.push(dispatcher.close())
98
108
  }
99
109
  this[kClients].clear()
100
110
 
@@ -103,13 +113,23 @@ class Agent extends DispatcherBase {
103
113
 
104
114
  async [kDestroy] (err) {
105
115
  const destroyPromises = []
106
- for (const client of this[kClients].values()) {
107
- destroyPromises.push(client.destroy(err))
116
+ for (const { dispatcher } of this[kClients].values()) {
117
+ destroyPromises.push(dispatcher.destroy(err))
108
118
  }
109
119
  this[kClients].clear()
110
120
 
111
121
  await Promise.all(destroyPromises)
112
122
  }
123
+
124
+ get stats () {
125
+ const allClientStats = {}
126
+ for (const { dispatcher } of this[kClients].values()) {
127
+ if (dispatcher.stats) {
128
+ allClientStats[dispatcher[kUrl].origin] = dispatcher.stats
129
+ }
130
+ }
131
+ return allClientStats
132
+ }
113
133
  }
114
134
 
115
135
  module.exports = Agent
@@ -295,11 +295,13 @@ function writeH2 (client, request) {
295
295
  if (Array.isArray(val)) {
296
296
  for (let i = 0; i < val.length; i++) {
297
297
  if (headers[key]) {
298
- headers[key] += `,${val[i]}`
298
+ headers[key] += `, ${val[i]}`
299
299
  } else {
300
300
  headers[key] = val[i]
301
301
  }
302
302
  }
303
+ } else if (headers[key]) {
304
+ headers[key] += `, ${val}`
303
305
  } else {
304
306
  headers[key] = val
305
307
  }
@@ -4,6 +4,7 @@ const assert = require('node:assert')
4
4
  const net = require('node:net')
5
5
  const http = require('node:http')
6
6
  const util = require('../core/util.js')
7
+ const { ClientStats } = require('../util/stats.js')
7
8
  const { channels } = require('../core/diagnostics.js')
8
9
  const Request = require('../core/request.js')
9
10
  const DispatcherBase = require('./dispatcher-base')
@@ -260,6 +261,10 @@ class Client extends DispatcherBase {
260
261
  this[kResume](true)
261
262
  }
262
263
 
264
+ get stats () {
265
+ return new ClientStats(this)
266
+ }
267
+
263
268
  get [kPending] () {
264
269
  return this[kQueue].length - this[kPendingIdx]
265
270
  }
@@ -1,9 +1,9 @@
1
1
  'use strict'
2
2
 
3
+ const { PoolStats } = require('../util/stats.js')
3
4
  const DispatcherBase = require('./dispatcher-base')
4
5
  const FixedQueue = require('./fixed-queue')
5
6
  const { kConnected, kSize, kRunning, kPending, kQueued, kBusy, kFree, kUrl, kClose, kDestroy, kDispatch } = require('../core/symbols')
6
- const PoolStats = require('./pool-stats')
7
7
 
8
8
  const kClients = Symbol('clients')
9
9
  const kNeedDrain = Symbol('needDrain')
@@ -16,7 +16,6 @@ const kOnConnectionError = Symbol('onConnectionError')
16
16
  const kGetDispatcher = Symbol('get dispatcher')
17
17
  const kAddClient = Symbol('add client')
18
18
  const kRemoveClient = Symbol('remove client')
19
- const kStats = Symbol('stats')
20
19
 
21
20
  class PoolBase extends DispatcherBase {
22
21
  constructor () {
@@ -67,8 +66,6 @@ class PoolBase extends DispatcherBase {
67
66
  this[kOnConnectionError] = (origin, targets, err) => {
68
67
  pool.emit('connectionError', origin, [pool, ...targets], err)
69
68
  }
70
-
71
- this[kStats] = new PoolStats(this)
72
69
  }
73
70
 
74
71
  get [kBusy] () {
@@ -108,7 +105,7 @@ class PoolBase extends DispatcherBase {
108
105
  }
109
106
 
110
107
  get stats () {
111
- return this[kStats]
108
+ return new PoolStats(this)
112
109
  }
113
110
 
114
111
  async [kClose] () {
@@ -5,7 +5,8 @@ const {
5
5
  kClients,
6
6
  kNeedDrain,
7
7
  kAddClient,
8
- kGetDispatcher
8
+ kGetDispatcher,
9
+ kRemoveClient
9
10
  } = require('./pool-base')
10
11
  const Client = require('./client')
11
12
  const {
@@ -35,6 +36,7 @@ class Pool extends PoolBase {
35
36
  autoSelectFamily,
36
37
  autoSelectFamilyAttemptTimeout,
37
38
  allowH2,
39
+ clientTtl,
38
40
  ...options
39
41
  } = {}) {
40
42
  if (connections != null && (!Number.isFinite(connections) || connections < 0)) {
@@ -65,12 +67,20 @@ class Pool extends PoolBase {
65
67
 
66
68
  this[kConnections] = connections || null
67
69
  this[kUrl] = util.parseOrigin(origin)
68
- this[kOptions] = { ...util.deepClone(options), connect, allowH2 }
70
+ this[kOptions] = { ...util.deepClone(options), connect, allowH2, clientTtl }
69
71
  this[kOptions].interceptors = options.interceptors
70
72
  ? { ...options.interceptors }
71
73
  : undefined
72
74
  this[kFactory] = factory
73
75
 
76
+ this.on('connect', (origin, targets) => {
77
+ if (clientTtl != null && clientTtl > 0) {
78
+ for (const target of targets) {
79
+ Object.assign(target, { ttl: Date.now() })
80
+ }
81
+ }
82
+ })
83
+
74
84
  this.on('connectionError', (origin, targets, error) => {
75
85
  // If a connection error occurs, we remove the client from the pool,
76
86
  // and emit a connectionError event. They will not be re-used.
@@ -87,8 +97,12 @@ class Pool extends PoolBase {
87
97
  }
88
98
 
89
99
  [kGetDispatcher] () {
100
+ const clientTtlOption = this[kOptions].clientTtl
90
101
  for (const client of this[kClients]) {
91
- if (!client[kNeedDrain]) {
102
+ // check ttl of client and if it's stale, remove it from the pool
103
+ if (clientTtlOption != null && clientTtlOption > 0 && client.ttl && ((Date.now() - client.ttl) > clientTtlOption)) {
104
+ this[kRemoveClient](client)
105
+ } else if (!client[kNeedDrain]) {
92
106
  return client
93
107
  }
94
108
  }
@@ -1,12 +1,13 @@
1
1
  'use strict'
2
2
 
3
- const { kProxy, kClose, kDestroy } = require('../core/symbols')
3
+ const { kProxy, kClose, kDestroy, kDispatch, kConnector } = require('../core/symbols')
4
4
  const { URL } = require('node:url')
5
5
  const Agent = require('./agent')
6
6
  const Pool = require('./pool')
7
7
  const DispatcherBase = require('./dispatcher-base')
8
8
  const { InvalidArgumentError, RequestAbortedError, SecureProxyConnectionError } = require('../core/errors')
9
9
  const buildConnector = require('../core/connect')
10
+ const Client = require('./client')
10
11
 
11
12
  const kAgent = Symbol('proxy agent')
12
13
  const kClient = Symbol('proxy client')
@@ -14,6 +15,7 @@ const kProxyHeaders = Symbol('proxy headers')
14
15
  const kRequestTls = Symbol('request tls settings')
15
16
  const kProxyTls = Symbol('proxy tls settings')
16
17
  const kConnectEndpoint = Symbol('connect endpoint function')
18
+ const kTunnelProxy = Symbol('tunnel proxy')
17
19
 
18
20
  function defaultProtocolPort (protocol) {
19
21
  return protocol === 'https:' ? 443 : 80
@@ -25,6 +27,61 @@ function defaultFactory (origin, opts) {
25
27
 
26
28
  const noop = () => {}
27
29
 
30
+ class ProxyClient extends DispatcherBase {
31
+ #client = null
32
+ constructor (origin, opts) {
33
+ if (typeof origin === 'string') {
34
+ origin = new URL(origin)
35
+ }
36
+
37
+ if (origin.protocol !== 'http:' && origin.protocol !== 'https:') {
38
+ throw new InvalidArgumentError('ProxyClient only supports http and https protocols')
39
+ }
40
+
41
+ super()
42
+
43
+ this.#client = new Client(origin, opts)
44
+ }
45
+
46
+ async [kClose] () {
47
+ await this.#client.close()
48
+ }
49
+
50
+ async [kDestroy] () {
51
+ await this.#client.destroy()
52
+ }
53
+
54
+ async [kDispatch] (opts, handler) {
55
+ const { method, origin } = opts
56
+ if (method === 'CONNECT') {
57
+ this.#client[kConnector]({
58
+ origin,
59
+ port: opts.port || defaultProtocolPort(opts.protocol),
60
+ path: opts.host,
61
+ signal: opts.signal,
62
+ headers: {
63
+ ...this[kProxyHeaders],
64
+ host: opts.host
65
+ },
66
+ servername: this[kProxyTls]?.servername || opts.servername
67
+ },
68
+ (err, socket) => {
69
+ if (err) {
70
+ handler.callback(err)
71
+ } else {
72
+ handler.callback(null, { socket, statusCode: 200 })
73
+ }
74
+ }
75
+ )
76
+ return
77
+ }
78
+ if (typeof origin === 'string') {
79
+ opts.origin = new URL(origin)
80
+ }
81
+
82
+ return this.#client.dispatch(opts, handler)
83
+ }
84
+ }
28
85
  class ProxyAgent extends DispatcherBase {
29
86
  constructor (opts) {
30
87
  if (!opts || (typeof opts === 'object' && !(opts instanceof URL) && !opts.uri)) {
@@ -36,6 +93,8 @@ class ProxyAgent extends DispatcherBase {
36
93
  throw new InvalidArgumentError('Proxy opts.clientFactory must be a function.')
37
94
  }
38
95
 
96
+ const { proxyTunnel = true } = opts
97
+
39
98
  super()
40
99
 
41
100
  const url = this.#getUrl(opts)
@@ -57,9 +116,19 @@ class ProxyAgent extends DispatcherBase {
57
116
  this[kProxyHeaders]['proxy-authorization'] = `Basic ${Buffer.from(`${decodeURIComponent(username)}:${decodeURIComponent(password)}`).toString('base64')}`
58
117
  }
59
118
 
119
+ const factory = (!proxyTunnel && protocol === 'http:')
120
+ ? (origin, options) => {
121
+ if (origin.protocol === 'http:') {
122
+ return new ProxyClient(origin, options)
123
+ }
124
+ return new Client(origin, options)
125
+ }
126
+ : undefined
127
+
60
128
  const connect = buildConnector({ ...opts.proxyTls })
61
129
  this[kConnectEndpoint] = buildConnector({ ...opts.requestTls })
62
- this[kClient] = clientFactory(url, { connect })
130
+ this[kClient] = clientFactory(url, { connect, factory })
131
+ this[kTunnelProxy] = proxyTunnel
63
132
  this[kAgent] = new Agent({
64
133
  ...opts,
65
134
  connect: async (opts, callback) => {
@@ -115,6 +184,10 @@ class ProxyAgent extends DispatcherBase {
115
184
  headers.host = host
116
185
  }
117
186
 
187
+ if (!this.#shouldConnect(new URL(opts.origin))) {
188
+ opts.path = opts.origin + opts.path
189
+ }
190
+
118
191
  return this[kAgent].dispatch(
119
192
  {
120
193
  ...opts,
@@ -147,6 +220,19 @@ class ProxyAgent extends DispatcherBase {
147
220
  await this[kAgent].destroy()
148
221
  await this[kClient].destroy()
149
222
  }
223
+
224
+ #shouldConnect (uri) {
225
+ if (typeof uri === 'string') {
226
+ uri = new URL(uri)
227
+ }
228
+ if (this[kTunnelProxy]) {
229
+ return true
230
+ }
231
+ if (uri.protocol !== 'http:' || this[kProxy].protocol !== 'http:') {
232
+ return true
233
+ }
234
+ return false
235
+ }
150
236
  }
151
237
 
152
238
  /**
@@ -20,7 +20,12 @@ const { AbortError } = require('../core/errors.js')
20
20
  */
21
21
  function needsRevalidation (result, cacheControlDirectives) {
22
22
  if (cacheControlDirectives?.['no-cache']) {
23
- // Always revalidate requests with the no-cache directive
23
+ // Always revalidate requests with the no-cache request directive
24
+ return true
25
+ }
26
+
27
+ if (result.cacheControlDirectives?.['no-cache'] && !Array.isArray(result.cacheControlDirectives['no-cache'])) {
28
+ // Always revalidate requests with unqualified no-cache response directive
24
29
  return true
25
30
  }
26
31
 
@@ -233,7 +238,7 @@ function handleResult (
233
238
  }
234
239
 
235
240
  let headers = {
236
- ...normaliseHeaders(opts),
241
+ ...opts.headers,
237
242
  'if-modified-since': new Date(result.cachedAt).toUTCString()
238
243
  }
239
244
 
@@ -319,6 +324,11 @@ module.exports = (opts = {}) => {
319
324
  return dispatch(opts, handler)
320
325
  }
321
326
 
327
+ opts = {
328
+ ...opts,
329
+ headers: normaliseHeaders(opts)
330
+ }
331
+
322
332
  const reqCacheControl = opts.headers?.['cache-control']
323
333
  ? parseCacheControlHeader(opts.headers['cache-control'])
324
334
  : undefined
@@ -16,11 +16,12 @@ const {
16
16
  kMockAgentIsCallHistoryEnabled,
17
17
  kMockAgentAddCallHistoryLog,
18
18
  kMockAgentMockCallHistoryInstance,
19
+ kMockAgentAcceptsNonStandardSearchParameters,
19
20
  kMockCallHistoryAddLog
20
21
  } = require('./mock-symbols')
21
22
  const MockClient = require('./mock-client')
22
23
  const MockPool = require('./mock-pool')
23
- const { matchValue, buildAndValidateMockOptions } = require('./mock-utils')
24
+ const { matchValue, normalizeSearchParams, buildAndValidateMockOptions } = require('./mock-utils')
24
25
  const { InvalidArgumentError, UndiciError } = require('../core/errors')
25
26
  const Dispatcher = require('../dispatcher/dispatcher')
26
27
  const PendingInterceptorsFormatter = require('./pending-interceptors-formatter')
@@ -35,6 +36,7 @@ class MockAgent extends Dispatcher {
35
36
  this[kNetConnect] = true
36
37
  this[kIsMockActive] = true
37
38
  this[kMockAgentIsCallHistoryEnabled] = mockOptions?.enableCallHistory ?? false
39
+ this[kMockAgentAcceptsNonStandardSearchParameters] = mockOptions?.acceptNonStandardSearchParameters ?? false
38
40
 
39
41
  // Instantiate Agent and encapsulate
40
42
  if (opts?.agent && typeof opts.agent.dispatch !== 'function') {
@@ -67,7 +69,17 @@ class MockAgent extends Dispatcher {
67
69
 
68
70
  this[kMockAgentAddCallHistoryLog](opts)
69
71
 
70
- return this[kAgent].dispatch(opts, handler)
72
+ const acceptNonStandardSearchParameters = this[kMockAgentAcceptsNonStandardSearchParameters]
73
+
74
+ const dispatchOpts = { ...opts }
75
+
76
+ if (acceptNonStandardSearchParameters && dispatchOpts.path) {
77
+ const [path, searchParams] = dispatchOpts.path.split('?')
78
+ const normalizedSearchParams = normalizeSearchParams(searchParams, acceptNonStandardSearchParameters)
79
+ dispatchOpts.path = `${path}?${normalizedSearchParams}`
80
+ }
81
+
82
+ return this[kAgent].dispatch(dispatchOpts, handler)
71
83
  }
72
84
 
73
85
  async close () {
@@ -147,7 +159,7 @@ class MockAgent extends Dispatcher {
147
159
  }
148
160
 
149
161
  [kMockAgentSet] (origin, dispatcher) {
150
- this[kClients].set(origin, dispatcher)
162
+ this[kClients].set(origin, { count: 0, dispatcher })
151
163
  }
152
164
 
153
165
  [kFactory] (origin) {
@@ -159,9 +171,9 @@ class MockAgent extends Dispatcher {
159
171
 
160
172
  [kMockAgentGet] (origin) {
161
173
  // First check if we can immediately find it
162
- const client = this[kClients].get(origin)
163
- if (client) {
164
- return client
174
+ const result = this[kClients].get(origin)
175
+ if (result?.dispatcher) {
176
+ return result.dispatcher
165
177
  }
166
178
 
167
179
  // If the origin is not a string create a dummy parent pool and return to user
@@ -172,11 +184,11 @@ class MockAgent extends Dispatcher {
172
184
  }
173
185
 
174
186
  // If we match, create a pool and assign the same dispatches
175
- for (const [keyMatcher, nonExplicitDispatcher] of Array.from(this[kClients])) {
176
- if (nonExplicitDispatcher && typeof keyMatcher !== 'string' && matchValue(keyMatcher, origin)) {
187
+ for (const [keyMatcher, result] of Array.from(this[kClients])) {
188
+ if (result && typeof keyMatcher !== 'string' && matchValue(keyMatcher, origin)) {
177
189
  const dispatcher = this[kFactory](origin)
178
190
  this[kMockAgentSet](origin, dispatcher)
179
- dispatcher[kDispatches] = nonExplicitDispatcher[kDispatches]
191
+ dispatcher[kDispatches] = result.dispatcher[kDispatches]
180
192
  return dispatcher
181
193
  }
182
194
  }
@@ -190,7 +202,7 @@ class MockAgent extends Dispatcher {
190
202
  const mockAgentClients = this[kClients]
191
203
 
192
204
  return Array.from(mockAgentClients.entries())
193
- .flatMap(([origin, scope]) => scope[kDispatches].map(dispatch => ({ ...dispatch, origin })))
205
+ .flatMap(([origin, result]) => result.dispatcher[kDispatches].map(dispatch => ({ ...dispatch, origin })))
194
206
  .filter(({ pending }) => pending)
195
207
  }
196
208
 
@@ -26,5 +26,6 @@ module.exports = {
26
26
  kMockAgentRegisterCallHistory: Symbol('mock agent register mock call history'),
27
27
  kMockAgentAddCallHistoryLog: Symbol('mock agent add call history log'),
28
28
  kMockAgentIsCallHistoryEnabled: Symbol('mock agent is call history enabled'),
29
+ kMockAgentAcceptsNonStandardSearchParameters: Symbol('mock agent accepts non standard search parameters'),
29
30
  kMockCallHistoryAddLog: Symbol('mock call history add log')
30
31
  }
@@ -92,13 +92,42 @@ function matchHeaders (mockDispatch, headers) {
92
92
  return true
93
93
  }
94
94
 
95
+ function normalizeSearchParams (query) {
96
+ if (typeof query !== 'string') {
97
+ return query
98
+ }
99
+
100
+ const originalQp = new URLSearchParams(query)
101
+ const normalizedQp = new URLSearchParams()
102
+
103
+ for (let [key, value] of originalQp.entries()) {
104
+ key = key.replace('[]', '')
105
+
106
+ const valueRepresentsString = /^(['"]).*\1$/.test(value)
107
+ if (valueRepresentsString) {
108
+ normalizedQp.append(key, value)
109
+ continue
110
+ }
111
+
112
+ if (value.includes(',')) {
113
+ const values = value.split(',')
114
+ for (const v of values) {
115
+ normalizedQp.append(key, v)
116
+ }
117
+ continue
118
+ }
119
+
120
+ normalizedQp.append(key, value)
121
+ }
122
+
123
+ return normalizedQp
124
+ }
125
+
95
126
  function safeUrl (path) {
96
127
  if (typeof path !== 'string') {
97
128
  return path
98
129
  }
99
-
100
130
  const pathSegments = path.split('?', 3)
101
-
102
131
  if (pathSegments.length !== 2) {
103
132
  return path
104
133
  }
@@ -376,6 +405,10 @@ function buildAndValidateMockOptions (opts) {
376
405
  throw new InvalidArgumentError('options.enableCallHistory must to be a boolean')
377
406
  }
378
407
 
408
+ if ('acceptNonStandardSearchParameters' in mockOptions && typeof mockOptions.acceptNonStandardSearchParameters !== 'boolean') {
409
+ throw new InvalidArgumentError('options.acceptNonStandardSearchParameters must to be a boolean')
410
+ }
411
+
379
412
  return mockOptions
380
413
  }
381
414
  }
@@ -395,5 +428,6 @@ module.exports = {
395
428
  checkNetConnect,
396
429
  buildAndValidateMockOptions,
397
430
  getHeaderByName,
398
- buildHeadersFromArray
431
+ buildHeadersFromArray,
432
+ normalizeSearchParams
399
433
  }
package/lib/util/cache.js CHANGED
@@ -12,13 +12,11 @@ function makeCacheKey (opts) {
12
12
  throw new Error('opts.origin is undefined')
13
13
  }
14
14
 
15
- const headers = normaliseHeaders(opts)
16
-
17
15
  return {
18
16
  origin: opts.origin.toString(),
19
17
  method: opts.method,
20
18
  path: opts.path,
21
- headers
19
+ headers: opts.headers
22
20
  }
23
21
  }
24
22
 
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+
3
+ const {
4
+ kConnected,
5
+ kPending,
6
+ kRunning,
7
+ kSize,
8
+ kFree,
9
+ kQueued
10
+ } = require('../core/symbols')
11
+
12
+ class ClientStats {
13
+ constructor (client) {
14
+ this.connected = client[kConnected]
15
+ this.pending = client[kPending]
16
+ this.running = client[kRunning]
17
+ this.size = client[kSize]
18
+ }
19
+ }
20
+
21
+ class PoolStats {
22
+ constructor (pool) {
23
+ this.connected = pool[kConnected]
24
+ this.free = pool[kFree]
25
+ this.pending = pool[kPending]
26
+ this.queued = pool[kQueued]
27
+ this.running = pool[kRunning]
28
+ this.size = pool[kSize]
29
+ }
30
+ }
31
+
32
+ module.exports = { ClientStats, PoolStats }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.8.0",
3
+ "version": "7.10.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": {
package/types/agent.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { URL } from 'url'
2
2
  import Pool from './pool'
3
3
  import Dispatcher from './dispatcher'
4
+ import TClientStats from './client-stats'
5
+ import TPoolStats from './pool-stats'
4
6
 
5
7
  export default Agent
6
8
 
@@ -12,6 +14,8 @@ declare class Agent extends Dispatcher {
12
14
  destroyed: boolean
13
15
  /** Dispatches a request. */
14
16
  dispatch (options: Agent.DispatchOptions, handler: Dispatcher.DispatchHandler): boolean
17
+ /** Aggregate stats for a Agent by origin. */
18
+ readonly stats: Record<string, TClientStats | TPoolStats>
15
19
  }
16
20
 
17
21
  declare namespace Agent {
@@ -0,0 +1,15 @@
1
+ import Client from './client'
2
+
3
+ export default ClientStats
4
+
5
+ declare class ClientStats {
6
+ constructor (pool: Client)
7
+ /** If socket has open connection. */
8
+ connected: boolean
9
+ /** Number of open socket connections in this client that do not have an active request. */
10
+ pending: number
11
+ /** Number of currently active requests of this client. */
12
+ running: number
13
+ /** Number of active, pending, or queued requests of this client. */
14
+ size: number
15
+ }
package/types/client.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { URL } from 'url'
2
2
  import Dispatcher from './dispatcher'
3
3
  import buildConnector from './connector'
4
+ import TClientStats from './client-stats'
4
5
 
5
6
  type ClientConnectOptions = Omit<Dispatcher.ConnectOptions, 'origin'>
6
7
 
@@ -15,6 +16,8 @@ export class Client extends Dispatcher {
15
16
  closed: boolean
16
17
  /** `true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed. */
17
18
  destroyed: boolean
19
+ /** Aggregate stats for a Client. */
20
+ readonly stats: TClientStats
18
21
 
19
22
  // Override dispatcher APIs.
20
23
  override connect (
@@ -84,13 +87,13 @@ export declare namespace Client {
84
87
  /**
85
88
  * @description Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.
86
89
  * @default false
87
- */
90
+ */
88
91
  allowH2?: boolean;
89
92
  /**
90
93
  * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
91
94
  * @default 100
92
- */
93
- maxConcurrentStreams?: number
95
+ */
96
+ maxConcurrentStreams?: number;
94
97
  }
95
98
  export interface SocketInfo {
96
99
  localAddress?: string
@@ -59,6 +59,9 @@ declare namespace MockAgent {
59
59
  /** Ignore trailing slashes in the path */
60
60
  ignoreTrailingSlash?: boolean;
61
61
 
62
+ /** Accept URLs with search parameters using non standard syntaxes. default false */
63
+ acceptNonStandardSearchParameters?: boolean;
64
+
62
65
  /** Enable call history. you can either call MockAgent.enableCallHistory(). default false */
63
66
  enableCallHistory?: boolean
64
67
  }
package/types/pool.d.ts CHANGED
@@ -33,6 +33,8 @@ declare namespace Pool {
33
33
  factory?(origin: URL, opts: object): Dispatcher;
34
34
  /** The max number of clients to create. `null` if no limit. Default `null`. */
35
35
  connections?: number | null;
36
+ /** The amount of time before a client is removed from the pool and closed. `null` if no time limit. Default `null` */
37
+ clientTtl?: number | null;
36
38
 
37
39
  interceptors?: { Pool?: readonly Dispatcher.DispatchInterceptor[] } & Client.Options['interceptors']
38
40
  }
@@ -24,5 +24,6 @@ declare namespace ProxyAgent {
24
24
  requestTls?: buildConnector.BuildOptions;
25
25
  proxyTls?: buildConnector.BuildOptions;
26
26
  clientFactory?(origin: URL, opts: object): Dispatcher;
27
+ proxyTunnel?: boolean;
27
28
  }
28
29
  }
@@ -1,36 +0,0 @@
1
- 'use strict'
2
-
3
- const { kFree, kConnected, kPending, kQueued, kRunning, kSize } = require('../core/symbols')
4
- const kPool = Symbol('pool')
5
-
6
- class PoolStats {
7
- constructor (pool) {
8
- this[kPool] = pool
9
- }
10
-
11
- get connected () {
12
- return this[kPool][kConnected]
13
- }
14
-
15
- get free () {
16
- return this[kPool][kFree]
17
- }
18
-
19
- get pending () {
20
- return this[kPool][kPending]
21
- }
22
-
23
- get queued () {
24
- return this[kPool][kQueued]
25
- }
26
-
27
- get running () {
28
- return this[kPool][kRunning]
29
- }
30
-
31
- get size () {
32
- return this[kPool][kSize]
33
- }
34
- }
35
-
36
- module.exports = PoolStats