undici 7.19.2 → 7.21.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.
@@ -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.
@@ -476,6 +476,7 @@ The `RequestOptions.method` property should not be value `'CONNECT'`.
476
476
  #### Parameter: `ResponseData`
477
477
 
478
478
  * **statusCode** `number`
479
+ * **statusText** `string` - The status message from the response (e.g., "OK", "Not Found").
479
480
  * **headers** `Record<string, string | string[]>` - Note that all header keys are lower-cased, e.g. `content-type`.
480
481
  * **body** `stream.Readable` which also implements [the body mixin from the Fetch Standard](https://fetch.spec.whatwg.org/#body-mixin).
481
482
  * **trailers** `Record<string, string>` - This object starts out
@@ -517,7 +518,7 @@ await once(server, 'listening')
517
518
  const client = new Client(`http://localhost:${server.address().port}`)
518
519
 
519
520
  try {
520
- const { body, headers, statusCode, trailers } = await client.request({
521
+ const { body, headers, statusCode, statusText, trailers } = await client.request({
521
522
  path: '/',
522
523
  method: 'GET'
523
524
  })
@@ -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.
@@ -323,7 +323,8 @@ try {
323
323
  method: 'GET'
324
324
  })
325
325
  } catch (error) {
326
- console.error(error) // Error: kaboom
326
+ console.error(error) // TypeError: fetch failed
327
+ console.error(error.cause) // Error: kaboom
327
328
  }
328
329
  ```
329
330
 
package/index-fetch.js CHANGED
@@ -4,10 +4,40 @@ 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
+
11
+ function appendFetchStackTrace (err, filename) {
12
+ if (!err || typeof err !== 'object') {
13
+ return
14
+ }
15
+
16
+ const stack = typeof err.stack === 'string' ? err.stack : ''
17
+ const normalizedFilename = filename.replace(/\\/g, '/')
18
+
19
+ if (stack && (stack.includes(filename) || stack.includes(normalizedFilename))) {
20
+ return
21
+ }
22
+
23
+ const capture = {}
24
+ Error.captureStackTrace(capture, appendFetchStackTrace)
25
+
26
+ if (!capture.stack) {
27
+ return
28
+ }
29
+
30
+ const captureLines = capture.stack.split('\n').slice(1).join('\n')
31
+
32
+ err.stack = stack ? `${stack}\n${captureLines}` : capture.stack
33
+ }
34
+
7
35
  module.exports.fetch = function fetch (init, options = undefined) {
8
36
  return fetchImpl(init, options).catch(err => {
9
- if (err && typeof err === 'object') {
10
- Error.captureStackTrace(err)
37
+ if (currentFilename) {
38
+ appendFetchStackTrace(err, currentFilename)
39
+ } else if (err && typeof err === 'object') {
40
+ Error.captureStackTrace(err, module.exports.fetch)
11
41
  }
12
42
  throw err
13
43
  })
package/index.js CHANGED
@@ -121,10 +121,40 @@ 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
+
128
+ function appendFetchStackTrace (err, filename) {
129
+ if (!err || typeof err !== 'object') {
130
+ return
131
+ }
132
+
133
+ const stack = typeof err.stack === 'string' ? err.stack : ''
134
+ const normalizedFilename = filename.replace(/\\/g, '/')
135
+
136
+ if (stack && (stack.includes(filename) || stack.includes(normalizedFilename))) {
137
+ return
138
+ }
139
+
140
+ const capture = {}
141
+ Error.captureStackTrace(capture, appendFetchStackTrace)
142
+
143
+ if (!capture.stack) {
144
+ return
145
+ }
146
+
147
+ const captureLines = capture.stack.split('\n').slice(1).join('\n')
148
+
149
+ err.stack = stack ? `${stack}\n${captureLines}` : capture.stack
150
+ }
151
+
124
152
  module.exports.fetch = function fetch (init, options = undefined) {
125
153
  return fetchImpl(init, options).catch(err => {
126
- if (err && typeof err === 'object') {
127
- Error.captureStackTrace(err)
154
+ if (currentFilename) {
155
+ appendFetchStackTrace(err, currentFilename)
156
+ } else if (err && typeof err === 'object') {
157
+ Error.captureStackTrace(err, module.exports.fetch)
128
158
  }
129
159
  throw err
130
160
  })
@@ -121,6 +121,7 @@ class RequestHandler extends AsyncResource {
121
121
  try {
122
122
  this.runInAsyncScope(callback, null, null, {
123
123
  statusCode,
124
+ statusText: statusMessage,
124
125
  headers,
125
126
  trailers: this.trailers,
126
127
  opaque,
@@ -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
  }
@@ -735,8 +735,13 @@ class Parser {
735
735
  }
736
736
  }
737
737
 
738
- function onParserTimeout (parser) {
739
- const { socket, timeoutType, client, paused } = parser.deref()
738
+ function onParserTimeout (parserWeakRef) {
739
+ const parser = parserWeakRef.deref()
740
+ if (!parser) {
741
+ return
742
+ }
743
+
744
+ const { socket, timeoutType, client, paused } = parser
740
745
 
741
746
  if (timeoutType === TIMEOUT_HEADERS) {
742
747
  if (!socket[kWriting] || socket.writableNeedDrain || client[kRunning] > 1) {
@@ -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.
@@ -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 {
@@ -312,9 +312,33 @@ function mockDispatch (opts, handler) {
312
312
  return true
313
313
  }
314
314
 
315
+ // Track whether the request has been aborted
316
+ let aborted = false
317
+ let timer = null
318
+
319
+ function abort (err) {
320
+ if (aborted) {
321
+ return
322
+ }
323
+ aborted = true
324
+
325
+ // Clear the pending delayed response if any
326
+ if (timer !== null) {
327
+ clearTimeout(timer)
328
+ timer = null
329
+ }
330
+
331
+ // Notify the handler of the abort
332
+ handler.onError(err)
333
+ }
334
+
335
+ // Call onConnect to allow the handler to register the abort callback
336
+ handler.onConnect?.(abort, null)
337
+
315
338
  // Handle the request with a delay if necessary
316
339
  if (typeof delay === 'number' && delay > 0) {
317
- setTimeout(() => {
340
+ timer = setTimeout(() => {
341
+ timer = null
318
342
  handleReply(this[kDispatches])
319
343
  }, delay)
320
344
  } else {
@@ -322,6 +346,11 @@ function mockDispatch (opts, handler) {
322
346
  }
323
347
 
324
348
  function handleReply (mockDispatches, _data = data) {
349
+ // Don't send response if the request was aborted
350
+ if (aborted) {
351
+ return
352
+ }
353
+
325
354
  // fetch's HeadersList is a 1D string array
326
355
  const optsHeaders = Array.isArray(opts.headers)
327
356
  ? buildHeadersFromArray(opts.headers)
@@ -340,11 +369,15 @@ function mockDispatch (opts, handler) {
340
369
  return body.then((newData) => handleReply(mockDispatches, newData))
341
370
  }
342
371
 
372
+ // Check again if aborted after async body resolution
373
+ if (aborted) {
374
+ return
375
+ }
376
+
343
377
  const responseData = getResponseData(body)
344
378
  const responseHeaders = generateKeyValues(headers)
345
379
  const responseTrailers = generateKeyValues(trailers)
346
380
 
347
- handler.onConnect?.(err => handler.onError(err), null)
348
381
  handler.onHeaders?.(statusCode, responseHeaders, resume, getStatusText(statusCode))
349
382
  handler.onData?.(Buffer.from(responseData))
350
383
  handler.onComplete?.(responseTrailers)
@@ -11,7 +11,7 @@ const { FormData, setFormDataState } = require('./formdata')
11
11
  const { webidl } = require('../webidl')
12
12
  const assert = require('node:assert')
13
13
  const { isErrored, isDisturbed } = require('node:stream')
14
- const { isArrayBuffer } = require('node:util/types')
14
+ const { isUint8Array } = require('node:util/types')
15
15
  const { serializeAMimeType } = require('./data-url')
16
16
  const { multipartFormDataParser } = require('./formdata-parser')
17
17
  const { createDeferredPromise } = require('../../util/promise')
@@ -45,6 +45,7 @@ const streamRegistry = new FinalizationRegistry((weakRef) => {
45
45
  function extractBody (object, keepalive = false) {
46
46
  // 1. Let stream be null.
47
47
  let stream = null
48
+ let controller = null
48
49
 
49
50
  // 2. If object is a ReadableStream object, then set stream to object.
50
51
  if (webidl.is.ReadableStream(object)) {
@@ -57,16 +58,11 @@ function extractBody (object, keepalive = false) {
57
58
  // 4. Otherwise, set stream to a new ReadableStream object, and set
58
59
  // up stream with byte reading support.
59
60
  stream = new ReadableStream({
60
- pull (controller) {
61
- const buffer = typeof source === 'string' ? textEncoder.encode(source) : source
62
-
63
- if (buffer.byteLength) {
64
- controller.enqueue(buffer)
65
- }
66
-
67
- queueMicrotask(() => readableStreamClose(controller))
61
+ pull () {},
62
+ start (c) {
63
+ controller = c
68
64
  },
69
- start () {},
65
+ cancel () {},
70
66
  type: 'bytes'
71
67
  })
72
68
  }
@@ -108,9 +104,8 @@ function extractBody (object, keepalive = false) {
108
104
  // Set type to `application/x-www-form-urlencoded;charset=UTF-8`.
109
105
  type = 'application/x-www-form-urlencoded;charset=UTF-8'
110
106
  } else if (webidl.is.BufferSource(object)) {
111
- source = isArrayBuffer(object)
112
- ? new Uint8Array(object.slice())
113
- : new Uint8Array(object.buffer.slice(object.byteOffset, object.byteOffset + object.byteLength))
107
+ // Set source to a copy of the bytes held by object.
108
+ source = webidl.util.getCopyOfBytesHeldByBufferSource(object)
114
109
  } else if (webidl.is.FormData(object)) {
115
110
  const boundary = `----formdata-undici-0${`${random(1e11)}`.padStart(11, '0')}`
116
111
  const prefix = `--${boundary}\r\nContent-Disposition: form-data`
@@ -213,45 +208,36 @@ function extractBody (object, keepalive = false) {
213
208
 
214
209
  // 11. If source is a byte sequence, then set action to a
215
210
  // step that returns source and length to source’s length.
216
- if (typeof source === 'string' || util.isBuffer(source)) {
217
- length = Buffer.byteLength(source)
211
+ if (typeof source === 'string' || isUint8Array(source)) {
212
+ action = () => {
213
+ length = typeof source === 'string' ? Buffer.byteLength(source) : source.length
214
+ return source
215
+ }
218
216
  }
219
217
 
220
- // 12. If action is non-null, then run these steps in in parallel:
218
+ // 12. If action is non-null, then run these steps in parallel:
221
219
  if (action != null) {
222
- // Run action.
223
- let iterator
224
- stream = new ReadableStream({
225
- start () {
226
- iterator = action(object)[Symbol.asyncIterator]()
227
- },
228
- pull (controller) {
229
- return iterator.next().then(({ value, done }) => {
230
- if (done) {
231
- // When running action is done, close stream.
232
- queueMicrotask(() => {
233
- controller.close()
234
- controller.byobRequest?.respond(0)
235
- })
236
- } else {
237
- // Whenever one or more bytes are available and stream is not errored,
238
- // enqueue a Uint8Array wrapping an ArrayBuffer containing the available
239
- // bytes into stream.
240
- if (!isErrored(stream)) {
241
- const buffer = new Uint8Array(value)
242
- if (buffer.byteLength) {
243
- controller.enqueue(buffer)
244
- }
245
- }
220
+ ;(async () => {
221
+ // 1. Run action.
222
+ const result = action()
223
+
224
+ // 2. Whenever one or more bytes are available and stream is not errored,
225
+ // enqueue the result of creating a Uint8Array from the available bytes into stream.
226
+ const iterator = result?.[Symbol.asyncIterator]?.()
227
+ if (iterator) {
228
+ for await (const bytes of iterator) {
229
+ if (isErrored(stream)) break
230
+ if (bytes.length) {
231
+ controller.enqueue(new Uint8Array(bytes))
246
232
  }
247
- return controller.desiredSize > 0
248
- })
249
- },
250
- cancel (reason) {
251
- return iterator.return()
252
- },
253
- type: 'bytes'
254
- })
233
+ }
234
+ } else if (result?.length && !isErrored(stream)) {
235
+ controller.enqueue(typeof result === 'string' ? textEncoder.encode(result) : new Uint8Array(result))
236
+ }
237
+
238
+ // 3. When running action is done, close stream.
239
+ queueMicrotask(() => readableStreamClose(controller))
240
+ })()
255
241
  }
256
242
 
257
243
  // 13. Let body be a body whose stream is stream, source is source,
@@ -436,18 +422,14 @@ function consumeBody (object, convertBytesToJSValue, instance, getInternalState)
436
422
  return Promise.reject(e)
437
423
  }
438
424
 
439
- const state = getInternalState(object)
425
+ object = getInternalState(object)
440
426
 
441
427
  // 1. If object is unusable, then return a promise rejected
442
428
  // with a TypeError.
443
- if (bodyUnusable(state)) {
429
+ if (bodyUnusable(object)) {
444
430
  return Promise.reject(new TypeError('Body is unusable: Body has already been read'))
445
431
  }
446
432
 
447
- if (state.aborted) {
448
- return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'))
449
- }
450
-
451
433
  // 2. Let promise be a new promise.
452
434
  const promise = createDeferredPromise()
453
435
 
@@ -468,14 +450,14 @@ function consumeBody (object, convertBytesToJSValue, instance, getInternalState)
468
450
 
469
451
  // 5. If object’s body is null, then run successSteps with an
470
452
  // empty byte sequence.
471
- if (state.body == null) {
453
+ if (object.body == null) {
472
454
  successSteps(Buffer.allocUnsafe(0))
473
455
  return promise.promise
474
456
  }
475
457
 
476
458
  // 6. Otherwise, fully read object’s body given successSteps,
477
459
  // errorSteps, and object’s relevant global object.
478
- fullyReadBody(state.body, successSteps, errorSteps)
460
+ fullyReadBody(object.body, successSteps, errorSteps)
479
461
 
480
462
  // 7. Return promise.
481
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
 
@@ -1321,8 +1315,8 @@ function httpRedirectFetch (fetchParams, response) {
1321
1315
  request.headersList.delete('host', true)
1322
1316
  }
1323
1317
 
1324
- // 14. If requests body is non-null, then set requests body to the first return
1325
- // value of safely extracting requests bodys source.
1318
+ // 14. If request's body is non-null, then set request's body to the first return
1319
+ // value of safely extracting request's body's source.
1326
1320
  if (request.body != null) {
1327
1321
  assert(request.body.source != null)
1328
1322
  request.body = safelyExtractBody(request.body.source)[0]
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const assert = require('node:assert')
3
4
  const { types, inspect } = require('node:util')
4
5
  const { runtimeFeatures } = require('../../util/runtime-features')
5
6
 
@@ -542,6 +543,57 @@ webidl.is.BufferSource = function (V) {
542
543
  )
543
544
  }
544
545
 
546
+ // https://webidl.spec.whatwg.org/#dfn-get-buffer-source-copy
547
+ webidl.util.getCopyOfBytesHeldByBufferSource = function (bufferSource) {
548
+ // 1. Let jsBufferSource be the result of converting bufferSource to a JavaScript value.
549
+ const jsBufferSource = bufferSource
550
+
551
+ // 2. Let jsArrayBuffer be jsBufferSource.
552
+ let jsArrayBuffer = jsBufferSource
553
+
554
+ // 3. Let offset be 0.
555
+ let offset = 0
556
+
557
+ // 4. Let length be 0.
558
+ let length = 0
559
+
560
+ // 5. If jsBufferSource has a [[ViewedArrayBuffer]] internal slot, then:
561
+ if (types.isTypedArray(jsBufferSource) || types.isDataView(jsBufferSource)) {
562
+ // 5.1. Set jsArrayBuffer to jsBufferSource.[[ViewedArrayBuffer]].
563
+ jsArrayBuffer = jsBufferSource.buffer
564
+
565
+ // 5.2. Set offset to jsBufferSource.[[ByteOffset]].
566
+ offset = jsBufferSource.byteOffset
567
+
568
+ // 5.3. Set length to jsBufferSource.[[ByteLength]].
569
+ length = jsBufferSource.byteLength
570
+ } else {
571
+ // 6. Otherwise:
572
+
573
+ // 6.1. Assert: jsBufferSource is an ArrayBuffer or SharedArrayBuffer object.
574
+ assert(types.isAnyArrayBuffer(jsBufferSource))
575
+
576
+ // 6.2. Set length to jsBufferSource.[[ArrayBufferByteLength]].
577
+ length = jsBufferSource.byteLength
578
+ }
579
+
580
+ // 7. If IsDetachedBuffer(jsArrayBuffer) is true, then return the empty byte sequence.
581
+ if (jsArrayBuffer.detached) {
582
+ return new Uint8Array(0)
583
+ }
584
+
585
+ // 8. Let bytes be a new byte sequence of length equal to length.
586
+ const bytes = new Uint8Array(length)
587
+
588
+ // 9. For i in the range offset to offset + length − 1, inclusive,
589
+ // set bytes[i − offset] to GetValueFromBuffer(jsArrayBuffer, i, Uint8, true, Unordered).
590
+ const view = new Uint8Array(jsArrayBuffer, offset, length)
591
+ bytes.set(view)
592
+
593
+ // 10. Return bytes.
594
+ return bytes
595
+ }
596
+
545
597
  // https://webidl.spec.whatwg.org/#es-DOMString
546
598
  webidl.converters.DOMString = function (V, prefix, argument, flags) {
547
599
  // 1. If V is null and the conversion is to an IDL type
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "7.19.2",
3
+ "version": "7.21.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",
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
@@ -181,6 +181,7 @@ declare namespace Dispatcher {
181
181
  }
182
182
  export interface ResponseData<TOpaque = null> {
183
183
  statusCode: number;
184
+ statusText: string;
184
185
  headers: IncomingHttpHeaders;
185
186
  body: BodyReadable & BodyMixin;
186
187
  trailers: Record<string, string>;
package/types/webidl.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // These types are not exported, and are only used internally
2
+ import { BufferSource } from 'node:stream/web'
2
3
  import * as undici from './index'
3
4
 
4
5
  /**
@@ -93,6 +94,11 @@ interface WebidlUtil {
93
94
  IsResizableArrayBuffer (V: ArrayBufferLike): boolean
94
95
 
95
96
  HasFlag (flag: number, attributes: number): boolean
97
+
98
+ /**
99
+ * @see https://webidl.spec.whatwg.org/#dfn-get-buffer-source-copy
100
+ */
101
+ getCopyOfBytesHeldByBufferSource (bufferSource: BufferSource): Uint8Array
96
102
  }
97
103
 
98
104
  interface WebidlConverters {
@@ -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: {