undici 8.9.0 → 8.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.
@@ -111,22 +111,35 @@ added: v1.0.0
111
111
  `autoSelectFamily` is enabled. **Default:** `250`.
112
112
  * `allowH2` {boolean} Enables HTTP/2 support when the server assigns it a
113
113
  higher priority through ALPN negotiation. **Default:** `true`.
114
- * `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS
115
- connections. **Default:** `false`.
116
- * `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2
114
+ * `useH2c` {boolean} _Deprecated: use h2Options.useH2c instead_ Enforces h2c (HTTP/2 cleartext) for non-HTTPS
115
+ connections. **Default:** `false`.
116
+ * `maxConcurrentStreams` {number} _Deprecated: use h2Options.useH2c instead_ The maximum number of concurrent HTTP/2
117
117
  streams for a single session. Once h2 is negotiated this — not `pipelining`,
118
118
  which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests.
119
119
  It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS`
120
120
  frame. **Default:** `100`.
121
- * `initialWindowSize` {number} The HTTP/2 stream-level flow-control window
122
- size (`SETTINGS_INITIAL_WINDOW_SIZE`). Must be a positive integer.
123
- **Default:** `262144`.
124
- * `connectionWindowSize` {number} The HTTP/2 connection-level flow-control
121
+ * `connectionWindowSize` {number} _Deprecated: use h2Options.connectionWindowSize instead_ The HTTP/2 connection-level flow-control
125
122
  window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a
126
123
  positive integer. **Default:** `524288`.
127
- * `pingInterval` {number} The time interval, in milliseconds, between HTTP/2
124
+ * `pingInterval` {number} _Deprecated: use h2Options.pingInterval instead_ The time interval, in milliseconds, between HTTP/2
128
125
  PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2
129
126
  connections and emits a `ping` event on the client. **Default:** `60e3`.
127
+ * `h2Options` {object} Set of options for HTTP/2 sessions
128
+ * `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS
129
+ connections. **Default:** `false`.
130
+ * `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2
131
+ streams for a single session. Once h2 is negotiated this — not `pipelining`,
132
+ which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests.
133
+ It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS`
134
+ frame. **Default:** `100`.
135
+ * `connectionWindowSize` {number} The HTTP/2 connection-level flow-control
136
+ window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a
137
+ positive integer. **Default:** `524288`.
138
+ * `pingInterval` {number} The time interval, in milliseconds, between HTTP/2
139
+ PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2
140
+ connections and emits a `ping` event on the client. **Default:** `60e3`.
141
+ * `settings` {object} `SETTINGS` frame options. For full reference, take a
142
+ look to [HTTP/2#Settings Object](https://nodejs.org/api/http2.html#settings-object)
130
143
  * `webSocket` {Object} (optional) WebSocket-specific configuration.
131
144
  * `maxFragments` {number} The maximum number of fragments in a message. Set
132
145
  to `0` to disable the limit. **Default:** `131072`.
@@ -15,7 +15,6 @@ const kContentType = Symbol('kContentType')
15
15
  const kContentLength = Symbol('kContentLength')
16
16
  const kUsed = Symbol('kUsed')
17
17
  const kBytesRead = Symbol('kBytesRead')
18
- const kPreservedBuffer = Symbol('kPreservedBuffer')
19
18
 
20
19
  const noop = () => {}
21
20
 
@@ -326,36 +325,14 @@ class BodyReadable extends Readable {
326
325
  */
327
326
  setEncoding (encoding) {
328
327
  if (Buffer.isEncoding(encoding)) {
329
- // Preserve raw Buffer chunks for the consume path (body.text(),
330
- // body.json(), etc.) before super.setEncoding() replaces them
331
- // with decoded strings. Without this, the consume path would
332
- // lose access to the original bytes — some of which may be held
333
- // by the decoder for incomplete multi-byte sequences, and the
334
- // rest converted to strings that can't be safely concatenated
335
- // byte-wise.
336
- const state = this._readableState
337
- const buffer = state.buffer
338
- if (buffer && state.length > 0) {
339
- const bufferIndex = state.bufferIndex ?? 0
340
- const preserved = []
341
- const source = typeof buffer.slice === 'function'
342
- ? buffer.slice(bufferIndex)
343
- : buffer
344
- for (const data of source) {
345
- if (Buffer.isBuffer(data)) {
346
- preserved.push(data)
347
- }
348
- }
349
- if (preserved.length > 0) {
350
- this[kPreservedBuffer] = (this[kPreservedBuffer] || []).concat(preserved)
351
- }
352
- }
353
-
354
328
  // Delegate to Node.js Readable.setEncoding() which initializes a
355
329
  // StringDecoder and re-encodes already-buffered chunks. This properly
356
330
  // handles multi-byte sequences split at chunk boundaries for the
357
331
  // for-await / on('data') paths. Without this, Node.js uses
358
332
  // buf.toString(encoding) on each chunk, producing U+FFFD for split chars.
333
+ //
334
+ // The consume path (body.text(), body.json(), ...) copes with the
335
+ // decoded strings this leaves in state.buffer, see consumeStart().
359
336
  super.setEncoding(encoding)
360
337
  }
361
338
  return this
@@ -464,17 +441,7 @@ function consumeStart (consume) {
464
441
 
465
442
  const { _readableState: state } = consume.stream
466
443
 
467
- // If setEncoding() was called, state.buffer may contain decoded strings
468
- // (which would break Buffer.concat in chunksDecode). Use the preserved
469
- // raw Buffers (saved before super.setEncoding() in setEncoding()) for
470
- // byte-level accurate consumption. Otherwise read from state.buffer.
471
- const preserved = consume.stream[kPreservedBuffer]
472
- if (preserved && preserved.length > 0) {
473
- for (const chunk of preserved) {
474
- consumePush(consume, chunk)
475
- }
476
- consume.stream[kPreservedBuffer] = null
477
- } else if (state.bufferIndex) {
444
+ if (state.bufferIndex) {
478
445
  const start = state.bufferIndex
479
446
  const end = state.buffer.length
480
447
  for (let n = start; n < end; n++) {
@@ -486,14 +453,29 @@ function consumeStart (consume) {
486
453
  }
487
454
  }
488
455
 
456
+ // If setEncoding() was called, state.buffer holds decoded strings, which
457
+ // consumePush() turns back into bytes. The trailing bytes of a multi-byte
458
+ // sequence split across a chunk boundary are not part of any of those
459
+ // strings, they are held inside the decoder until the rest arrives, so
460
+ // take them from there.
461
+ const decoder = state.decoder
462
+ if (decoder != null && decoder.lastNeed > 0) {
463
+ consumePush(consume, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed)))
464
+ }
465
+
489
466
  if (state.endEmitted) {
490
- consumeEnd(this[kConsume], this._readableState.encoding)
491
- } else {
492
- consume.stream.on('end', function () {
493
- consumeEnd(this[kConsume], this._readableState.encoding)
494
- })
467
+ // No `this` to read the consume off here: consumeStart is a free function, called from
468
+ // the queueMicrotask above. The callback below does have one, because the emitter passes
469
+ // the stream as its receiver. Returning matters too - consumeEnd() clears consume.stream,
470
+ // which the resume() below would then dereference.
471
+ consumeEnd(consume, state.encoding)
472
+ return
495
473
  }
496
474
 
475
+ consume.stream.on('end', function () {
476
+ consumeEnd(this[kConsume], this._readableState.encoding)
477
+ })
478
+
497
479
  consume.stream.resume()
498
480
 
499
481
  while (consume.stream.read() != null) {
@@ -583,7 +565,7 @@ function consumeEnd (consume, encoding) {
583
565
 
584
566
  /**
585
567
  * @param {Consume} consume
586
- * @param {Buffer} chunk
568
+ * @param {Buffer|string} chunk
587
569
  * @returns {void}
588
570
  */
589
571
  function consumePush (consume, chunk) {
@@ -591,6 +573,14 @@ function consumePush (consume, chunk) {
591
573
  return
592
574
  }
593
575
 
576
+ if (typeof chunk === 'string') {
577
+ // Buffered before the consume started, while an encoding was set.
578
+ // consume.length has to stay a byte count and chunksDecode()/chunksConcat()
579
+ // only work on bytes, so re-encode. A string's own length is in UTF-16 code
580
+ // units and Uint8Array.prototype.set() ignores a string argument entirely.
581
+ chunk = Buffer.from(chunk, consume.stream._readableState.encoding)
582
+ }
583
+
594
584
  consume.length += chunk.length
595
585
  consume.body.push(chunk)
596
586
  }
@@ -105,13 +105,27 @@ function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketP
105
105
 
106
106
  port = port || 80
107
107
 
108
- socket = net.connect({
108
+ const connectOptions = {
109
109
  highWaterMark: 64 * 1024, // Same as nodejs fs streams.
110
110
  ...options,
111
111
  localAddress,
112
112
  port,
113
113
  host: hostname
114
- })
114
+ }
115
+
116
+ const family = net.isIP(hostname)
117
+ if (family !== 0 && servername && servername !== hostname) {
118
+ connectOptions.host = servername
119
+ connectOptions.lookup = (_hostname, lookupOptions, cb) => {
120
+ if (lookupOptions.all) {
121
+ cb(null, [{ address: hostname, family }])
122
+ } else {
123
+ cb(null, hostname, family)
124
+ }
125
+ }
126
+ }
127
+
128
+ socket = net.connect(connectOptions)
115
129
  if (useH2c === true) {
116
130
  socket.alpnProtocol = 'h2'
117
131
  }
@@ -56,6 +56,7 @@ module.exports = {
56
56
  kCounter: Symbol('socket request counter'),
57
57
  kMaxResponseSize: Symbol('max response size'),
58
58
  kHTTP2Session: Symbol('http2Session'),
59
+ kHTTP2Options: Symbol('http2 options'),
59
60
  kHTTP2SessionState: Symbol('http2Session state'),
60
61
  kRetryHandlerDefaultRetry: Symbol('retry agent default retry'),
61
62
  kConstruct: Symbol('constructable'),
@@ -1052,7 +1052,7 @@ function onSocketClose () {
1052
1052
 
1053
1053
  function clearIdleSocketValidation (socket) {
1054
1054
  if (socket[kIdleSocketValidationTimeout]) {
1055
- clearImmediate(socket[kIdleSocketValidationTimeout])
1055
+ clearTimeout(socket[kIdleSocketValidationTimeout])
1056
1056
  socket[kIdleSocketValidationTimeout] = null
1057
1057
  }
1058
1058
 
@@ -1061,14 +1061,14 @@ function clearIdleSocketValidation (socket) {
1061
1061
 
1062
1062
  function scheduleIdleSocketValidation (client, socket) {
1063
1063
  socket[kIdleSocketValidation] = 1
1064
- socket[kIdleSocketValidationTimeout] = setImmediate(() => {
1064
+ socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1065
1065
  socket[kIdleSocketValidationTimeout] = null
1066
1066
  socket[kIdleSocketValidation] = 2
1067
1067
 
1068
1068
  if (client[kSocket] === socket && !socket.destroyed) {
1069
1069
  client[kResume]()
1070
1070
  }
1071
- })
1071
+ }, 0)
1072
1072
  socket[kIdleSocketValidationTimeout].unref?.()
1073
1073
  }
1074
1074
 
@@ -26,10 +26,7 @@ const {
26
26
  kStrictContentLength,
27
27
  kOnError,
28
28
  kMaxConcurrentStreams,
29
- kPingInterval,
30
29
  kHTTP2Session,
31
- kHTTP2InitialWindowSize,
32
- kHTTP2ConnectionWindowSize,
33
30
  kHostAuthority,
34
31
  kResume,
35
32
  kSize,
@@ -41,7 +38,8 @@ const {
41
38
  kEnableConnectProtocol,
42
39
  kRemoteSettings,
43
40
  kHTTP2Stream,
44
- kHTTP2SessionState
41
+ kHTTP2SessionState,
42
+ kHTTP2Options
45
43
  } = require('../core/symbols.js')
46
44
  const { channels } = require('../core/diagnostics.js')
47
45
 
@@ -51,6 +49,14 @@ const kRequestStream = Symbol('request stream')
51
49
  const kRequestStreamCleanup = Symbol('request stream cleanup')
52
50
  const kRequestStreamState = Symbol('request stream state')
53
51
  const kReceivedGoAway = Symbol('received goaway')
52
+ const kGoAwayReplayAttempts = Symbol('goaway replay attempts')
53
+ const kRefusedStreamRetry = Symbol('refused stream retry')
54
+
55
+ // RFC 9113 section 8.7: a client SHOULD NOT automatically retry a request more
56
+ // than once. Without a budget a peer that keeps refusing turns one request into
57
+ // an unbounded connect/refuse/reconnect loop that never settles and starves the
58
+ // event loop.
59
+ const MAX_GOAWAY_REPLAY_ATTEMPTS = 1
54
60
 
55
61
  let extractBody
56
62
 
@@ -179,12 +185,24 @@ function completeRequest (client, request, resetPendingIdx = false) {
179
185
  }
180
186
  }
181
187
 
182
- function canRetryRequestAfterGoAway (request) {
188
+ function canReplayRequest (request) {
183
189
  const { body } = request
184
190
 
185
191
  return body == null || util.isBuffer(body) || util.isBlobLike(body)
186
192
  }
187
193
 
194
+ // Count a GOAWAY refusal against the request's replay budget. A peer that
195
+ // refuses every connection must eventually surface an error to the caller
196
+ // rather than being retried forever. Kept separate from canReplayRequest so
197
+ // that the REFUSED_STREAM retry, which has its own single-attempt limit, does
198
+ // not consume this budget just by asking whether the body can be replayed.
199
+ function registerGoAwayRefusal (request) {
200
+ const attempts = (request[kGoAwayReplayAttempts] ?? 0) + 1
201
+ request[kGoAwayReplayAttempts] = attempts
202
+
203
+ return attempts <= MAX_GOAWAY_REPLAY_ATTEMPTS
204
+ }
205
+
188
206
  function closeStream (stream, code = NGHTTP2_REFUSED_STREAM) {
189
207
  if (stream != null && !stream.destroyed && !stream.closed) {
190
208
  try {
@@ -197,19 +215,44 @@ function detachRequestStreamForClose (request) {
197
215
  const stream = request[kRequestStream]
198
216
 
199
217
  clearRequestStream(request)
218
+ severRequestStream(stream)
200
219
 
201
220
  return stream
202
221
  }
203
222
 
223
+ // Unbind a stream from its request for good. releaseRequestStream() alone
224
+ // leaves the 'close' listener attached and kRequestStreamState populated, so a
225
+ // stream abandoned here would still run completeRequestStream() later — and
226
+ // splice out the request that has since been requeued onto another session.
227
+ function severRequestStream (stream) {
228
+ if (stream == null || stream[kRequestStreamState] == null) {
229
+ return
230
+ }
231
+
232
+ stream[kRequestStreamState] = null
233
+ stream.off('close', completeRequestStream)
234
+ // Upgrade streams use their own close cleanup, which would otherwise release
235
+ // the session a second time after the stream has been severed for GOAWAY.
236
+ stream.off('close', onUpgradeStreamClose)
237
+
238
+ if (stream[kHTTP2Session] != null) {
239
+ closeStreamSession(stream)
240
+ }
241
+
242
+ if (!stream.destroyed && !stream.closed) {
243
+ stream.once('error', noop)
244
+ }
245
+ }
246
+
204
247
  function connectH2 (client, socket) {
205
248
  client[kSocket] = socket
206
249
 
207
- const http2InitialWindowSize = client[kHTTP2InitialWindowSize]
208
- const http2ConnectionWindowSize = client[kHTTP2ConnectionWindowSize]
250
+ const http2InitialWindowSize = client[kHTTP2Options].sessionOptions?.initialWindowSize
251
+ const http2ConnectionWindowSize = client[kHTTP2Options].connectionWindowSize
209
252
 
210
253
  const session = http2.connect(client[kUrl], {
211
254
  createConnection: () => socket,
212
- peerMaxConcurrentStreams: client[kMaxConcurrentStreams],
255
+ peerMaxConcurrentStreams: client[kHTTP2Options].maxConcurrentStreams,
213
256
  settings: {
214
257
  // TODO(metcoder95): add support for PUSH
215
258
  enablePush: false,
@@ -223,13 +266,16 @@ function connectH2 (client, socket) {
223
266
  session[kSocket] = socket
224
267
  session[kHTTP2SessionState] = {
225
268
  idleTimeout: null,
269
+ // Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have
270
+ // work that cannot start. See setNoStreamsTimeout.
271
+ noStreamsTimeout: null,
226
272
  // Sockets start out ref'd. Session ref/unref proxies to the socket, so a
227
273
  // single cached flag lets us skip redundant uv ref/unref calls, provided
228
274
  // every ref/unref of the session or its socket goes through
229
275
  // refH2Session/unrefH2Session.
230
276
  refed: true,
231
277
  ping: {
232
- interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref()
278
+ interval: client[kHTTP2Options].pingInterval === 0 ? null : setInterval(onHttp2SendPing, client[kHTTP2Options].pingInterval, session).unref()
233
279
  }
234
280
  }
235
281
  session[kReceivedGoAway] = false
@@ -369,7 +415,74 @@ function resumeH2 (client) {
369
415
  } else {
370
416
  clearHttp2IdleTimeout(session)
371
417
  }
418
+
419
+ if (client[kMaxConcurrentStreams] === 0 && client[kRunning] === 0 && client[kPending] > 0) {
420
+ setNoStreamsTimeout(session)
421
+ } else {
422
+ clearNoStreamsTimeout(session)
423
+ }
424
+ }
425
+ }
426
+
427
+ function clearNoStreamsTimeout (session) {
428
+ const state = session[kHTTP2SessionState]
429
+
430
+ if (state?.noStreamsTimeout != null) {
431
+ clearTimeout(state.noStreamsTimeout)
432
+ state.noStreamsTimeout = null
433
+ }
434
+ }
435
+
436
+ // A peer is allowed to advertise SETTINGS_MAX_CONCURRENT_STREAMS = 0 to refuse
437
+ // new streams (RFC 9113 §6.5.2), and is expected to raise it again later. Until
438
+ // it does, busy() reports the client as permanently busy and queued requests
439
+ // cannot open a stream — which means no per-stream timeout covers them, and no
440
+ // reconnect can happen either, so the SETTINGS frame that would lift the limit
441
+ // can never arrive. Give the peer headersTimeout to start honouring requests
442
+ // before failing them; a request that cannot even be sent has missed the same
443
+ // deadline as one whose headers never arrive.
444
+ function setNoStreamsTimeout (session) {
445
+ const client = session[kClient]
446
+ const state = session[kHTTP2SessionState]
447
+ const timeout = client[kHeadersTimeout]
448
+
449
+ if (!timeout || state.noStreamsTimeout != null) {
450
+ return
451
+ }
452
+
453
+ state.noStreamsTimeout = setTimeout(onNoStreamsTimeout, timeout, session).unref()
454
+ }
455
+
456
+ function onNoStreamsTimeout (session) {
457
+ const client = session[kClient]
458
+ const state = session[kHTTP2SessionState]
459
+
460
+ state.noStreamsTimeout = null
461
+
462
+ if (
463
+ client[kHTTP2Session] !== session ||
464
+ client[kMaxConcurrentStreams] !== 0 ||
465
+ client[kRunning] !== 0 ||
466
+ client[kPending] === 0
467
+ ) {
468
+ return
469
+ }
470
+
471
+ const err = new HeadersTimeoutError(
472
+ `HTTP/2: server did not accept a new stream within ${client[kHeadersTimeout]}`
473
+ )
474
+
475
+ const requests = client[kQueue].splice(client[kPendingIdx])
476
+ for (let i = 0; i < requests.length; i++) {
477
+ if (requests[i] != null) {
478
+ util.errorRequest(client, requests[i], err)
479
+ }
372
480
  }
481
+
482
+ // Drop the unusable session so the next request gets a fresh connection,
483
+ // whose SETTINGS may well allow streams again.
484
+ session[kError] = err
485
+ resetHttp2Session(session, err)
373
486
  }
374
487
 
375
488
  function clearHttp2IdleTimeout (session) {
@@ -527,7 +640,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
527
640
  if (request != null) {
528
641
  streamsToClose.push(detachRequestStreamForClose(request))
529
642
 
530
- if (canRetryRequestAfterGoAway(request)) {
643
+ if (canReplayRequest(request) && registerGoAwayRefusal(request)) {
531
644
  retriableRequests.push(request)
532
645
  } else {
533
646
  util.errorRequest(client, request, err)
@@ -552,6 +665,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
552
665
  }
553
666
 
554
667
  clearHttp2IdleTimeout(this)
668
+ clearNoStreamsTimeout(this)
555
669
 
556
670
  if (!this.closed && !this.destroyed) {
557
671
  this.close()
@@ -576,6 +690,7 @@ function onHttp2SessionClose () {
576
690
  }
577
691
 
578
692
  clearHttp2IdleTimeout(this)
693
+ clearNoStreamsTimeout(this)
579
694
 
580
695
  if (state.ping.interval != null) {
581
696
  clearInterval(state.ping.interval)
@@ -687,6 +802,16 @@ function completeRequestStream () {
687
802
 
688
803
  if (state.pendingEnd && !state.request.aborted && !state.request.completed) {
689
804
  state.request.onResponseEnd(state.trailers || {})
805
+ } else if (!state.request.aborted && !state.request.completed) {
806
+ // The stream closed without a complete response and without reporting an
807
+ // error. finalizeRequest() below frees the queue slot either way, so
808
+ // without this the request would simply vanish and its caller would never
809
+ // hear back.
810
+ util.errorRequest(
811
+ state.client,
812
+ state.request,
813
+ new InformationalError('HTTP/2: stream closed before the response was complete')
814
+ )
690
815
  }
691
816
 
692
817
  finalizeRequest(state)
@@ -1286,6 +1411,38 @@ function onEnd () {
1286
1411
  }
1287
1412
  }
1288
1413
 
1414
+ function retryRefusedStream (stream, state) {
1415
+ const { client, request } = state
1416
+
1417
+ if (
1418
+ state.responseReceived ||
1419
+ request.aborted ||
1420
+ request.completed ||
1421
+ request[kRefusedStreamRetry] ||
1422
+ !canReplayRequest(request)
1423
+ ) {
1424
+ return false
1425
+ }
1426
+
1427
+ // RFC 9113 section 8.7 permits retrying REFUSED_STREAM, but says clients
1428
+ // SHOULD NOT automatically retry the same request more than once.
1429
+ request[kRefusedStreamRetry] = true
1430
+
1431
+ // Detach the failed attempt before moving the request back to the pending
1432
+ // queue. The peer only reset this stream, so the HTTP/2 session remains
1433
+ // usable for the retry. Severing also drops the 'close' listener, so the
1434
+ // abandoned stream cannot later complete the retried request.
1435
+ detachRequestStreamForClose(request)
1436
+ state.stream = null
1437
+ state.requestFinalized = true
1438
+
1439
+ completeRequest(client, request)
1440
+ client[kQueue].splice(client[kPendingIdx], 0, request)
1441
+ client[kResume]()
1442
+
1443
+ return true
1444
+ }
1445
+
1289
1446
  function onError (err) {
1290
1447
  const stream = this
1291
1448
  const state = stream[kRequestStreamState]
@@ -1295,6 +1452,18 @@ function onError (err) {
1295
1452
  }
1296
1453
 
1297
1454
  stream.off('error', onError)
1455
+
1456
+ if (typeof stream.rstCode === 'number' && stream.rstCode !== NGHTTP2_NO_ERROR) {
1457
+ err.http2ErrorCode = stream.rstCode
1458
+ }
1459
+
1460
+ if (
1461
+ stream.rstCode === NGHTTP2_REFUSED_STREAM &&
1462
+ retryRefusedStream(stream, state)
1463
+ ) {
1464
+ return
1465
+ }
1466
+
1298
1467
  state.abort(err)
1299
1468
  }
1300
1469
 
@@ -53,10 +53,8 @@ const {
53
53
  kHTTPContext,
54
54
  kMaxConcurrentStreams,
55
55
  kHostAuthority,
56
- kHTTP2InitialWindowSize,
57
- kHTTP2ConnectionWindowSize,
58
56
  kResume,
59
- kPingInterval
57
+ kHTTP2Options
60
58
  } = require('../core/symbols.js')
61
59
  const connectH1 = require('./client-h1.js')
62
60
  const connectH2 = require('./client-h2.js')
@@ -76,6 +74,16 @@ function getPipelining (client) {
76
74
  return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1
77
75
  }
78
76
 
77
+ let h2NamespaceOptsWarning = false
78
+ function emitH2OptionsNamespaceWarning (optName) {
79
+ if (h2NamespaceOptsWarning === true) return
80
+
81
+ process.emitWarning(`Use h2Options.${optName} instead. ${optName} for H2 will be deprecated in future major.`, {
82
+ code: 'UNDICI-H2-OPTIONS'
83
+ })
84
+ h2NamespaceOptsWarning = true
85
+ }
86
+
79
87
  // Protocol-aware dispatch ceiling. h1 RFC7230 pipelining is unrelated to h2
80
88
  // stream multiplexing — over h2 the ceiling is the (server-confirmed)
81
89
  // maxConcurrentStreams. Before a context is attached we use the h1
@@ -128,7 +136,8 @@ class Client extends DispatcherBase {
128
136
  initialWindowSize,
129
137
  connectionWindowSize,
130
138
  pingInterval,
131
- webSocket
139
+ webSocket,
140
+ h2Options
132
141
  } = {}) {
133
142
  if (keepAlive !== undefined) {
134
143
  throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -216,24 +225,55 @@ class Client extends DispatcherBase {
216
225
  throw new InvalidArgumentError('allowH2 must be a valid boolean value')
217
226
  }
218
227
 
219
- if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) {
220
- throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
221
- }
228
+ // We validate only if allowH2 is enabled or null (enabled by default)
229
+ if (allowH2 !== false) {
230
+ // Prioritise new h2Options object, otherwise fallback to prior configuration options
231
+ if (h2Options != null) {
232
+ if (h2Options.useH2c != null && typeof h2Options.useH2c !== 'boolean') {
233
+ throw new InvalidArgumentError('h2Options.useH2c must be a valid boolean value')
234
+ }
222
235
 
223
- if (useH2c != null && typeof useH2c !== 'boolean') {
224
- throw new InvalidArgumentError('useH2c must be a valid boolean value')
225
- }
236
+ if (h2Options.settings?.initialWindowSize != null && (!Number.isInteger(h2Options.settings.initialWindowSize) || h2Options.settings.initialWindowSize < 1)) {
237
+ throw new InvalidArgumentError('h2Options.settings.initialWindowSize must be a positive integer, greater than 0')
238
+ }
226
239
 
227
- if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) {
228
- throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0')
229
- }
240
+ if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.maxConcurrentStreams < 1)) {
241
+ throw new InvalidArgumentError('h2Options.maxConcurrentStreams must be a positive integer, greater than 0')
242
+ }
230
243
 
231
- if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) {
232
- throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0')
233
- }
244
+ if (h2Options.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) {
245
+ throw new InvalidArgumentError('h2Options.connectionWindowSize must be a positive integer, greater than 0')
246
+ }
247
+
248
+ if (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== 'number' || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) {
249
+ throw new InvalidArgumentError('h2Options.pingInterval must be a positive integer, greater or equal to 0')
250
+ }
251
+ } else {
252
+ if (useH2c != null && typeof useH2c !== 'boolean') {
253
+ emitH2OptionsNamespaceWarning('useH2c')
254
+ throw new InvalidArgumentError('useH2c must be a valid boolean value')
255
+ }
256
+
257
+ if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) {
258
+ emitH2OptionsNamespaceWarning('maxConcurrentStreams')
259
+ throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
260
+ }
234
261
 
235
- if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) {
236
- throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
262
+ if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) {
263
+ emitH2OptionsNamespaceWarning('initialWindowSize')
264
+ throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0')
265
+ }
266
+
267
+ if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) {
268
+ emitH2OptionsNamespaceWarning('connectionWindowSize')
269
+ throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0')
270
+ }
271
+
272
+ if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) {
273
+ emitH2OptionsNamespaceWarning('pingInterval')
274
+ throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0')
275
+ }
276
+ }
237
277
  }
238
278
 
239
279
  super({ webSocket })
@@ -243,8 +283,8 @@ class Client extends DispatcherBase {
243
283
  ...tls,
244
284
  maxCachedSessions,
245
285
  allowH2,
246
- useH2c,
247
286
  socketPath,
287
+ useH2c: h2Options?.useH2c ?? useH2c,
248
288
  timeout: connectTimeout,
249
289
  ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
250
290
  ...connect
@@ -280,16 +320,20 @@ class Client extends DispatcherBase {
280
320
  this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1
281
321
  this[kHTTPContext] = null
282
322
  // h2
283
- this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server
284
- // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance:
285
- // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1)
286
- // Allows more data to be sent before requiring acknowledgment, improving throughput
287
- // especially on high-latency networks. This matches common production HTTP/2 servers.
288
- // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set)
289
- // Provides better flow control for the entire connection across multiple streams.
290
- this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144
291
- this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288
292
- this[kPingInterval] = pingInterval != null ? pingInterval : 60e3 // Default ping interval for h2 - 1 minute
323
+ this[kHTTP2Options] = {
324
+ pingInterval: h2Options?.pingInterval ?? pingInterval ?? 60e3,
325
+ connectionWindowSize: h2Options?.connectionWindowSize ?? connectionWindowSize ?? 524288,
326
+ maxConcurrentStreams: h2Options?.maxConcurrentStreams ?? maxConcurrentStreams ?? 100, // Max peerConcurrentStreams for a Node h2 server
327
+ sessionOptions: {
328
+ // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance:
329
+ // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1)
330
+ // Allows more data to be sent before requiring acknowledgment, improving throughput
331
+ // especially on high-latency networks. This matches common production HTTP/2 servers.
332
+ // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set)
333
+ // Provides better flow control for the entire connection across multiple streams.
334
+ initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144
335
+ }
336
+ }
293
337
 
294
338
  // kQueue is built up of 3 sections separated by
295
339
  // the kRunningIdx and kPendingIdx indices.
@@ -672,6 +716,7 @@ function _resume (client, sync) {
672
716
  }
673
717
 
674
718
  if (!client[kHTTPContext]) {
719
+ client[kServerName] = request.servername
675
720
  connect(client)
676
721
  return
677
722
  }
@@ -65,9 +65,10 @@ class EnvHttpProxyAgent extends DispatcherBase {
65
65
  #getProxyAgentForUrl (url) {
66
66
  let { protocol, host: hostname, port } = url
67
67
 
68
- // Stripping ports in this way instead of using parsedUrl.hostname to make
69
- // sure that the brackets around IPv6 addresses are kept.
70
- hostname = hostname.replace(/:\d*$/, '').toLowerCase()
68
+ // Remove the port suffix (e.g. ":8080") and then strip surrounding
69
+ // brackets from IPv6 literals (e.g. "[::1]" -> "::1") so that the
70
+ // result matches the unbracketed form stored by #parseNoProxy.
71
+ hostname = hostname.replace(/:\d*$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase()
71
72
  port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0
72
73
  if (!this.#shouldProxy(hostname, port)) {
73
74
  return this[kNoProxyAgent]
@@ -119,11 +120,32 @@ class EnvHttpProxyAgent extends DispatcherBase {
119
120
  if (!entry) {
120
121
  continue
121
122
  }
122
- const parsed = entry.match(/^(.+):(\d+)$/)
123
+
124
+ // An IPv6 entry with a port must be bracketed: [::1]:443.
125
+ // A bare IPv6 address like ::1 contains colons that must not be
126
+ // confused with a host:port separator, so we handle it separately.
127
+ let hostname, port
128
+ const ipv6WithPort = entry.match(/^\[(.+)\]:(\d+)$/)
129
+ if (ipv6WithPort) {
130
+ hostname = ipv6WithPort[1]
131
+ port = Number.parseInt(ipv6WithPort[2], 10)
132
+ } else {
133
+ // Bracketed IPv6 without port, or plain hostname[:port], or bare IPv6.
134
+ // Strip optional brackets first.
135
+ const unbracketed = entry.replace(/^\[(.+)\]$/, '$1')
136
+ // A bare IPv6 address contains multiple colons; a hostname:port entry
137
+ // has exactly one colon followed by digits. Only attempt host:port
138
+ // splitting when that is unambiguously the case.
139
+ const colonCount = (unbracketed.match(/:/g) || []).length
140
+ const parsed = colonCount === 1 && unbracketed.match(/^(.+):(\d+)$/)
141
+ hostname = parsed ? parsed[1] : unbracketed
142
+ port = parsed ? Number.parseInt(parsed[2], 10) : 0
143
+ }
144
+
123
145
  noProxyEntries.push({
124
146
  // strip leading dot or asterisk with dot
125
- hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, '').toLowerCase(),
126
- port: parsed ? Number.parseInt(parsed[2], 10) : 0
147
+ hostname: hostname.replace(/^\*?\./, '').toLowerCase(),
148
+ port
127
149
  })
128
150
  }
129
151
 
@@ -6,7 +6,7 @@ let tls // include tls conditionally since it is not always available
6
6
  const DispatcherBase = require('./dispatcher-base')
7
7
  const { InvalidArgumentError } = require('../core/errors')
8
8
  const { Socks5Client, STATES } = require('../core/socks5-client')
9
- const { kDispatch, kClose, kDestroy } = require('../core/symbols')
9
+ const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symbols')
10
10
  const Pool = require('./pool')
11
11
  const buildConnector = require('../core/connect')
12
12
  const { debuglog } = require('node:util')
@@ -226,6 +226,20 @@ class Socks5ProxyAgent extends DispatcherBase {
226
226
  }
227
227
  })
228
228
  this[kPools].set(originKey, pool)
229
+
230
+ const closePoolIfUnused = () => {
231
+ if (this[kPools].get(originKey) !== pool || pool[kConnected] > 0 || pool[kBusy]) {
232
+ return
233
+ }
234
+
235
+ this[kPools].delete(originKey)
236
+ if (!pool.destroyed) {
237
+ pool.close()
238
+ }
239
+ }
240
+
241
+ pool.on('disconnect', closePoolIfUnused)
242
+ pool.on('connectionError', closePoolIfUnused)
229
243
  }
230
244
 
231
245
  // Dispatch the request through the per-origin pool
@@ -241,6 +241,11 @@ class RetryHandler {
241
241
  }
242
242
 
243
243
  onResponseStart (controller, statusCode, headers, statusMessage) {
244
+ if (statusCode < 200) {
245
+ this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage)
246
+ return
247
+ }
248
+
244
249
  this.error = null
245
250
  this.retryCount += 1
246
251
  this.statusCode = statusCode
@@ -305,7 +310,7 @@ class RetryHandler {
305
310
  // First time we receive 206
306
311
  const range = parseRangeHeader(headers['content-range'])
307
312
 
308
- if (range == null) {
313
+ if (range == null || range.end == null) {
309
314
  this.headersSent = true
310
315
  this.handler.onResponseStart?.(
311
316
  this.controllerProxy,
@@ -330,7 +335,7 @@ class RetryHandler {
330
335
  }
331
336
 
332
337
  // We make our best to checkpoint the body for further range headers
333
- if (this.end == null) {
338
+ if (this.end == null && this.opts.method !== 'HEAD') {
334
339
  const contentLength = headers['content-length']
335
340
  this.end = contentLength != null ? Number(contentLength) - 1 : null
336
341
  }
@@ -540,13 +540,16 @@ module.exports = (opts = {}) => {
540
540
 
541
541
  return dispatch => {
542
542
  return (opts, handler) => {
543
- if (!opts.origin || arrayIncludes(safeMethodsToNotCache, opts.method)) {
544
- // Not a method we want to cache or we don't have the origin, skip
543
+ if (arrayIncludes(safeMethodsToNotCache, opts.method)) {
544
+ // Not a method we want to cache, skip
545
545
  return dispatch(opts, handler)
546
546
  }
547
547
 
548
548
  // Check if origin is in whitelist
549
549
  if (origins !== undefined) {
550
+ if (!opts.origin) {
551
+ return dispatch(opts, handler)
552
+ }
550
553
  const requestOrigin = opts.origin.toString().toLowerCase()
551
554
  let isAllowed = false
552
555
 
@@ -59,7 +59,7 @@ module.exports = (opts = {}) => {
59
59
 
60
60
  return dispatch => {
61
61
  return (opts, handler) => {
62
- if (!opts.origin || methods.includes(opts.method) === false) {
62
+ if (opts.upgrade || methods.includes(opts.method) === false) {
63
63
  return dispatch(opts, handler)
64
64
  }
65
65
 
@@ -17,6 +17,7 @@ const {
17
17
  }
18
18
  } = require('node:util')
19
19
  const { InvalidArgumentError } = require('../core/errors')
20
+ const requestAborted = Symbol('request aborted')
20
21
 
21
22
  function matchValue (match, value) {
22
23
  if (typeof match === 'string') {
@@ -153,6 +154,11 @@ function getResponseData (data) {
153
154
  return data
154
155
  } else if (data instanceof ArrayBuffer) {
155
156
  return data
157
+ } else if (ArrayBuffer.isView(data)) {
158
+ // A DataView, or any non-Uint8Array typed array, is a byte container
159
+ // rather than a plain object. Buffer.from() cannot read one directly, so
160
+ // expose the bytes it covers instead of letting it reach JSON.stringify.
161
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
156
162
  } else if (typeof data === 'object') {
157
163
  return JSON.stringify(data)
158
164
  } else if (data) {
@@ -225,9 +231,15 @@ function deleteMockDispatch (mockDispatches, key) {
225
231
  }
226
232
 
227
233
  /**
228
- * @param {string} path Path to remove trailing slash from
234
+ * @param {string|RegExp|Function} path Path, or path matcher, to remove trailing slash from
229
235
  */
230
236
  function removeTrailingSlash (path) {
237
+ // Registered path matchers may be a RegExp or a function, which have no
238
+ // trailing slash to strip; hand those back for matchValue to apply.
239
+ if (typeof path !== 'string') {
240
+ return path
241
+ }
242
+
231
243
  while (path.endsWith('/')) {
232
244
  path = path.slice(0, -1)
233
245
  }
@@ -302,9 +314,13 @@ function mockDispatch (opts, handler) {
302
314
  mockDispatch.consumed = !mockDispatch.persist && timesInvoked >= times
303
315
  mockDispatch.pending = timesInvoked < times
304
316
 
317
+ const hasBodyHooks = typeof handler.onBodySent === 'function' ||
318
+ typeof handler.onRequestSent === 'function'
319
+
305
320
  // Here's where we resolve a callback if a callback is present for the dispatch data.
306
- if (mockDispatch.data.callback) {
307
- const callbackResult = mockDispatch.data.callback(opts)
321
+ if (mockDispatch.data.callback && (!hasBodyHooks || opts.body == null)) {
322
+ const { callback, ...responseDefaults } = mockDispatch.data
323
+ const callbackResult = callback(opts)
308
324
 
309
325
  // An asynchronous reply options callback resolves to the reply data, so
310
326
  // the dispatch can only continue once the returned promise settles.
@@ -313,18 +329,25 @@ function mockDispatch (opts, handler) {
313
329
  if (isPromise(callbackResult)) {
314
330
  callbackResult.then(
315
331
  (resolvedData) => {
316
- mockDispatch.data = { ...mockDispatch.data, ...resolvedData }
332
+ if (resolvedData == null || typeof resolvedData !== 'object') {
333
+ handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object'))
334
+ return
335
+ }
336
+ mockDispatch.data = { ...responseDefaults, ...resolvedData }
317
337
  dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler)
318
338
  },
319
339
  (error) => {
320
- deleteMockDispatch(mockDispatches, key)
321
340
  handler.onResponseError(null, error)
322
341
  }
323
342
  )
324
343
  return true
325
344
  }
326
345
 
327
- mockDispatch.data = { ...mockDispatch.data, ...callbackResult }
346
+ if (callbackResult == null || typeof callbackResult !== 'object') {
347
+ throw new InvalidArgumentError('reply options callback must return an object')
348
+ }
349
+
350
+ mockDispatch.data = { ...responseDefaults, ...callbackResult }
328
351
  }
329
352
 
330
353
  return dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler)
@@ -335,12 +358,12 @@ function mockDispatch (opts, handler) {
335
358
  */
336
359
  function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
337
360
  // Parse mockDispatch data
338
- const { data: { statusCode, data, headers, trailers, error }, delay } = mockDispatch
361
+ const { data: response, delay } = mockDispatch
339
362
 
340
363
  // If specified, trigger dispatch error
341
- if (error !== null) {
364
+ if (response.error !== null) {
342
365
  deleteMockDispatch(mockDispatches, key)
343
- handler.onResponseError(null, error)
366
+ handler.onResponseError(null, response.error)
344
367
  return true
345
368
  }
346
369
 
@@ -375,32 +398,107 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
375
398
  }
376
399
  }
377
400
 
401
+ let replyOpts = opts
402
+ const dispatches = mockDispatches
403
+
378
404
  // Call onRequestStart to allow the handler to receive the controller
379
405
  handler.onRequestStart?.(controller, null)
380
406
 
381
- // Handle the request with a delay if necessary
382
- if (typeof delay === 'number' && delay > 0) {
383
- timer = setTimeout(() => {
384
- timer = null
385
- handleReply(mockDispatches)
386
- }, delay)
387
- } else {
388
- handleReply(mockDispatches)
407
+ if (aborted) {
408
+ return true
409
+ }
410
+
411
+ const requestBody = dispatchRequestBody(opts.body, handler, controller, () => aborted)
412
+
413
+ if (isPromise(requestBody)) {
414
+ requestBody.then((body) => {
415
+ if (body === requestAborted) {
416
+ return
417
+ }
418
+
419
+ if (body !== opts.body) {
420
+ replyOpts = { ...opts, body }
421
+ }
422
+
423
+ sendReply()
424
+ }, (error) => controller.abort(error))
425
+ return true
426
+ }
427
+
428
+ if (requestBody === requestAborted) {
429
+ return true
430
+ }
431
+
432
+ if (requestBody !== opts.body) {
433
+ replyOpts = { ...opts, body: requestBody }
434
+ }
435
+
436
+ sendReply()
437
+
438
+ function sendReply () {
439
+ if (response.callback) {
440
+ const { callback, ...responseDefaults } = response
441
+ let callbackResult
442
+ try {
443
+ callbackResult = callback(replyOpts)
444
+ } catch (err) {
445
+ deleteMockDispatch(mockDispatches, key)
446
+ handler.onResponseError(null, err)
447
+ return
448
+ }
449
+
450
+ if (isPromise(callbackResult)) {
451
+ callbackResult.then(
452
+ (resolvedData) => {
453
+ if (resolvedData == null || typeof resolvedData !== 'object') {
454
+ handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object'))
455
+ return
456
+ }
457
+ mockDispatch.data = { ...responseDefaults, ...resolvedData }
458
+ handleReply(dispatches, mockDispatch.data)
459
+ },
460
+ (err) => {
461
+ handler.onResponseError(null, err)
462
+ }
463
+ )
464
+ return
465
+ }
466
+
467
+ if (callbackResult == null || typeof callbackResult !== 'object') {
468
+ throw new InvalidArgumentError('reply options callback must return an object')
469
+ }
470
+
471
+ mockDispatch.data = { ...responseDefaults, ...callbackResult }
472
+ handleReply(dispatches, mockDispatch.data)
473
+ return
474
+ }
475
+
476
+ // Handle the request with a delay if necessary
477
+ if (typeof delay === 'number' && delay > 0) {
478
+ timer = setTimeout(() => {
479
+ timer = null
480
+ handleReply(dispatches)
481
+ }, delay)
482
+ } else {
483
+ handleReply(dispatches)
484
+ }
389
485
  }
390
486
 
391
- function handleReply (mockDispatches, _data = data) {
487
+ function handleReply (mockDispatches, _response = response) {
392
488
  // Don't send response if the request was aborted
393
489
  if (aborted) {
394
490
  return
395
491
  }
396
492
 
493
+ const { statusCode, data, headers, trailers } = _response
494
+
397
495
  // fetch's HeadersList is a 1D string array
398
496
  const optsHeaders = Array.isArray(opts.headers)
399
497
  ? buildHeadersFromArray(opts.headers)
400
498
  : opts.headers
401
- const body = typeof _data === 'function'
402
- ? _data({ ...opts, headers: optsHeaders })
403
- : _data
499
+ const body = typeof data === 'function'
500
+ ? data({ ...replyOpts, headers: optsHeaders })
501
+ : data
404
502
 
405
503
  // util.types.isPromise is likely needed for jest.
406
504
  if (isPromise(body)) {
@@ -409,7 +507,7 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
409
507
  // synchronously throw the error, which breaks some tests.
410
508
  // Rather, we wait for the callback to resolve if it is a
411
509
  // promise, and then re-run handleReply with the new body.
412
- return body.then((newData) => handleReply(mockDispatches, newData))
510
+ return body.then((newData) => handleReply(mockDispatches, { ..._response, data: newData }))
413
511
  }
414
512
 
415
513
  // Check again if aborted after async body resolution
@@ -418,8 +516,8 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
418
516
  }
419
517
 
420
518
  const responseData = getResponseData(body)
421
- const responseHeaders = generateKeyValues(headers)
422
- const responseTrailers = generateKeyValues(trailers)
519
+ const responseHeaders = generateKeyValues(headers ?? {})
520
+ const responseTrailers = generateKeyValues(trailers ?? {})
423
521
 
424
522
  // Update the controller with response data
425
523
  controller.rawHeaders = responseHeaders
@@ -434,6 +532,97 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) {
434
532
  return true
435
533
  }
436
534
 
535
+ function dispatchRequestBody (body, handler, controller, isAborted) {
536
+ if (typeof handler.onBodySent !== 'function' && typeof handler.onRequestSent !== 'function') {
537
+ return body
538
+ }
539
+
540
+ if (body == null) {
541
+ return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted
542
+ }
543
+
544
+ if (body && typeof body[Symbol.asyncIterator] === 'function') {
545
+ return dispatchAsyncIterableBody(body, handler, controller, isAborted)
546
+ }
547
+
548
+ if (isIterableBody(body)) {
549
+ const chunks = []
550
+
551
+ for (const chunk of body) {
552
+ if (isAborted()) {
553
+ return requestAborted
554
+ }
555
+ chunks.push(chunk)
556
+ if (!callOnBodySent(handler, controller, chunk) || isAborted()) {
557
+ return requestAborted
558
+ }
559
+ }
560
+
561
+ return callOnRequestSent(handler, controller, isAborted) ? chunks : requestAborted
562
+ }
563
+
564
+ if (isAborted()) {
565
+ return requestAborted
566
+ }
567
+
568
+ if (!callOnBodySent(handler, controller, body)) {
569
+ return requestAborted
570
+ }
571
+
572
+ return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted
573
+ }
574
+
575
+ async function dispatchAsyncIterableBody (body, handler, controller, isAborted) {
576
+ const chunks = []
577
+
578
+ for await (const chunk of body) {
579
+ if (isAborted()) {
580
+ return requestAborted
581
+ }
582
+ chunks.push(chunk)
583
+ if (!callOnBodySent(handler, controller, chunk) || isAborted()) {
584
+ return requestAborted
585
+ }
586
+ }
587
+
588
+ if (!callOnRequestSent(handler, controller, isAborted)) {
589
+ return requestAborted
590
+ }
591
+
592
+ return {
593
+ async * [Symbol.asyncIterator] () {
594
+ yield * chunks
595
+ }
596
+ }
597
+ }
598
+
599
+ function callOnBodySent (handler, controller, chunk) {
600
+ try {
601
+ handler.onBodySent?.(chunk)
602
+ return true
603
+ } catch (error) {
604
+ controller.abort(error)
605
+ return false
606
+ }
607
+ }
608
+
609
+ function callOnRequestSent (handler, controller, isAborted) {
610
+ try {
611
+ handler.onRequestSent?.()
612
+ return !isAborted()
613
+ } catch (error) {
614
+ controller.abort(error)
615
+ return false
616
+ }
617
+ }
618
+
619
+ function isIterableBody (body) {
620
+ return typeof body !== 'string' &&
621
+ !Buffer.isBuffer(body) &&
622
+ !ArrayBuffer.isView(body) &&
623
+ typeof body[Symbol.iterator] === 'function'
624
+ }
625
+
437
626
  function buildMockDispatch () {
438
627
  const agent = this[kMockAgent]
439
628
  const origin = this[kOrigin]
package/lib/util/cache.js CHANGED
@@ -148,9 +148,7 @@ function getMalformedRestrictiveDirectiveName (key) {
148
148
  * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts
149
149
  */
150
150
  function makeCacheKey (opts) {
151
- if (!opts.origin) {
152
- throw new Error('opts.origin is undefined')
153
- }
151
+ const origin = opts.origin ? opts.origin.toString() : ''
154
152
 
155
153
  let fullPath = opts.path || '/'
156
154
 
@@ -159,7 +157,7 @@ function makeCacheKey (opts) {
159
157
  }
160
158
 
161
159
  return {
162
- origin: opts.origin.toString(),
160
+ origin,
163
161
  method: opts.method,
164
162
  path: fullPath,
165
163
  headers: opts.headers
@@ -25,6 +25,9 @@ const { SendQueue } = require('./sender')
25
25
  const { WebsocketFrameSend } = require('./frame')
26
26
  const { channels } = require('../../core/diagnostics')
27
27
 
28
+ const kRef = Symbol.for('nodejs.ref')
29
+ const kUnref = Symbol.for('nodejs.unref')
30
+
28
31
  function getSocketAddress (socket) {
29
32
  if (typeof socket?.address === 'function') {
30
33
  return socket.address()
@@ -68,6 +71,7 @@ class WebSocket extends EventTarget {
68
71
  #bufferedAmount = 0
69
72
  #protocol = ''
70
73
  #extensions = ''
74
+ #refed = true
71
75
 
72
76
  /** @type {SendQueue} */
73
77
  #sendQueue
@@ -194,6 +198,20 @@ class WebSocket extends EventTarget {
194
198
  this.#binaryType = 'blob'
195
199
  }
196
200
 
201
+ [kRef] () {
202
+ webidl.brandCheck(this, WebSocket)
203
+
204
+ this.#refed = true
205
+ this.#handler.socket?.ref?.()
206
+ }
207
+
208
+ [kUnref] () {
209
+ webidl.brandCheck(this, WebSocket)
210
+
211
+ this.#refed = false
212
+ this.#handler.socket?.unref?.()
213
+ }
214
+
197
215
  /**
198
216
  * @see https://websockets.spec.whatwg.org/#dom-websocket-close
199
217
  * @param {number|undefined} code
@@ -468,6 +486,10 @@ class WebSocket extends EventTarget {
468
486
  // once this happens, the connection is open
469
487
  this.#handler.socket = response.socket
470
488
 
489
+ if (!this.#refed) {
490
+ this.#handler.socket.unref?.()
491
+ }
492
+
471
493
  // Get options from dispatcher options
472
494
  const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments
473
495
  const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "undici",
3
- "version": "8.9.0",
3
+ "version": "8.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/client.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import { URL } from 'node:url'
2
+ import { SessionOptions } from 'node:http2'
2
3
  import Dispatcher from './dispatcher'
3
4
  import buildConnector from './connector'
4
5
  import TClientStats from './client-stats'
5
6
 
6
7
  type ClientConnectOptions<TOpaque = null> = Omit<Dispatcher.ConnectOptions<TOpaque>, 'origin'>
7
8
 
9
+ // TODO: Pendings
10
+ // 1. Reflect this on Client instantiation
11
+ // 2. Client H2 should use this namespaced options instead.
12
+
8
13
  /**
9
14
  * A basic HTTP/1.1 client, mapped on top a single TCP/TLS connection. Pipelining is disabled by default.
10
15
  */
@@ -87,23 +92,31 @@ export declare namespace Client {
87
92
  /**
88
93
  * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
89
94
  * @default 100
95
+ * @deprecated Use h2Options.maxConcurrentStreams instead
90
96
  */
91
97
  maxConcurrentStreams?: number;
92
98
  /**
93
99
  * @description Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE).
94
100
  * @default 262144
101
+ * @deprecated Use h2Options.settings.initialWindowSize instead
95
102
  */
96
103
  initialWindowSize?: number;
97
104
  /**
98
105
  * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize).
99
106
  * @default 524288
107
+ * @deprecated Use h2Options.connectionWindowSize instead
100
108
  */
101
109
  connectionWindowSize?: number;
102
110
  /**
103
111
  * @description Time interval between PING frames dispatch
104
112
  * @default 60000
113
+ * @deprecated Use h2Options.connectionWindowSize instead
105
114
  */
106
115
  pingInterval?: number;
116
+ /**
117
+ * @description HTTP/2 configuration options
118
+ */
119
+ h2Options?: Client.H2Options;
107
120
  }
108
121
  export interface SocketInfo {
109
122
  localAddress?: string
@@ -129,6 +142,33 @@ export declare namespace Client {
129
142
  */
130
143
  maxPayloadSize?: number;
131
144
  }
145
+
146
+ export interface H2Options extends Omit<SessionOptions, keyof buildConnector.BuildOptions> {
147
+ /**
148
+ * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize).
149
+ * @default 524288
150
+ */
151
+ connectionWindowSize?: number;
152
+ /**
153
+ * @description Time interval between PING frames dispatch
154
+ * @default 60000
155
+ */
156
+ pingInterval?: number;
157
+ /**
158
+ * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame.
159
+ * @default 100
160
+ */
161
+ maxConcurrentStreams?: number;
162
+ /**
163
+ * @description Enable support for H2C (plain text)
164
+ * @default false
165
+ */
166
+ useH2c?: boolean;
167
+ /**
168
+ * @description SETTINGS frame object. Default to 'node:http2' defaults
169
+ */
170
+ settings?: Omit<SessionOptions['settings'], 'enablePush' | 'maxConcurrentStreams' | 'enableConnectProtocol'>
171
+ }
132
172
  }
133
173
 
134
174
  export default Client