undici 7.29.0 → 7.29.1

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.
@@ -49,14 +49,16 @@ function defaultFactory (origin, opts) {
49
49
  }
50
50
 
51
51
  class BalancedPool extends PoolBase {
52
- constructor (upstreams = [], { factory = defaultFactory, ...opts } = {}) {
52
+ constructor (upstreams = [], { factory = defaultFactory, connect, tls, ...opts } = {}) {
53
53
  if (typeof factory !== 'function') {
54
54
  throw new InvalidArgumentError('factory must be a function.')
55
55
  }
56
56
 
57
57
  super(opts)
58
58
 
59
- this[kOptions] = { ...util.deepClone(opts) }
59
+ if (connect && typeof connect !== 'function') connect = { ...connect }
60
+ if (tls && typeof tls !== 'function') tls = { ...tls }
61
+ this[kOptions] = { ...util.deepClone(opts), connect, tls }
60
62
  this[kOptions].interceptors = opts.interceptors
61
63
  ? { ...opts.interceptors }
62
64
  : undefined
@@ -1012,7 +1012,7 @@ function onSocketClose () {
1012
1012
 
1013
1013
  function clearIdleSocketValidation (socket) {
1014
1014
  if (socket[kIdleSocketValidationTimeout]) {
1015
- clearTimeout(socket[kIdleSocketValidationTimeout])
1015
+ clearImmediate(socket[kIdleSocketValidationTimeout])
1016
1016
  socket[kIdleSocketValidationTimeout] = null
1017
1017
  }
1018
1018
 
@@ -1021,15 +1021,23 @@ function clearIdleSocketValidation (socket) {
1021
1021
 
1022
1022
  function scheduleIdleSocketValidation (client, socket) {
1023
1023
  socket[kIdleSocketValidation] = 1
1024
- socket[kIdleSocketValidationTimeout] = setTimeout(() => {
1024
+ // Yield to the check phase (after poll) so unsolicited bytes / FIN / RST
1025
+ // already pending on this idle keep-alive socket are processed before the
1026
+ // next request is written (GHSA-35p6-xmwp-9g52).
1027
+ //
1028
+ // setTimeout(0) pays Node's ~1ms timer floor on every sequential reuse
1029
+ // (#5493). setImmediate avoids that, but an *unref'd* Immediate lets poll
1030
+ // block for ~500ms when the event loop is otherwise idle (#5600 / #5606).
1031
+ // A ref'd Immediate both keeps the pending request alive and makes poll
1032
+ // return immediately — the hybrid those issues asked for.
1033
+ socket[kIdleSocketValidationTimeout] = setImmediate(() => {
1025
1034
  socket[kIdleSocketValidationTimeout] = null
1026
1035
  socket[kIdleSocketValidation] = 2
1027
1036
 
1028
1037
  if (client[kSocket] === socket && !socket.destroyed) {
1029
1038
  client[kResume]()
1030
1039
  }
1031
- }, 0)
1032
- socket[kIdleSocketValidationTimeout].unref?.()
1040
+ })
1033
1041
  }
1034
1042
 
1035
1043
  /**
@@ -8,7 +8,9 @@ const {
8
8
  RequestAbortedError,
9
9
  SocketError,
10
10
  InformationalError,
11
- InvalidArgumentError
11
+ InvalidArgumentError,
12
+ HeadersTimeoutError,
13
+ BodyTimeoutError
12
14
  } = require('../core/errors.js')
13
15
  const {
14
16
  kUrl,
@@ -33,6 +35,7 @@ const {
33
35
  kHTTPContext,
34
36
  kClosed,
35
37
  kBodyTimeout,
38
+ kHeadersTimeout,
36
39
  kEnableConnectProtocol,
37
40
  kRemoteSettings,
38
41
  kHTTP2Stream,
@@ -219,7 +222,11 @@ function resumeH2 (client) {
219
222
  const socket = client[kSocket]
220
223
 
221
224
  if (socket?.destroyed === false) {
222
- if (client[kSize] === 0 || client[kMaxConcurrentStreams] === 0) {
225
+ // Only let the process exit when there is genuinely nothing outstanding.
226
+ // Unreffing because the peer advertised MAX_CONCURRENT_STREAMS = 0 left
227
+ // queued requests with nothing holding the event loop open, so the process
228
+ // could exit with status 0 while an awaited request never settled.
229
+ if (client[kSize] === 0) {
223
230
  socket.unref()
224
231
  client[kHTTP2Session].unref()
225
232
  } else {
@@ -314,6 +321,36 @@ function onHttp2SessionEnd () {
314
321
  * @this {import('http2').ClientHttp2Session}
315
322
  * @param {number} errorCode
316
323
  */
324
+ // Backport of #5410 and #5569. HTTP/2 multiplexes, so requests complete out of
325
+ // order; advancing kRunningIdx blindly retired whichever request happened to
326
+ // sit at the head instead of the one that actually finished, which both lost
327
+ // requests and left phantom running slots behind.
328
+ function completeRequest (client, request, resetPendingIdx = false) {
329
+ const queue = client[kQueue]
330
+ const runningIdx = client[kRunningIdx]
331
+
332
+ // In-order completion: clear the request and advance without splicing.
333
+ // The client's resume loop compacts cleared slots once the index grows.
334
+ if (runningIdx < client[kPendingIdx] && queue[runningIdx] === request) {
335
+ queue[runningIdx] = null
336
+ client[kRunningIdx] = runningIdx + 1
337
+ return
338
+ }
339
+
340
+ const index = queue.indexOf(request, runningIdx)
341
+
342
+ if (index === -1 || index >= client[kPendingIdx]) {
343
+ return
344
+ }
345
+
346
+ queue.splice(index, 1)
347
+ client[kPendingIdx]--
348
+
349
+ if (resetPendingIdx && client[kPendingIdx] < client[kRunningIdx]) {
350
+ client[kPendingIdx] = client[kRunningIdx]
351
+ }
352
+ }
353
+
317
354
  function onHttp2SessionGoAway (errorCode) {
318
355
  // TODO(mcollina): Verify if GOAWAY implements the spec correctly:
319
356
  // https://datatracker.ietf.org/doc/html/rfc7540#section-6.8
@@ -335,7 +372,9 @@ function onHttp2SessionGoAway (errorCode) {
335
372
  if (client[kRunningIdx] < client[kQueue].length) {
336
373
  const request = client[kQueue][client[kRunningIdx]]
337
374
  client[kQueue][client[kRunningIdx]++] = null
338
- util.errorRequest(client, request, err)
375
+ if (request != null) {
376
+ util.errorRequest(client, request, err)
377
+ }
339
378
  client[kPendingIdx] = client[kRunningIdx]
340
379
  }
341
380
 
@@ -368,7 +407,9 @@ function onHttp2SessionClose () {
368
407
  const requests = client[kQueue].splice(client[kRunningIdx])
369
408
  for (let i = 0; i < requests.length; i++) {
370
409
  const request = requests[i]
371
- util.errorRequest(client, request, err)
410
+ if (request != null) {
411
+ util.errorRequest(client, request, err)
412
+ }
372
413
  }
373
414
  }
374
415
  }
@@ -416,7 +457,10 @@ function shouldSendContentLength (method) {
416
457
  }
417
458
 
418
459
  function writeH2 (client, request) {
419
- const requestTimeout = request.bodyTimeout ?? client[kBodyTimeout]
460
+ // Time to the response headers, then time between body chunks. Using
461
+ // bodyTimeout for both made headersTimeout a no-op over HTTP/2.
462
+ const headersTimeout = request.headersTimeout ?? client[kHeadersTimeout]
463
+ const bodyTimeout = request.bodyTimeout ?? client[kBodyTimeout]
420
464
  const session = client[kHTTP2Session]
421
465
  const { method, path, host, upgrade, expectContinue, signal, protocol, headers: reqHeaders } = request
422
466
  let { body } = request
@@ -483,6 +527,7 @@ function writeH2 (client, request) {
483
527
 
484
528
  // We move the running index to the next request
485
529
  client[kOnError](err)
530
+ completeRequest(client, request)
486
531
  client[kResume]()
487
532
  }
488
533
 
@@ -537,7 +582,7 @@ function writeH2 (client, request) {
537
582
  request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
538
583
 
539
584
  ++session[kOpenStreams]
540
- client[kQueue][client[kRunningIdx]++] = null
585
+ completeRequest(client, request)
541
586
  })
542
587
 
543
588
  stream.on('error', () => {
@@ -554,7 +599,7 @@ function writeH2 (client, request) {
554
599
  if (session[kOpenStreams] === 0) session.unref()
555
600
  })
556
601
 
557
- stream.setTimeout(requestTimeout)
602
+ stream.setTimeout(headersTimeout)
558
603
  return true
559
604
  }
560
605
 
@@ -570,13 +615,14 @@ function writeH2 (client, request) {
570
615
 
571
616
  request.onUpgrade(statusCode, parseH2Headers(realHeaders), stream)
572
617
  ++session[kOpenStreams]
573
- client[kQueue][client[kRunningIdx]++] = null
618
+ completeRequest(client, request)
574
619
  })
620
+ stream.on('error', abort)
575
621
  stream.once('close', () => {
576
622
  session[kOpenStreams] -= 1
577
623
  if (session[kOpenStreams] === 0) session.unref()
578
624
  })
579
- stream.setTimeout(requestTimeout)
625
+ stream.setTimeout(headersTimeout)
580
626
 
581
627
  return true
582
628
  }
@@ -677,7 +723,7 @@ function writeH2 (client, request) {
677
723
 
678
724
  // Increment counter as we have new streams open
679
725
  ++session[kOpenStreams]
680
- stream.setTimeout(requestTimeout)
726
+ stream.setTimeout(headersTimeout)
681
727
 
682
728
  // Track whether we received a response (headers)
683
729
  let responseReceived = false
@@ -686,6 +732,7 @@ function writeH2 (client, request) {
686
732
  const { [HTTP2_HEADER_STATUS]: statusCode, ...realHeaders } = headers
687
733
  request.onResponseStarted()
688
734
  responseReceived = true
735
+ stream.setTimeout(bodyTimeout)
689
736
 
690
737
  // Due to the stream nature, it is possible we face a race condition
691
738
  // where the stream has been assigned, but the request has been aborted
@@ -720,14 +767,13 @@ function writeH2 (client, request) {
720
767
  request.onComplete({})
721
768
  }
722
769
 
723
- client[kQueue][client[kRunningIdx]++] = null
770
+ completeRequest(client, request)
724
771
  client[kResume]()
725
772
  } else {
726
773
  // Stream ended without receiving a response - this is an error
727
774
  // (e.g., server destroyed the stream before sending headers)
728
775
  abort(new InformationalError('HTTP/2: stream half-closed (remote)'))
729
- client[kQueue][client[kRunningIdx]++] = null
730
- client[kPendingIdx] = client[kRunningIdx]
776
+ completeRequest(client, request, true)
731
777
  client[kResume]()
732
778
  }
733
779
  })
@@ -738,6 +784,14 @@ function writeH2 (client, request) {
738
784
  if (session[kOpenStreams] === 0) {
739
785
  session.unref()
740
786
  }
787
+
788
+ // A stream can close without ever emitting 'end' or 'error': a peer's
789
+ // RST_STREAM(CANCEL) received before the response is reported by Node as a
790
+ // bare 'close', and destroying the stream unenrolls its timeout, so no
791
+ // 'timeout' follows either. Nothing else would ever settle this request.
792
+ if (!request.aborted && !request.completed) {
793
+ abort(new InformationalError('HTTP/2: stream closed before the response was complete'))
794
+ }
741
795
  })
742
796
 
743
797
  stream.once('error', function (err) {
@@ -755,7 +809,9 @@ function writeH2 (client, request) {
755
809
  })
756
810
 
757
811
  stream.on('timeout', () => {
758
- const err = new InformationalError(`HTTP/2: "stream timeout after ${requestTimeout}"`)
812
+ const err = responseReceived
813
+ ? new BodyTimeoutError(`HTTP/2: "body timeout after ${bodyTimeout}"`)
814
+ : new HeadersTimeoutError(`HTTP/2: "headers timeout after ${headersTimeout}"`)
759
815
  stream.removeAllListeners('data')
760
816
  session[kOpenStreams] -= 1
761
817
 
@@ -374,7 +374,9 @@ class Client extends DispatcherBase {
374
374
  const requests = this[kQueue].splice(this[kPendingIdx])
375
375
  for (let i = 0; i < requests.length; i++) {
376
376
  const request = requests[i]
377
- util.errorRequest(this, request, err)
377
+ if (request != null) {
378
+ util.errorRequest(this, request, err)
379
+ }
378
380
  }
379
381
 
380
382
  const callback = () => {
@@ -413,7 +415,9 @@ function onError (client, err) {
413
415
 
414
416
  for (let i = 0; i < requests.length; i++) {
415
417
  const request = requests[i]
416
- util.errorRequest(client, request, err)
418
+ if (request != null) {
419
+ util.errorRequest(client, request, err)
420
+ }
417
421
  }
418
422
  assert(client[kSize] === 0)
419
423
  }
@@ -207,6 +207,13 @@ class CacheHandler {
207
207
  }
208
208
 
209
209
  const cacheControlHeader = resHeaders['cache-control']
210
+ const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
211
+
212
+ if (revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives)) {
213
+ deleteCachedValue(this.#store, this.#cacheKey)
214
+ return downstreamOnHeaders()
215
+ }
216
+
210
217
  const heuristicallyCacheable = resHeaders['last-modified'] && arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, statusCode)
211
218
  if (
212
219
  !cacheControlHeader &&
@@ -223,8 +230,7 @@ class CacheHandler {
223
230
  return downstreamOnHeaders()
224
231
  }
225
232
 
226
- const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
227
- if (!canCacheResponse(this.#cacheType, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
233
+ if (!canCacheResponse(this.#cacheType, this.#cacheKey.method, statusCode, resHeaders, cacheControlDirectives, this.#cacheKey.headers)) {
228
234
  if (statusCode === 304 && (cacheControlHeader || revalidationResponseDisallowsCachedReuse(this.#cacheType, resHeaders, cacheControlDirectives))) {
229
235
  deleteCachedValue(this.#store, this.#cacheKey)
230
236
  }
@@ -465,7 +471,10 @@ function deleteCachedValueIfNotModified (statusCode, store, cacheKey) {
465
471
  */
466
472
  function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheControlDirectives) {
467
473
  return cacheControlDirectives['no-store'] === true ||
468
- (cacheType === 'shared' && cacheControlDirectives.private === true) ||
474
+ (cacheType === 'shared' && (
475
+ cacheControlDirectives.private === true ||
476
+ Object.hasOwn(resHeaders, 'set-cookie')
477
+ )) ||
469
478
  (resHeaders.vary ? isInvalidOrWildcardVaryHeader(resHeaders.vary) : false)
470
479
  }
471
480
 
@@ -473,12 +482,16 @@ function revalidationResponseDisallowsCachedReuse (cacheType, resHeaders, cacheC
473
482
  * @see https://www.rfc-editor.org/rfc/rfc9111.html#name-storing-responses-to-authen
474
483
  *
475
484
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
485
+ * @param {string} method
476
486
  * @param {number} statusCode
477
487
  * @param {import('../../types/header.d.ts').IncomingHttpHeaders} resHeaders
478
488
  * @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives} cacheControlDirectives
479
489
  * @param {import('../../types/header.d.ts').IncomingHttpHeaders} [reqHeaders]
480
490
  */
481
- function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
491
+ function canCacheResponse (cacheType, method, statusCode, resHeaders, cacheControlDirectives, reqHeaders) {
492
+ if (!arrayIncludes(util.safeHTTPMethods, method)) {
493
+ return false
494
+ }
482
495
  // Status code must be final and understood.
483
496
  if (statusCode < 200 || arrayIncludes(NOT_UNDERSTOOD_STATUS_CODES, statusCode)) {
484
497
  return false
@@ -499,7 +512,10 @@ function canCacheResponse (cacheType, statusCode, resHeaders, cacheControlDirect
499
512
  return false
500
513
  }
501
514
 
502
- if (cacheType === 'shared' && cacheControlDirectives.private === true) {
515
+ if (cacheType === 'shared' && (
516
+ cacheControlDirectives.private === true ||
517
+ Object.hasOwn(resHeaders, 'set-cookie')
518
+ )) {
503
519
  return false
504
520
  }
505
521
 
@@ -95,8 +95,16 @@ class RetryHandler {
95
95
  if (this.retryOpts.throwOnError) {
96
96
  // Preserve old behavior for status codes that are not eligible for retry
97
97
  if (this.retryOpts.statusCodes.includes(statusCode) === false) {
98
- this.headersSent = true
99
- this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
98
+ if (this.headersSent) {
99
+ // The downstream handler already received the response from an
100
+ // earlier attempt. Forwarding this response would replace the
101
+ // downstream body and leave the original body pending forever.
102
+ this.handler.onResponseError?.(controller, err)
103
+ } else {
104
+ this.headersSent = true
105
+ this.checkpointResponseEnd(headers)
106
+ this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
107
+ }
100
108
  } else {
101
109
  this.error = err
102
110
  }
@@ -106,14 +114,23 @@ class RetryHandler {
106
114
 
107
115
  if (isDisturbed(this.opts.body)) {
108
116
  this.headersSent = true
117
+ this.checkpointResponseEnd(headers)
109
118
  this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
110
119
  return
111
120
  }
112
121
 
113
122
  function shouldRetry (passedErr) {
114
123
  if (passedErr) {
115
- this.headersSent = true
116
- this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
124
+ if (this.headersSent) {
125
+ // The downstream handler already received the response from an
126
+ // earlier attempt. Forwarding this response would replace the
127
+ // downstream body and leave the original body pending forever.
128
+ this.handler.onResponseError?.(controller, passedErr)
129
+ } else {
130
+ this.headersSent = true
131
+ this.checkpointResponseEnd(headers)
132
+ this.handler.onResponseStart?.(controller, statusCode, headers, statusMessage)
133
+ }
117
134
  controller.resume()
118
135
  return
119
136
  }
@@ -133,6 +150,20 @@ class RetryHandler {
133
150
  )
134
151
  }
135
152
 
153
+ checkpointResponseEnd (headers) {
154
+ if (this.end == null && this.opts.method !== 'HEAD') {
155
+ const contentLength = headers['content-length']
156
+ this.end = contentLength != null ? Number(contentLength) - 1 : null
157
+
158
+ assert(
159
+ this.end == null || Number.isFinite(this.end),
160
+ 'invalid content-length'
161
+ )
162
+
163
+ this.resume = this.end != null
164
+ }
165
+ }
166
+
136
167
  onRequestStart (controller, context) {
137
168
  if (!this.headersSent) {
138
169
  this.handler.onRequestStart?.(controller, context)
@@ -253,8 +284,12 @@ class RetryHandler {
253
284
 
254
285
  const { start, size, end = size ? size - 1 : null } = contentRange
255
286
 
256
- assert(this.start === start, 'content-range mismatch')
257
- assert(this.end == null || this.end === end, 'content-range mismatch')
287
+ if (this.start !== start || (this.end != null && this.end !== end)) {
288
+ throw new RequestRetryError('Content-Range mismatch', statusCode, {
289
+ headers,
290
+ data: { count: this.retryCount }
291
+ })
292
+ }
258
293
 
259
294
  return
260
295
  }
@@ -379,7 +414,7 @@ class RetryHandler {
379
414
  }
380
415
 
381
416
  onResponseError (controller, err) {
382
- if (controller?.aborted || isDisturbed(this.opts.body)) {
417
+ if (controller?.aborted || isDisturbed(this.opts.body) || (this.headersSent && !this.resume)) {
383
418
  this.handler.onResponseError?.(controller, err)
384
419
  return
385
420
  }
@@ -117,7 +117,10 @@ function staleResponseRequiresRevalidation (result, cacheType) {
117
117
  * @returns {boolean}
118
118
  */
119
119
  function revalidationResponseDisallowsCachedReuse (cacheType, headers) {
120
- if (headers.vary && isInvalidOrWildcardVaryHeader(headers.vary)) {
120
+ if (
121
+ (headers.vary && isInvalidOrWildcardVaryHeader(headers.vary)) ||
122
+ (cacheType === 'shared' && Object.hasOwn(headers, 'set-cookie'))
123
+ ) {
121
124
  return true
122
125
  }
123
126
 
@@ -376,6 +379,17 @@ function handleResult (
376
379
  return handleUncachedResponse(dispatch, globalOpts, cacheKey, handler, opts, reqCacheControl)
377
380
  }
378
381
 
382
+ // Shared stores may outlive the Undici version that wrote them. Do not
383
+ // re-serve a Set-Cookie header from an existing shared-cache entry.
384
+ if (globalOpts.type === 'shared' && Object.hasOwn(result.headers, 'set-cookie')) {
385
+ if (util.isStream(result.body)) {
386
+ result.body.on('error', nop).destroy()
387
+ }
388
+
389
+ deleteCachedValue(globalOpts.store, cacheKey)
390
+ return handleUncachedResponse(dispatch, globalOpts, cacheKey, handler, opts, reqCacheControl)
391
+ }
392
+
379
393
  const now = Date.now()
380
394
  if (now > result.deleteAt) {
381
395
  // Response is expired, cache store shouldn't have given this to us
@@ -574,6 +588,11 @@ module.exports = (opts = {}) => {
574
588
  * @type {import('../../types/cache-interceptor.d.ts').default.CacheKey}
575
589
  */
576
590
  const cacheKey = makeCacheKey(opts)
591
+
592
+ if (!arrayIncludes(util.safeHTTPMethods, opts.method)) {
593
+ return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
594
+ }
595
+
577
596
  const result = store.get(cacheKey)
578
597
 
579
598
  if (result && typeof result.then === 'function') {