undici 7.20.0 → 7.22.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.
@@ -66,10 +66,12 @@ Parameters:
66
66
  Returns: `GetResult | Promise<GetResult | undefined> | undefined` - If the request is cached, the cached response is returned. If the request's method is anything other than HEAD, the response is also returned.
67
67
  If the request isn't cached, `undefined` is returned.
68
68
 
69
+ The `get` method may return a `Promise` for async cache stores (e.g. Redis-backed or remote stores). The cache interceptor handles both synchronous and asynchronous return values, including in revalidation paths (304 Not Modified handling and stale-while-revalidate background revalidation).
70
+
69
71
  Response properties:
70
72
 
71
73
  * **response** `CacheValue` - The cached response data.
72
- * **body** `Readable | undefined` - The response's body.
74
+ * **body** `Readable | Iterable<Buffer> | undefined` - The response's body. This can be an array of `Buffer` chunks (with a `.values()` method) or a `Readable` stream. Both formats are supported in all code paths, including 304 revalidation.
73
75
 
74
76
  ### Function: `createWriteStream`
75
77
 
@@ -98,8 +100,11 @@ This is an interface containing the majority of a response's data (minus the bod
98
100
 
99
101
  ### Property `vary`
100
102
 
101
- `Record<string, string | string[]> | undefined` - The headers defined by the response's `Vary` header
102
- and their respective values for later comparison
103
+ `Record<string, string | string[] | null> | undefined` - The headers defined by the response's `Vary` header
104
+ and their respective values for later comparison. Values are `null` when the
105
+ header specified in `Vary` was not present in the original request. These `null`
106
+ values are automatically filtered out during revalidation so they are not sent
107
+ as request headers.
103
108
 
104
109
  For example, for a response like
105
110
  ```
@@ -116,6 +121,14 @@ This would be
116
121
  }
117
122
  ```
118
123
 
124
+ If the original request did not include the `accepts` header:
125
+ ```js
126
+ {
127
+ 'content-encoding': 'utf8',
128
+ accepts: null
129
+ }
130
+ ```
131
+
119
132
  ### Property `cachedAt`
120
133
 
121
134
  `number` - Time in millis that this value was cached.
@@ -34,6 +34,7 @@ Returns: `Client`
34
34
  * **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.
35
35
  * **initialWindowSize**: `number` (optional) - Default: `262144` (256KB). Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). Must be a positive integer greater than 0. This default is higher than Node.js core's default (65535 bytes) to improve throughput, Node's choice is very conservative for current high-bandwith networks. See [RFC 7540 Section 6.9.2](https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2) for more details.
36
36
  * **connectionWindowSize**: `number` (optional) - Default `524288` (512KB). Sets the HTTP/2 connection-level flow-control window size using `ClientHttp2Session.setLocalWindowSize()`. Must be a positive integer greater than 0. This provides better flow control for the entire connection across multiple streams. See [Node.js HTTP/2 documentation](https://nodejs.org/api/http2.html#clienthttp2sessionsetlocalwindowsize) for more details.
37
+ * **pingInterval**: `number` - Default: `60e3`. The time interval in milliseconds between PING frames sent to the server. Set to `0` to disable PING frames. This is only applicable for HTTP/2 connections. This will emit a `ping` event on the client with the duration of the ping in milliseconds.
37
38
 
38
39
  > **Notes about HTTP/2**
39
40
  > - It only works under TLS connections. h2c is not supported.
@@ -48,6 +48,7 @@ Returns: `H2CClient`
48
48
  - **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
49
49
  - **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.
50
50
  - **pipelining** `number | null` (optional) - Default to `maxConcurrentStreams` - The amount of concurrent requests sent over a single HTTP/2 session in accordance with [RFC-7540](https://httpwg.org/specs/rfc7540.html#StreamsLayer) Stream specification. Streams can be closed up by remote server at any time.
51
+ - **pingInterval**: `number` - Default: `60e3`. The time interval in milliseconds between PING frames sent to the server. Set to `0` to disable PING frames. This is only applicable for HTTP/2 connections.
51
52
  - **connect** `ConnectOptions | null` (optional) - Default: `null`.
52
53
  - **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source.
53
54
  - **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.
@@ -15,7 +15,7 @@ Arguments:
15
15
 
16
16
  This example will not work in browsers or other platforms that don't allow passing an object.
17
17
 
18
- ```mjs
18
+ ```js
19
19
  import { WebSocket, ProxyAgent } from 'undici'
20
20
 
21
21
  const proxyAgent = new ProxyAgent('my.proxy.server')
@@ -28,7 +28,7 @@ const ws = new WebSocket('wss://echo.websocket.events', {
28
28
 
29
29
  If you do not need a custom Dispatcher, it's recommended to use the following pattern:
30
30
 
31
- ```mjs
31
+ ```js
32
32
  import { WebSocket } from 'undici'
33
33
 
34
34
  const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
@@ -44,7 +44,7 @@ const ws = new WebSocket('wss://echo.websocket.events', ['echo', 'chat'])
44
44
 
45
45
  This example will not work in browsers or other platforms that don't allow passing an object.
46
46
 
47
- ```mjs
47
+ ```js
48
48
  import { Agent } from 'undici'
49
49
 
50
50
  const agent = new Agent({ allowH2: true })
package/index-fetch.js CHANGED
@@ -4,6 +4,10 @@ const { getGlobalDispatcher, setGlobalDispatcher } = require('./lib/global')
4
4
  const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
5
5
  const fetchImpl = require('./lib/web/fetch').fetch
6
6
 
7
+ // Capture __filename at module load time for stack trace augmentation.
8
+ // This may be undefined when bundled in environments like Node.js internals.
9
+ const currentFilename = typeof __filename !== 'undefined' ? __filename : undefined
10
+
7
11
  function appendFetchStackTrace (err, filename) {
8
12
  if (!err || typeof err !== 'object') {
9
13
  return
@@ -30,7 +34,11 @@ function appendFetchStackTrace (err, filename) {
30
34
 
31
35
  module.exports.fetch = function fetch (init, options = undefined) {
32
36
  return fetchImpl(init, options).catch(err => {
33
- appendFetchStackTrace(err, __filename)
37
+ if (currentFilename) {
38
+ appendFetchStackTrace(err, currentFilename)
39
+ } else if (err && typeof err === 'object') {
40
+ Error.captureStackTrace(err, module.exports.fetch)
41
+ }
34
42
  throw err
35
43
  })
36
44
  }
package/index.js CHANGED
@@ -121,6 +121,10 @@ module.exports.getGlobalDispatcher = getGlobalDispatcher
121
121
 
122
122
  const fetchImpl = require('./lib/web/fetch').fetch
123
123
 
124
+ // Capture __filename at module load time for stack trace augmentation.
125
+ // This may be undefined when bundled in environments like Node.js internals.
126
+ const currentFilename = typeof __filename !== 'undefined' ? __filename : undefined
127
+
124
128
  function appendFetchStackTrace (err, filename) {
125
129
  if (!err || typeof err !== 'object') {
126
130
  return
@@ -147,7 +151,11 @@ function appendFetchStackTrace (err, filename) {
147
151
 
148
152
  module.exports.fetch = function fetch (init, options = undefined) {
149
153
  return fetchImpl(init, options).catch(err => {
150
- appendFetchStackTrace(err, __filename)
154
+ if (currentFilename) {
155
+ appendFetchStackTrace(err, currentFilename)
156
+ } else if (err && typeof err === 'object') {
157
+ Error.captureStackTrace(err, module.exports.fetch)
158
+ }
151
159
  throw err
152
160
  })
153
161
  }
@@ -67,6 +67,7 @@ module.exports = {
67
67
  kEnableConnectProtocol: Symbol('http2session connect protocol'),
68
68
  kRemoteSettings: Symbol('http2session remote settings'),
69
69
  kHTTP2Stream: Symbol('http2session client stream'),
70
+ kPingInterval: Symbol('ping interval'),
70
71
  kNoProxyAgent: Symbol('no proxy agent'),
71
72
  kHttpProxyAgent: Symbol('http proxy agent'),
72
73
  kHttpsProxyAgent: Symbol('https proxy agent')
@@ -92,7 +92,9 @@ class Agent extends DispatcherBase {
92
92
  if (connected) result.count -= 1
93
93
  if (result.count <= 0) {
94
94
  this[kClients].delete(key)
95
- result.dispatcher.close()
95
+ if (!result.dispatcher.destroyed) {
96
+ result.dispatcher.close()
97
+ }
96
98
  }
97
99
  this[kOrigins].delete(key)
98
100
  }
@@ -24,6 +24,7 @@ const {
24
24
  kStrictContentLength,
25
25
  kOnError,
26
26
  kMaxConcurrentStreams,
27
+ kPingInterval,
27
28
  kHTTP2Session,
28
29
  kHTTP2InitialWindowSize,
29
30
  kHTTP2ConnectionWindowSize,
@@ -34,7 +35,8 @@ const {
34
35
  kBodyTimeout,
35
36
  kEnableConnectProtocol,
36
37
  kRemoteSettings,
37
- kHTTP2Stream
38
+ kHTTP2Stream,
39
+ kHTTP2SessionState
38
40
  } = require('../core/symbols.js')
39
41
  const { channels } = require('../core/diagnostics.js')
40
42
 
@@ -102,10 +104,15 @@ function connectH2 (client, socket) {
102
104
  }
103
105
  })
104
106
 
107
+ client[kSocket] = socket
105
108
  session[kOpenStreams] = 0
106
109
  session[kClient] = client
107
110
  session[kSocket] = socket
108
- session[kHTTP2Session] = null
111
+ session[kHTTP2SessionState] = {
112
+ ping: {
113
+ interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref()
114
+ }
115
+ }
109
116
  // We set it to true by default in a best-effort; however once connected to an H2 server
110
117
  // we will check if extended CONNECT protocol is supported or not
111
118
  // and set this value accordingly.
@@ -253,6 +260,31 @@ function onHttp2RemoteSettings (settings) {
253
260
  this[kClient][kResume]()
254
261
  }
255
262
 
263
+ function onHttp2SendPing (session) {
264
+ const state = session[kHTTP2SessionState]
265
+ if ((session.closed || session.destroyed) && state.ping.interval != null) {
266
+ clearInterval(state.ping.interval)
267
+ state.ping.interval = null
268
+ return
269
+ }
270
+
271
+ // If no ping sent, do nothing
272
+ session.ping(onPing.bind(session))
273
+
274
+ function onPing (err, duration) {
275
+ const client = this[kClient]
276
+ const socket = this[kClient]
277
+
278
+ if (err != null) {
279
+ const error = new InformationalError(`HTTP/2: "PING" errored - type ${err.message}`)
280
+ socket[kError] = error
281
+ client[kOnError](error)
282
+ } else {
283
+ client.emit('ping', duration)
284
+ }
285
+ }
286
+ }
287
+
256
288
  function onHttp2SessionError (err) {
257
289
  assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID')
258
290
 
@@ -316,7 +348,7 @@ function onHttp2SessionGoAway (errorCode) {
316
348
  }
317
349
 
318
350
  function onHttp2SessionClose () {
319
- const { [kClient]: client } = this
351
+ const { [kClient]: client, [kHTTP2SessionState]: state } = this
320
352
  const { [kSocket]: socket } = client
321
353
 
322
354
  const err = this[kSocket][kError] || this[kError] || new SocketError('closed', util.getSocketInfo(socket))
@@ -324,6 +356,11 @@ function onHttp2SessionClose () {
324
356
  client[kSocket] = null
325
357
  client[kHTTPContext] = null
326
358
 
359
+ if (state.ping.interval != null) {
360
+ clearInterval(state.ping.interval)
361
+ state.ping.interval = null
362
+ }
363
+
327
364
  if (client.destroyed) {
328
365
  assert(client[kPending] === 0)
329
366
 
@@ -54,7 +54,8 @@ const {
54
54
  kMaxConcurrentStreams,
55
55
  kHTTP2InitialWindowSize,
56
56
  kHTTP2ConnectionWindowSize,
57
- kResume
57
+ kResume,
58
+ kPingInterval
58
59
  } = require('../core/symbols.js')
59
60
  const connectH1 = require('./client-h1.js')
60
61
  const connectH2 = require('./client-h2.js')
@@ -112,7 +113,8 @@ class Client extends DispatcherBase {
112
113
  allowH2,
113
114
  useH2c,
114
115
  initialWindowSize,
115
- connectionWindowSize
116
+ connectionWindowSize,
117
+ pingInterval
116
118
  } = {}) {
117
119
  if (keepAlive !== undefined) {
118
120
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -216,6 +218,10 @@ class Client extends DispatcherBase {
216
218
  throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0')
217
219
  }
218
220
 
221
+ if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) {
222
+ throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
223
+ }
224
+
219
225
  super()
220
226
 
221
227
  if (typeof connect !== 'function') {
@@ -250,6 +256,8 @@ class Client extends DispatcherBase {
250
256
  this[kMaxRequests] = maxRequestsPerClient
251
257
  this[kClosedResolve] = null
252
258
  this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1
259
+ this[kHTTPContext] = null
260
+ // h2
253
261
  this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server
254
262
  // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance:
255
263
  // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1)
@@ -259,7 +267,7 @@ class Client extends DispatcherBase {
259
267
  // Provides better flow control for the entire connection across multiple streams.
260
268
  this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144
261
269
  this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288
262
- this[kHTTPContext] = null
270
+ this[kPingInterval] = pingInterval != null ? pingInterval : 60e3 // Default ping interval for h2 - 1 minute
263
271
 
264
272
  // kQueue is built up of 3 sections separated by
265
273
  // the kRunningIdx and kPendingIdx indices.
@@ -95,16 +95,14 @@ class EnvHttpProxyAgent extends DispatcherBase {
95
95
  if (entry.port && entry.port !== port) {
96
96
  continue // Skip if ports don't match.
97
97
  }
98
- if (!/^[.*]/.test(entry.hostname)) {
99
- // No wildcards, so don't proxy only if there is not an exact match.
100
- if (hostname === entry.hostname) {
101
- return false
102
- }
103
- } else {
104
- // Don't proxy if the hostname ends with the no_proxy host.
105
- if (hostname.endsWith(entry.hostname.replace(/^\*/, ''))) {
106
- return false
107
- }
98
+ // Don't proxy if the hostname is equal with the no_proxy host.
99
+ if (hostname === entry.hostname) {
100
+ return false
101
+ }
102
+ // Don't proxy if the hostname is the subdomain of the no_proxy host.
103
+ // Reference - https://github.com/denoland/deno/blob/6fbce91e40cc07fc6da74068e5cc56fdd40f7b4c/ext/fetch/proxy.rs#L485
104
+ if (hostname.slice(-(entry.hostname.length + 1)) === `.${entry.hostname}`) {
105
+ return false
108
106
  }
109
107
  }
110
108
 
@@ -123,7 +121,8 @@ class EnvHttpProxyAgent extends DispatcherBase {
123
121
  }
124
122
  const parsed = entry.match(/^(.+):(\d+)$/)
125
123
  noProxyEntries.push({
126
- hostname: (parsed ? parsed[1] : entry).toLowerCase(),
124
+ // strip leading dot or asterisk with dot
125
+ hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, '').toLowerCase(),
127
126
  port: parsed ? Number.parseInt(parsed[2], 10) : 0
128
127
  })
129
128
  }
@@ -48,9 +48,12 @@ class PoolBase extends DispatcherBase {
48
48
  }
49
49
 
50
50
  if (this[kClosedResolve] && queue.isEmpty()) {
51
- const closeAll = new Array(this[kClients].length)
51
+ const closeAll = []
52
52
  for (let i = 0; i < this[kClients].length; i++) {
53
- closeAll[i] = this[kClients][i].close()
53
+ const client = this[kClients][i]
54
+ if (!client.destroyed) {
55
+ closeAll.push(client.close())
56
+ }
54
57
  }
55
58
  return Promise.all(closeAll)
56
59
  .then(this[kClosedResolve])
@@ -119,9 +122,12 @@ class PoolBase extends DispatcherBase {
119
122
 
120
123
  [kClose] () {
121
124
  if (this[kQueue].isEmpty()) {
122
- const closeAll = new Array(this[kClients].length)
125
+ const closeAll = []
123
126
  for (let i = 0; i < this[kClients].length; i++) {
124
- closeAll[i] = this[kClients][i].close()
127
+ const client = this[kClients][i]
128
+ if (!client.destroyed) {
129
+ closeAll.push(client.close())
130
+ }
125
131
  }
126
132
  return Promise.all(closeAll)
127
133
  } else {
@@ -193,57 +193,92 @@ class CacheHandler {
193
193
  // Not modified, re-use the cached value
194
194
  // https://www.rfc-editor.org/rfc/rfc9111.html#name-handling-304-not-modified
195
195
  if (statusCode === 304) {
196
- /**
197
- * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
198
- */
199
- const cachedValue = this.#store.get(this.#cacheKey)
200
- if (!cachedValue) {
201
- // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
202
- return downstreamOnHeaders()
203
- }
204
-
205
- // Re-use the cached value: statuscode, statusmessage, headers and body
206
- value.statusCode = cachedValue.statusCode
207
- value.statusMessage = cachedValue.statusMessage
208
- value.etag = cachedValue.etag
209
- value.headers = { ...cachedValue.headers, ...strippedHeaders }
196
+ const handle304 = (cachedValue) => {
197
+ if (!cachedValue) {
198
+ // Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
199
+ return downstreamOnHeaders()
200
+ }
210
201
 
211
- downstreamOnHeaders()
202
+ // Re-use the cached value: statuscode, statusmessage, headers and body
203
+ value.statusCode = cachedValue.statusCode
204
+ value.statusMessage = cachedValue.statusMessage
205
+ value.etag = cachedValue.etag
206
+ value.headers = { ...cachedValue.headers, ...strippedHeaders }
212
207
 
213
- this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
208
+ downstreamOnHeaders()
214
209
 
215
- if (!this.#writeStream || !cachedValue?.body) {
216
- return
217
- }
210
+ this.#writeStream = this.#store.createWriteStream(this.#cacheKey, value)
218
211
 
219
- const bodyIterator = cachedValue.body.values()
212
+ if (!this.#writeStream || !cachedValue?.body) {
213
+ return
214
+ }
220
215
 
221
- const streamCachedBody = () => {
222
- for (const chunk of bodyIterator) {
223
- const full = this.#writeStream.write(chunk) === false
224
- this.#handler.onResponseData?.(controller, chunk)
225
- // when stream is full stop writing until we get a 'drain' event
226
- if (full) {
227
- break
216
+ if (typeof cachedValue.body.values === 'function') {
217
+ const bodyIterator = cachedValue.body.values()
218
+
219
+ const streamCachedBody = () => {
220
+ for (const chunk of bodyIterator) {
221
+ const full = this.#writeStream.write(chunk) === false
222
+ this.#handler.onResponseData?.(controller, chunk)
223
+ // when stream is full stop writing until we get a 'drain' event
224
+ if (full) {
225
+ break
226
+ }
227
+ }
228
228
  }
229
- }
230
- }
231
229
 
232
- this.#writeStream
233
- .on('error', function () {
234
- handler.#writeStream = undefined
235
- handler.#store.delete(handler.#cacheKey)
236
- })
237
- .on('drain', () => {
230
+ this.#writeStream
231
+ .on('error', function () {
232
+ handler.#writeStream = undefined
233
+ handler.#store.delete(handler.#cacheKey)
234
+ })
235
+ .on('drain', () => {
236
+ streamCachedBody()
237
+ })
238
+ .on('close', function () {
239
+ if (handler.#writeStream === this) {
240
+ handler.#writeStream = undefined
241
+ }
242
+ })
243
+
238
244
  streamCachedBody()
239
- })
240
- .on('close', function () {
241
- if (handler.#writeStream === this) {
242
- handler.#writeStream = undefined
243
- }
244
- })
245
+ } else if (typeof cachedValue.body.on === 'function') {
246
+ // Readable stream body (e.g. from async/remote cache stores)
247
+ cachedValue.body
248
+ .on('data', (chunk) => {
249
+ this.#writeStream.write(chunk)
250
+ this.#handler.onResponseData?.(controller, chunk)
251
+ })
252
+ .on('end', () => {
253
+ this.#writeStream.end()
254
+ })
255
+ .on('error', () => {
256
+ this.#writeStream = undefined
257
+ this.#store.delete(this.#cacheKey)
258
+ })
259
+
260
+ this.#writeStream
261
+ .on('error', function () {
262
+ handler.#writeStream = undefined
263
+ handler.#store.delete(handler.#cacheKey)
264
+ })
265
+ .on('close', function () {
266
+ if (handler.#writeStream === this) {
267
+ handler.#writeStream = undefined
268
+ }
269
+ })
270
+ }
271
+ }
245
272
 
246
- streamCachedBody()
273
+ /**
274
+ * @type {import('../../types/cache-interceptor.d.ts').default.CacheValue}
275
+ */
276
+ const result = this.#store.get(this.#cacheKey)
277
+ if (result && typeof result.then === 'function') {
278
+ result.then(handle304)
279
+ } else {
280
+ handle304(result)
281
+ }
247
282
  } else {
248
283
  if (typeof resHeaders.etag === 'string' && isEtagUsable(resHeaders.etag)) {
249
284
  value.etag = resHeaders.etag
@@ -292,7 +292,7 @@ function handleResult (
292
292
 
293
293
  // Start background revalidation (fire-and-forget)
294
294
  queueMicrotask(() => {
295
- let headers = {
295
+ const headers = {
296
296
  ...opts.headers,
297
297
  'if-modified-since': new Date(result.cachedAt).toUTCString()
298
298
  }
@@ -302,9 +302,10 @@ function handleResult (
302
302
  }
303
303
 
304
304
  if (result.vary) {
305
- headers = {
306
- ...headers,
307
- ...result.vary
305
+ for (const key in result.vary) {
306
+ if (result.vary[key] != null) {
307
+ headers[key] = result.vary[key]
308
+ }
308
309
  }
309
310
  }
310
311
 
@@ -335,7 +336,7 @@ function handleResult (
335
336
  withinStaleIfErrorThreshold = now < (result.staleAt + (staleIfErrorExpiry * 1000))
336
337
  }
337
338
 
338
- let headers = {
339
+ const headers = {
339
340
  ...opts.headers,
340
341
  'if-modified-since': new Date(result.cachedAt).toUTCString()
341
342
  }
@@ -345,9 +346,10 @@ function handleResult (
345
346
  }
346
347
 
347
348
  if (result.vary) {
348
- headers = {
349
- ...headers,
350
- ...result.vary
349
+ for (const key in result.vary) {
350
+ if (result.vary[key] != null) {
351
+ headers[key] = result.vary[key]
352
+ }
351
353
  }
352
354
  }
353
355
 
@@ -46,8 +46,6 @@ module.exports = (opts = {}) => {
46
46
  // Convert to lowercase Set for case-insensitive header exclusion from deduplication key
47
47
  const excludeHeaderNamesSet = new Set(excludeHeaderNames.map(name => name.toLowerCase()))
48
48
 
49
- const safeMethodsToNotDeduplicate = util.safeHTTPMethods.filter(method => methods.includes(method) === false)
50
-
51
49
  /**
52
50
  * Map of pending requests for deduplication
53
51
  * @type {Map<string, DeduplicationHandler>}
@@ -56,7 +54,7 @@ module.exports = (opts = {}) => {
56
54
 
57
55
  return dispatch => {
58
56
  return (opts, handler) => {
59
- if (!opts.origin || safeMethodsToNotDeduplicate.includes(opts.method)) {
57
+ if (!opts.origin || methods.includes(opts.method) === false) {
60
58
  return dispatch(opts, handler)
61
59
  }
62
60
 
@@ -422,18 +422,14 @@ function consumeBody (object, convertBytesToJSValue, instance, getInternalState)
422
422
  return Promise.reject(e)
423
423
  }
424
424
 
425
- const state = getInternalState(object)
425
+ object = getInternalState(object)
426
426
 
427
427
  // 1. If object is unusable, then return a promise rejected
428
428
  // with a TypeError.
429
- if (bodyUnusable(state)) {
429
+ if (bodyUnusable(object)) {
430
430
  return Promise.reject(new TypeError('Body is unusable: Body has already been read'))
431
431
  }
432
432
 
433
- if (state.aborted) {
434
- return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'))
435
- }
436
-
437
433
  // 2. Let promise be a new promise.
438
434
  const promise = createDeferredPromise()
439
435
 
@@ -454,14 +450,14 @@ function consumeBody (object, convertBytesToJSValue, instance, getInternalState)
454
450
 
455
451
  // 5. If object’s body is null, then run successSteps with an
456
452
  // empty byte sequence.
457
- if (state.body == null) {
453
+ if (object.body == null) {
458
454
  successSteps(Buffer.allocUnsafe(0))
459
455
  return promise.promise
460
456
  }
461
457
 
462
458
  // 6. Otherwise, fully read object’s body given successSteps,
463
459
  // errorSteps, and object’s relevant global object.
464
- fullyReadBody(state.body, successSteps, errorSteps)
460
+ fullyReadBody(object.body, successSteps, errorSteps)
465
461
 
466
462
  // 7. Return promise.
467
463
  return promise.promise
@@ -157,7 +157,7 @@ function fetch (input, init = undefined) {
157
157
  if (requestObject.signal.aborted) {
158
158
  // 1. Abort the fetch() call with p, request, null, and
159
159
  // requestObject’s signal’s abort reason.
160
- abortFetch(p, request, null, requestObject.signal.reason)
160
+ abortFetch(p, request, null, requestObject.signal.reason, null)
161
161
 
162
162
  // 2. Return p.
163
163
  return p.promise
@@ -200,7 +200,7 @@ function fetch (input, init = undefined) {
200
200
 
201
201
  // 4. Abort the fetch() call with p, request, responseObject,
202
202
  // and requestObject’s signal’s abort reason.
203
- abortFetch(p, request, realResponse, requestObject.signal.reason)
203
+ abortFetch(p, request, realResponse, requestObject.signal.reason, controller.controller)
204
204
  }
205
205
  )
206
206
 
@@ -227,7 +227,7 @@ function fetch (input, init = undefined) {
227
227
  // 2. Abort the fetch() call with p, request, responseObject, and
228
228
  // deserializedError.
229
229
 
230
- abortFetch(p, request, responseObject, controller.serializedAbortReason)
230
+ abortFetch(p, request, responseObject, controller.serializedAbortReason, controller.controller)
231
231
  return
232
232
  }
233
233
 
@@ -327,7 +327,7 @@ function finalizeAndReportTiming (response, initiatorType = 'other') {
327
327
  const markResourceTiming = performance.markResourceTiming
328
328
 
329
329
  // https://fetch.spec.whatwg.org/#abort-fetch
330
- function abortFetch (p, request, responseObject, error) {
330
+ function abortFetch (p, request, responseObject, error, controller /* undici-specific */) {
331
331
  // 1. Reject promise with error.
332
332
  if (p) {
333
333
  // We might have already resolved the promise at this stage
@@ -357,13 +357,7 @@ function abortFetch (p, request, responseObject, error) {
357
357
  // 5. If response’s body is not null and is readable, then error response’s
358
358
  // body with error.
359
359
  if (response.body?.stream != null && isReadable(response.body.stream)) {
360
- response.body.stream.cancel(error).catch((err) => {
361
- if (err.code === 'ERR_INVALID_STATE') {
362
- // Node bug?
363
- return
364
- }
365
- throw err
366
- })
360
+ controller.error(error)
367
361
  }
368
362
  }
369
363
 
@@ -2308,6 +2302,41 @@ async function httpNetworkFetch (
2308
2302
  reject(error)
2309
2303
  },
2310
2304
 
2305
+ onRequestUpgrade (_controller, status, headers, socket) {
2306
+ // We need to support 200 for websocket over h2 as per RFC-8441
2307
+ // Absence of session means H1
2308
+ if ((socket.session != null && status !== 200) || (socket.session == null && status !== 101)) {
2309
+ return false
2310
+ }
2311
+
2312
+ const headersList = new HeadersList()
2313
+
2314
+ for (const [name, value] of Object.entries(headers)) {
2315
+ if (value == null) {
2316
+ continue
2317
+ }
2318
+
2319
+ const headerName = name.toLowerCase()
2320
+
2321
+ if (Array.isArray(value)) {
2322
+ for (const entry of value) {
2323
+ headersList.append(headerName, String(entry), true)
2324
+ }
2325
+ } else {
2326
+ headersList.append(headerName, String(value), true)
2327
+ }
2328
+ }
2329
+
2330
+ resolve({
2331
+ status,
2332
+ statusText: STATUS_CODES[status],
2333
+ headersList,
2334
+ socket
2335
+ })
2336
+
2337
+ return true
2338
+ },
2339
+
2311
2340
  onUpgrade (status, rawHeaders, socket) {
2312
2341
  // We need to support 200 for websocket over h2 as per RFC-8441
2313
2342
  // Absence of session means H1
@@ -242,7 +242,8 @@ class Response {
242
242
  const clonedResponse = cloneResponse(this.#state)
243
243
 
244
244
  // Note: To re-register because of a new stream.
245
- if (this.#state.body?.stream) {
245
+ // Don't set finalizers other than for fetch responses.
246
+ if (this.#state.urlList.length !== 0 && this.#state.body?.stream) {
246
247
  streamRegistry.register(this, new WeakRef(this.#state.body.stream))
247
248
  }
248
249
 
@@ -1439,7 +1439,7 @@ function hasAuthenticationEntry (request) {
1439
1439
  */
1440
1440
  function includesCredentials (url) {
1441
1441
  // A URL includes credentials if its username or password is not the empty string.
1442
- return !!(url.username && url.password)
1442
+ return !!(url.username || url.password)
1443
1443
  }
1444
1444
 
1445
1445
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.20.0",
3
+ "version": "7.22.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": {
@@ -71,29 +71,29 @@
71
71
  "test:javascript": "npm run test:javascript:no-jest && npm run test:jest",
72
72
  "test:javascript:no-jest": "npm run generate-pem && npm run test:unit && npm run test:fetch && npm run test:node-fetch && npm run test:infra && npm run test:cache && npm run test:cache-interceptor && npm run test:interceptors && npm run test:cookies && npm run test:eventsource && npm run test:subresource-integrity && npm run test:wpt && npm run test:websocket && npm run test:node-test && npm run test:cache-tests",
73
73
  "test:javascript:without-intl": "npm run test:javascript:no-jest",
74
- "test:busboy": "borp -p \"test/busboy/*.js\"",
75
- "test:cache": "borp -p \"test/cache/*.js\"",
76
- "test:cache-interceptor": "borp -p \"test/cache-interceptor/*.js\"",
74
+ "test:busboy": "borp --timeout 180000 -p \"test/busboy/*.js\"",
75
+ "test:cache": "borp --timeout 180000 -p \"test/cache/*.js\"",
76
+ "test:cache-interceptor": "borp --timeout 180000 -p \"test/cache-interceptor/*.js\"",
77
77
  "test:cache-interceptor:sqlite": "cross-env NODE_OPTIONS=--experimental-sqlite npm run test:cache-interceptor",
78
- "test:cookies": "borp -p \"test/cookie/*.js\"",
79
- "test:eventsource": "npm run build:node && borp --expose-gc -p \"test/eventsource/*.js\"",
78
+ "test:cookies": "borp --timeout 180000 -p \"test/cookie/*.js\"",
79
+ "test:eventsource": "npm run build:node && borp --timeout 180000 --expose-gc -p \"test/eventsource/*.js\"",
80
80
  "test:fuzzing": "node test/fuzzing/fuzzing.test.js",
81
81
  "test:fetch": "npm run build:node && borp --timeout 180000 --expose-gc --concurrency 1 -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy",
82
- "test:subresource-integrity": "borp -p \"test/subresource-integrity/*.js\"",
82
+ "test:subresource-integrity": "borp --timeout 180000 -p \"test/subresource-integrity/*.js\"",
83
83
  "test:h2": "npm run test:h2:core && npm run test:h2:fetch",
84
- "test:h2:core": "borp -p \"test/+(http2|h2)*.js\"",
85
- "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"",
86
- "test:infra": "borp -p \"test/infra/*.js\"",
87
- "test:interceptors": "borp -p \"test/interceptors/*.js\"",
84
+ "test:h2:core": "borp --timeout 180000 -p \"test/+(http2|h2)*.js\"",
85
+ "test:h2:fetch": "npm run build:node && borp --timeout 180000 -p \"test/fetch/http2*.js\"",
86
+ "test:infra": "borp --timeout 180000 -p \"test/infra/*.js\"",
87
+ "test:interceptors": "borp --timeout 180000 -p \"test/interceptors/*.js\"",
88
88
  "test:jest": "cross-env NODE_V8_COVERAGE= jest",
89
- "test:unit": "borp --expose-gc -p \"test/*.js\"",
90
- "test:node-fetch": "borp -p \"test/node-fetch/**/*.js\"",
91
- "test:node-test": "borp -p \"test/node-test/**/*.js\"",
92
- "test:tdd": "borp --expose-gc -p \"test/*.js\"",
93
- "test:tdd:node-test": "borp -p \"test/node-test/**/*.js\" -w",
89
+ "test:unit": "borp --timeout 180000 --expose-gc -p \"test/*.js\"",
90
+ "test:node-fetch": "borp --timeout 180000 -p \"test/node-fetch/**/*.js\"",
91
+ "test:node-test": "borp --timeout 180000 -p \"test/node-test/**/*.js\"",
92
+ "test:tdd": "borp --timeout 180000 --expose-gc -p \"test/*.js\"",
93
+ "test:tdd:node-test": "borp --timeout 180000 -p \"test/node-test/**/*.js\" -w",
94
94
  "test:typescript": "tsd && tsc test/imports/undici-import.ts --typeRoots ./types --noEmit && tsc ./types/*.d.ts --noEmit --typeRoots ./types",
95
- "test:webidl": "borp -p \"test/webidl/*.js\"",
96
- "test:websocket": "borp -p \"test/websocket/**/*.js\"",
95
+ "test:webidl": "borp --timeout 180000 -p \"test/webidl/*.js\"",
96
+ "test:websocket": "borp --timeout 180000 -p \"test/websocket/**/*.js\"",
97
97
  "test:websocket:autobahn": "node test/autobahn/client.js",
98
98
  "test:websocket:autobahn:report": "node test/autobahn/report.js",
99
99
  "test:wpt:setup": "node test/web-platform-tests/wpt-runner.mjs setup",
@@ -119,7 +119,7 @@
119
119
  "c8": "^10.0.0",
120
120
  "cross-env": "^10.0.0",
121
121
  "dns-packet": "^5.4.0",
122
- "esbuild": "^0.25.2",
122
+ "esbuild": "^0.27.3",
123
123
  "eslint": "^9.9.0",
124
124
  "fast-check": "^4.1.1",
125
125
  "husky": "^9.0.7",
package/types/client.d.ts CHANGED
@@ -102,6 +102,11 @@ export declare namespace Client {
102
102
  * @default 524288
103
103
  */
104
104
  connectionWindowSize?: number;
105
+ /**
106
+ * @description Time interval between PING frames dispatch
107
+ * @default 60000
108
+ */
109
+ pingInterval?: number;
105
110
  }
106
111
  export interface SocketInfo {
107
112
  localAddress?: string
@@ -169,6 +169,8 @@ interface WebSocketStream {
169
169
  writable: WritableStream
170
170
  }>
171
171
  url: string
172
+
173
+ close(options?: Partial<WebSocketCloseInfo>): void
172
174
  }
173
175
 
174
176
  export declare const WebSocketStream: {