undici 7.9.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.
@@ -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
 
@@ -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}
@@ -144,7 +164,20 @@ class MemoryCacheStore {
144
164
 
145
165
  store.#size += entry.size
146
166
 
167
+ // Check if cache is full and emit event if needed
147
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
148
181
  for (const [key, entries] of store.#entries) {
149
182
  for (const entry of entries.splice(0, entries.length / 2)) {
150
183
  store.#size -= entry.size
@@ -154,6 +187,11 @@ class MemoryCacheStore {
154
187
  store.#entries.delete(key)
155
188
  }
156
189
  }
190
+
191
+ // Reset the event flag after eviction
192
+ if (store.#size < store.#maxSize && store.#count < store.#maxCount) {
193
+ store.#hasEmittedMaxSizeEvent = false
194
+ }
157
195
  }
158
196
 
159
197
  callback(null)
@@ -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,8 +113,8 @@ 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
 
@@ -113,9 +123,9 @@ class Agent extends DispatcherBase {
113
123
 
114
124
  get stats () {
115
125
  const allClientStats = {}
116
- for (const client of this[kClients].values()) {
117
- if (client.stats) {
118
- allClientStats[client[kUrl].origin] = client.stats
126
+ for (const { dispatcher } of this[kClients].values()) {
127
+ if (dispatcher.stats) {
128
+ allClientStats[dispatcher[kUrl].origin] = dispatcher.stats
119
129
  }
120
130
  }
121
131
  return allClientStats
@@ -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
  /**
@@ -159,7 +159,7 @@ class MockAgent extends Dispatcher {
159
159
  }
160
160
 
161
161
  [kMockAgentSet] (origin, dispatcher) {
162
- this[kClients].set(origin, dispatcher)
162
+ this[kClients].set(origin, { count: 0, dispatcher })
163
163
  }
164
164
 
165
165
  [kFactory] (origin) {
@@ -171,9 +171,9 @@ class MockAgent extends Dispatcher {
171
171
 
172
172
  [kMockAgentGet] (origin) {
173
173
  // First check if we can immediately find it
174
- const client = this[kClients].get(origin)
175
- if (client) {
176
- return client
174
+ const result = this[kClients].get(origin)
175
+ if (result?.dispatcher) {
176
+ return result.dispatcher
177
177
  }
178
178
 
179
179
  // If the origin is not a string create a dummy parent pool and return to user
@@ -184,11 +184,11 @@ class MockAgent extends Dispatcher {
184
184
  }
185
185
 
186
186
  // If we match, create a pool and assign the same dispatches
187
- for (const [keyMatcher, nonExplicitDispatcher] of Array.from(this[kClients])) {
188
- 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)) {
189
189
  const dispatcher = this[kFactory](origin)
190
190
  this[kMockAgentSet](origin, dispatcher)
191
- dispatcher[kDispatches] = nonExplicitDispatcher[kDispatches]
191
+ dispatcher[kDispatches] = result.dispatcher[kDispatches]
192
192
  return dispatcher
193
193
  }
194
194
  }
@@ -202,7 +202,7 @@ class MockAgent extends Dispatcher {
202
202
  const mockAgentClients = this[kClients]
203
203
 
204
204
  return Array.from(mockAgentClients.entries())
205
- .flatMap(([origin, scope]) => scope[kDispatches].map(dispatch => ({ ...dispatch, origin })))
205
+ .flatMap(([origin, result]) => result.dispatcher[kDispatches].map(dispatch => ({ ...dispatch, origin })))
206
206
  .filter(({ pending }) => pending)
207
207
  }
208
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.9.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/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
  }