Commit d1a16216fda for nodejs
commit d1a16216fda39bf65931e65d0a04cf9f3bb23e7d
Author: Node.js GitHub Bot <github-bot@iojs.org>
Date: Sat Nov 7 05:42:45 2026 -0500
deps: update undici to 8.12.4
PR-URL: https://github.com/nodejs/node/pull/67272
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Jake Yuesong Li <jake.yuesong@gmail.com>
diff --git a/deps/undici/src/README.md b/deps/undici/src/README.md
index 479059fbc1a..7662b905bdf 100644
--- a/deps/undici/src/README.md
+++ b/deps/undici/src/README.md
@@ -171,7 +171,7 @@ const response = await fetch('https://api.example.com/data');
- Building isomorphic code that runs in browsers and Node.js
- Publishing to npm and want to maximize compatibility with JS runtimes
- Simple HTTP requests without advanced configuration
-- You're publishing to npm and you want to maximize compatiblity
+- You're publishing to npm and you want to maximize compatibility
- You don't depend on features from a specific version of undici
#### Use Undici Module When:
diff --git a/deps/undici/src/docs/docs/api/Dispatcher.md b/deps/undici/src/docs/docs/api/Dispatcher.md
index 4e3c1293130..61c0d64f53d 100644
--- a/deps/undici/src/docs/docs/api/Dispatcher.md
+++ b/deps/undici/src/docs/docs/api/Dispatcher.md
@@ -205,7 +205,10 @@ added: v4.0.0
-->
* `options` {Object}
- * `origin` {string|URL} (optional) The origin to dispatch the request to.
+ * `origin` {string|URL} The origin to dispatch the request to. Optional for a
+ dispatcher that is already bound to a single origin, such as `Client` or
+ `Pool`, which then use their own origin. Required on `Agent`, which is not
+ bound to one origin and throws an `InvalidArgumentError` when it is missing.
* `path` {string} The request path.
* `method` {string} The HTTP method, e.g. `'GET'` or `'POST'`.
* `body` {string|Buffer|Uint8Array|Readable|FormData|null} The request body.
diff --git a/deps/undici/src/docs/docs/api/RetryAgent.md b/deps/undici/src/docs/docs/api/RetryAgent.md
index 726620b72f2..bcc8582b908 100644
--- a/deps/undici/src/docs/docs/api/RetryAgent.md
+++ b/deps/undici/src/docs/docs/api/RetryAgent.md
@@ -60,7 +60,7 @@ added: v6.7.0
* `retryAfter` {boolean} When `true`, the delay between retries is inferred from
the `Retry-After` response header when present. **Default:** `true`.
* `methods` {string[]} HTTP methods that are eligible for retry.
- **Default:** `['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']`.
+ **Default:** `['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY']`.
* `statusCodes` {number[]} HTTP status codes that trigger a retry.
**Default:** `[500, 502, 503, 504, 429]`.
* `errorCodes` {string[]} Transport error codes that trigger a retry.
diff --git a/deps/undici/src/docs/docs/api/RetryHandler.md b/deps/undici/src/docs/docs/api/RetryHandler.md
index fa028ef1d59..3557ab73334 100644
--- a/deps/undici/src/docs/docs/api/RetryHandler.md
+++ b/deps/undici/src/docs/docs/api/RetryHandler.md
@@ -85,7 +85,7 @@ changes:
* `retryAfter` {boolean} When `true`, the delay before the next retry is inferred
from the `Retry-After` response header when present. **Default:** `true`.
* `methods` {string[]} HTTP methods that are eligible for retrying. **Default:**
- `['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']`.
+ `['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY']`.
* `statusCodes` {number[]} HTTP status codes that trigger a retry. **Default:**
`[500, 502, 503, 504, 429]`.
* `errorCodes` {string[]} Network error codes that trigger a retry. **Default:**
diff --git a/deps/undici/src/docs/docs/index.md b/deps/undici/src/docs/docs/index.md
index bb402702ee7..dee24547d53 100644
--- a/deps/undici/src/docs/docs/index.md
+++ b/deps/undici/src/docs/docs/index.md
@@ -155,7 +155,7 @@ const response = await fetch('https://api.example.com/data');
- Building isomorphic code that runs in browsers and Node.js
- Publishing to npm and want to maximize compatibility with JS runtimes
- Simple HTTP requests without advanced configuration
-- You're publishing to npm and you want to maximize compatiblity
+- You're publishing to npm and you want to maximize compatibility
- You don't depend on features from a specific version of undici
#### Use Undici Module When:
diff --git a/deps/undici/src/lib/cache/sqlite-cache-store.js b/deps/undici/src/lib/cache/sqlite-cache-store.js
index d17b13241b1..1024cb6a791 100644
--- a/deps/undici/src/lib/cache/sqlite-cache-store.js
+++ b/deps/undici/src/lib/cache/sqlite-cache-store.js
@@ -6,7 +6,9 @@ const { zstdCompressSync, zstdDecompressSync } = require('node:zlib')
let DatabaseSync
-const VERSION = 4
+// Bump when entries written by an older cache policy are unsafe to reuse.
+// Version 5 invalidates entries that may have been poisoned through redirects.
+const VERSION = 5
// 2gb
const MAX_ENTRY_SIZE = 2 * 1000 * 1000 * 1000
@@ -18,6 +20,20 @@ const COMPRESS_THRESHOLD = 1024 * 1024
// node:sqlite is available.
const hasZstd = typeof zstdCompressSync === 'function' && typeof zstdDecompressSync === 'function'
+// SQLite reports a database that another connection is writing as SQLITE_BUSY
+// (5) or SQLITE_LOCKED (6).
+// https://www.sqlite.org/rescode.html#busy
+const SQLITE_BUSY = 5
+const SQLITE_LOCKED = 6
+
+/**
+ * @param {Error & { errcode?: number }} err
+ * @returns {boolean}
+ */
+function isLockedError (err) {
+ return err?.errcode === SQLITE_BUSY || err?.errcode === SQLITE_LOCKED
+}
+
/**
* @typedef {import('../../types/cache-interceptor.d.ts').default.CacheStore} CacheStore
* @implements {CacheStore}
@@ -259,7 +275,7 @@ module.exports = class SqliteCacheStore {
get (key) {
assertCacheKey(key)
- const value = this.#findValue(key)
+ const value = this.#lockSafe(() => this.#findValue(key))
return value
? {
body: value.body
@@ -307,10 +323,10 @@ module.exports = class SqliteCacheStore {
}
}
- const existingValue = this.#findValue(key, true)
+ const existingValue = this.#lockSafe(() => this.#findValue(key, true))
if (existingValue) {
// Updating an existing response, let's overwrite it
- this.#updateValueQuery.run(
+ this.#lockSafe(() => this.#updateValueQuery.run(
storedBody,
compressed,
value.deleteAt,
@@ -323,10 +339,10 @@ module.exports = class SqliteCacheStore {
value.cachedAt,
value.staleAt,
existingValue.id
- )
+ ))
} else {
// New response, let's insert it
- this.#insertValueQuery.run(
+ this.#lockSafe(() => this.#insertValueQuery.run(
url,
key.method,
storedBody,
@@ -340,7 +356,7 @@ module.exports = class SqliteCacheStore {
value.vary ? JSON.stringify(value.vary) : null,
value.cachedAt,
value.staleAt
- )
+ ))
this.#prune()
}
}
@@ -389,23 +405,53 @@ module.exports = class SqliteCacheStore {
throw new TypeError(`expected key to be object, got ${typeof key}`)
}
- this.#deleteByUrlQuery.run(this.#makeValueUrl(key))
+ this.#lockSafe(() => this.#deleteByUrlQuery.run(this.#makeValueUrl(key)))
+ }
+
+ /**
+ * Runs a database operation, skipping it when another connection holds the
+ * database lock.
+ *
+ * The cache store is meant to be shared between processes, and SQLite reports
+ * a concurrent writer as SQLITE_BUSY/SQLITE_LOCKED. Those errors are
+ * transient, and caching is best effort: the entry is skipped and the next
+ * request retries, rather than failing the request that triggered the write.
+ *
+ * @template T
+ * @param {() => T} fn
+ * @returns {T | undefined}
+ */
+ #lockSafe (fn) {
+ try {
+ return fn()
+ } catch (err) {
+ if (!isLockedError(err)) {
+ throw err
+ }
+
+ return undefined
+ }
}
#prune () {
- if (Number.isFinite(this.#maxCount) && this.size <= this.#maxCount) {
- return 0
+ if (Number.isFinite(this.#maxCount)) {
+ // A locked database cannot be measured, so skip pruning instead of
+ // failing the write that triggered it.
+ const size = this.#lockSafe(() => this.size)
+ if (size === undefined || size <= this.#maxCount) {
+ return 0
+ }
}
{
- const removed = this.#deleteExpiredValuesQuery.run(Date.now()).changes
+ const removed = this.#lockSafe(() => this.#deleteExpiredValuesQuery.run(Date.now()).changes)
if (removed) {
return removed
}
}
{
- const removed = this.#deleteOldValuesQuery?.run(Math.max(Math.floor(this.#maxCount * 0.1), 1)).changes
+ const removed = this.#lockSafe(() => this.#deleteOldValuesQuery?.run(Math.max(Math.floor(this.#maxCount * 0.1), 1)).changes)
if (removed) {
return removed
}
diff --git a/deps/undici/src/lib/dispatcher/client-h1.js b/deps/undici/src/lib/dispatcher/client-h1.js
index cbbdaaf3543..a26b9c014e8 100644
--- a/deps/undici/src/lib/dispatcher/client-h1.js
+++ b/deps/undici/src/lib/dispatcher/client-h1.js
@@ -83,7 +83,9 @@ function lazyllhttp () {
// We disable wasm SIMD on older versions of Node.js on ppc64 that are broken on Power >=9 architectures.
let useWasmSIMD = true
- if (process.arch === 'ppc64') {
+ if (process.arch === 'riscv64') {
+ useWasmSIMD = false
+ } else if (process.arch === 'ppc64') {
const [major, minor] = process.versions.node.split('.').map(n => parseInt(n, 10))
if (major < 24 || (major === 24 && minor < 12)) {
useWasmSIMD = false
diff --git a/deps/undici/src/lib/dispatcher/client-h2.js b/deps/undici/src/lib/dispatcher/client-h2.js
index f0d3a9ca38a..9857b80b1e2 100644
--- a/deps/undici/src/lib/dispatcher/client-h2.js
+++ b/deps/undici/src/lib/dispatcher/client-h2.js
@@ -47,7 +47,6 @@ const {
const { channels } = require('../core/diagnostics.js')
const kOpenStreams = Symbol('open streams')
-const kRequestStreamId = Symbol('request stream id')
const kRequestStream = Symbol('request stream')
const kRequestStreamCleanup = Symbol('request stream cleanup')
const kRequestStreamState = Symbol('request stream state')
@@ -61,6 +60,9 @@ const kRefusedStreamRetry = Symbol('refused stream retry')
// event loop.
const MAX_GOAWAY_REPLAY_ATTEMPTS = 1
+// RFC 9113 section 6.2. node:http2 reports frame types as raw numbers.
+const HTTP2_FRAME_TYPE_HEADERS = 1
+
// Lower bound on how long a liveness PING may take to be acked. Stream
// timeouts can be tiny (tens of ms), while an ack on a live connection can take
// longer than that on a loaded event loop; without a floor a slow but healthy
@@ -133,7 +135,9 @@ function getGoAwayPendingIdx (client, lastStreamID) {
continue
}
- if (typeof request[kRequestStreamId] !== 'number' || request[kRequestStreamId] > maxAcceptedStreamID) {
+ // A pending stream may not have had an ID when it was bound to the request.
+ const streamId = request[kRequestStream]?.id
+ if (typeof streamId !== 'number' || streamId > maxAcceptedStreamID) {
return i
}
}
@@ -142,7 +146,6 @@ function getGoAwayPendingIdx (client, lastStreamID) {
}
function detachRequestFromStream (request) {
- request[kRequestStreamId] = null
request[kRequestStream] = null
request[kRequestStreamCleanup] = null
}
@@ -152,7 +155,6 @@ function bindRequestToStream (request, stream, cleanup) {
const previousStream = request[kRequestStream]
detachRequestFromStream(request)
previousCleanup?.(previousStream)
- request[kRequestStreamId] = stream.id
request[kRequestStream] = stream
request[kRequestStreamCleanup] = cleanup
}
@@ -253,8 +255,14 @@ function severRequestStream (stream) {
closeStreamSession(stream)
}
+ // #5936: Node can still deliver a session-level error to a native stream
+ // after undici has stopped tracking it (e.g. on idle session teardown). Keep
+ // a persistent noop error sink so the 'error' event is never emitted with no
+ // listener, which would crash the process. A single sink is enough even if
+ // one was already attached by the onError path, so clear it first.
if (!stream.destroyed && !stream.closed) {
- stream.once('error', noop)
+ stream.off('error', noop)
+ stream.on('error', noop)
}
}
@@ -732,16 +740,18 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
}
}
- for (let i = 0; i < streamsToClose.length; i++) {
- closeStream(streamsToClose[i])
- }
-
if (pendingIdx !== previousPendingIdx) {
const remainingPendingRequests = client[kQueue].slice(previousPendingIdx)
client[kQueue].length = pendingIdx
client[kQueue].push(...retriableRequests, ...remainingPendingRequests)
}
+ // Publish the rebuilt queue and detach the session before closing anything.
+ // A synchronous sibling HEADERS frameError can requeue an accepted-but-unsent
+ // request and resume the client. It must see the final indices and must not
+ // dispatch its retry on the session that is being retired.
+ client[kPendingIdx] = pendingIdx
+
if (client[kHTTP2Session] === this) {
client[kSocket] = null
client[kHTTPContext] = null
@@ -756,12 +766,14 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) {
clearNoStreamsTimeout(this)
clearHttp2ProbeTimeout(this)
+ for (let i = 0; i < streamsToClose.length; i++) {
+ closeStream(streamsToClose[i])
+ }
+
if (!this.closed && !this.destroyed) {
this.close()
}
- client[kPendingIdx] = pendingIdx
-
client.emit('disconnect', client[kUrl], [client], err)
client[kResume]()
@@ -868,8 +880,8 @@ function closeStreamSession (stream) {
}
function onUpgradeStreamClose () {
- this.off('error', noop)
-
+ // #5936: Retain the noop error sink so a session-level error delivered to
+ // this stream after undici detaches it cannot crash via an unhandled 'error'.
const state = this[kRequestStreamState]
this[kRequestStreamState] = null
@@ -973,8 +985,11 @@ function releaseUpgradeStream (stream) {
removeUpgradeStreamListeners(stream)
+ // #5936: removeUpgradeStreamListeners() dropped the noop sink; re-attach a
+ // persistent one so a late session-level error on this (possibly still native
+ // tracked) stream cannot crash via an unhandled 'error'.
if (!stream.destroyed && !stream.closed) {
- stream.once('error', noop)
+ stream.on('error', noop)
}
}
@@ -1407,12 +1422,14 @@ function releaseRequestStream (stream) {
detachRequestFromStream(request)
}
- // A closed or destroyed stream cannot emit further events; leaving the
- // listeners in place saves the removal scans (they are collected with
- // the stream). All handlers bail out when the stream state is gone.
+ // #5936: Node can deliver a session-level error to a released stream before
+ // its native handle is dropped (e.g. on idle session teardown). Attach a
+ // persistent noop error sink so a late 'error' always has a listener and
+ // cannot crash the process. A closed/destroyed stream cannot emit further
+ // events and is no longer tracked natively, so skip the listener teardown.
if (!stream.destroyed && !stream.closed) {
removeRequestStreamListeners(stream)
- stream.once('error', noop)
+ stream.on('error', noop)
}
}
@@ -1607,10 +1624,43 @@ function retryRefusedStream (stream, state) {
// SHOULD NOT automatically retry the same request more than once.
request[kRefusedStreamRetry] = true
+ // The peer only reset this stream, so the HTTP/2 session remains usable for
+ // the retry.
+ requeueStreamRequest(client, state)
+
+ return true
+}
+
+// nghttp2 refuses to send the HEADERS of a stream opened before a GOAWAY was
+// received but flushed after it. The peer never saw the request, so it can be
+// replayed on a new connection whatever its method. A GOAWAY whose
+// lastStreamID covers the stream (e.g. 2^31-1, a graceful shutdown notice)
+// leaves the stream attached to the session, so this is the only signal.
+function retryUnsentStream (state) {
+ const { client, request, session } = state
+
+ if (
+ !session[kReceivedGoAway] ||
+ state.responseReceived ||
+ request.aborted ||
+ request.completed ||
+ !canReplayRequest(request) ||
+ !registerGoAwayRefusal(request)
+ ) {
+ return false
+ }
+
+ requeueStreamRequest(client, state)
+
+ return true
+}
+
+function requeueStreamRequest (client, state) {
+ const { request } = state
+
// Detach the failed attempt before moving the request back to the pending
- // queue. The peer only reset this stream, so the HTTP/2 session remains
- // usable for the retry. Severing also drops the 'close' listener, so the
- // abandoned stream cannot later complete the retried request.
+ // queue. Severing also drops the 'close' listener, so the abandoned stream
+ // cannot later complete the retried request.
detachRequestStreamForClose(request)
state.stream = null
state.requestFinalized = true
@@ -1618,8 +1668,6 @@ function retryRefusedStream (stream, state) {
completeRequest(client, request)
client[kQueue].splice(client[kPendingIdx], 0, request)
client[kResume]()
-
- return true
}
function onError (err) {
@@ -1631,6 +1679,10 @@ function onError (err) {
}
stream.off('error', onError)
+ // #5936: The stream has already errored, but Node may destroy it again with a
+ // session-level error before the native handle is released. Keep a persistent
+ // noop error sink so that re-delivery cannot go unhandled.
+ stream.on('error', noop)
// Node's HTTP/2 implementation can turn an incomplete Content-Length body
// into a protocol stream error instead of emitting 'end'. Prefer the
@@ -1662,6 +1714,11 @@ function onFrameError (type, code) {
}
stream.off('frameError', onFrameError)
+
+ if (type === HTTP2_FRAME_TYPE_HEADERS && retryUnsentStream(state)) {
+ return
+ }
+
state.abort(new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`))
}
diff --git a/deps/undici/src/lib/dispatcher/parser-h1.js b/deps/undici/src/lib/dispatcher/parser-h1.js
index 263d67e7763..c1440170fd2 100644
--- a/deps/undici/src/lib/dispatcher/parser-h1.js
+++ b/deps/undici/src/lib/dispatcher/parser-h1.js
@@ -52,7 +52,8 @@ const EVENT_HEADERS_SIZE = 19
let miloPackage
// We disable wasm SIMD on ppc64 as it seems to be broken on Power 9 architectures.
-let useWasmSIMD = process.arch !== 'ppc64'
+// We disable wasm SIMD on riscv64 as it seems to be broken on there as well.
+let useWasmSIMD = (process.arch !== 'riscv64' && process.arch !== 'ppc64')
// The Env Variable UNDICI_NO_WASM_SIMD allows explicitly overriding the default behavior.
if (process.env.UNDICI_NO_WASM_SIMD === '1') {
useWasmSIMD = false
diff --git a/deps/undici/src/lib/handler/cache-handler.js b/deps/undici/src/lib/handler/cache-handler.js
index fed01a1aa0c..20c96def267 100644
--- a/deps/undici/src/lib/handler/cache-handler.js
+++ b/deps/undici/src/lib/handler/cache-handler.js
@@ -10,6 +10,17 @@ const {
isInvalidOrWildcardVaryHeader,
isEtagUsable
} = require('../util/cache')
+
+/**
+ * A suppressed redirect hop recorded by the redirect handler. Carried on
+ * {@link kRedirectCacheControl} and inspected by the cache handler before the
+ * final response is stored under the original request key.
+ *
+ * @typedef {object} RedirectHop
+ * @property {number} statusCode The hop's response status code (e.g. 302).
+ * @property {import('../../types/header.d.ts').IncomingHttpHeaders} headers The hop's response headers.
+ * @property {number} receivedAt The time the hop's response headers were received.
+ */
const { parseHttpDate } = require('../util/date.js')
function noop () {}
@@ -105,6 +116,16 @@ function arrayIncludes (array, value) {
return false
}
+function appendVaryValues (target, varyValue) {
+ if (Array.isArray(varyValue)) {
+ for (let i = 0; i < varyValue.length; i++) {
+ target.push(varyValue[i])
+ }
+ } else if (varyValue != null) {
+ target.push(varyValue)
+ }
+}
+
function appendConnectionHeaderTokens (headersToRemove, connectionHeader) {
const values = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader]
@@ -176,6 +197,26 @@ function invalidateUnsafeRequest (store, cacheKey, resHeaders) {
deleteLocationTargets(store, cacheKey, resHeaders.location)
deleteLocationTargets(store, cacheKey, resHeaders['content-location'])
}
+
+/**
+ * Whether the original request carried a credential that is not reflected in
+ * the cache key. The memory/sqlite stores key an entry on origin, method, path
+ * and `vary` only, so a credentialed request that is redirected to a resource
+ * that varies per principal must not be stored under the original (alias) key:
+ * a later caller would be served the first principal's body. The set mirrors the
+ * request credentials the redirect handler strips across origins (`authorization`,
+ * `cookie`, `proxy-authorization`).
+ *
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders | undefined} headers
+ * @returns {boolean}
+ */
+function requestCarriesCredentials (headers) {
+ return headers != null && (
+ Object.hasOwn(headers, 'authorization') ||
+ Object.hasOwn(headers, 'cookie') ||
+ Object.hasOwn(headers, 'proxy-authorization')
+ )
+}
/**
* @typedef {import('../../types/dispatcher.d.ts').default.DispatchHandler} DispatchHandler
*
@@ -220,7 +261,7 @@ class CacheHandler {
/**
* The URLs visited while following redirects, shared by reference from the
* redirect handler through the request context. Carries the suppressed hops'
- * cache-control under {@link kRedirectCacheControl}.
+ * status/headers under {@link kRedirectCacheControl}.
* @type {URL[] | undefined}
*/
#history
@@ -296,35 +337,62 @@ class CacheHandler {
return downstreamOnHeaders()
}
+ // A credential-bearing request that was redirected must not have the final
+ // hop's response stored under the original request's key. The cache key
+ // does not include the credential, and the redirect target may vary per
+ // principal (e.g. `/me` -> `/profile/{user}`), so replaying the body under
+ // the alias key would leak one principal's data to another — or to an
+ // unauthenticated caller — from a shared cache. The final hop's permissive
+ // cache-control (e.g. `public`) does not authorize this: the credential was
+ // presented to the original hop, not the resource that actually produced
+ // the body. Fail closed. A history with more than one entry means at least
+ // one redirect was actually followed (a single non-redirected hop records
+ // exactly one entry), so a plain revalidation is not mistaken for a redirect.
+ if (
+ this.#history && this.#history.length > 1 &&
+ requestCarriesCredentials(this.#cacheKey.headers)
+ ) {
+ return downstreamOnHeaders()
+ }
+
// A cross-origin redirect can change the origin that actually produced the
// final response. Keying that response on the original (trusted) origin
// would let a redirect target control an entry for a URL the application
- // trusts. The final request is the last URL in the redirect history; only
- // cache when its origin still matches the cache key origin.
+ // trusts. Every hop in the redirect chain must stay on the cache key's
+ // origin. An intermediate cross-origin hop can bounce back to the trusted
+ // origin (or to any cacheable asset there) even when the final response
+ // comes from the trusted origin, so checking only the final URL is not
+ // enough.
if (this.#history && this.#history.length > 0) {
- const finalUrl = this.#history[this.#history.length - 1]
- if (!sameOrigin(finalUrl.origin, this.#cacheKey.origin)) {
- return downstreamOnHeaders()
- }
- }
-
- // A redirect hop that is not forwarded can still carry a restrictive
- // storage policy (for example `no-store`, or `private` for a shared cache).
- // Honor it, otherwise a redirect that must not be stored could be laundered
- // into a cacheable final response under the original key.
- const redirectCacheControl = this.#history?.[kRedirectCacheControl]
- if (redirectCacheControl) {
- for (const hopCacheControl of redirectCacheControl) {
- const hopDirectives = hopCacheControl ? parseCacheControlHeader(hopCacheControl) : {}
- if (
- hopDirectives['no-store'] === true ||
- (this.#cacheType === 'shared' && hopDirectives.private === true)
- ) {
+ for (const hopUrl of this.#history) {
+ if (!sameOrigin(hopUrl.origin, this.#cacheKey.origin)) {
return downstreamOnHeaders()
}
}
}
+ // A redirect hop that is not forwarded can still carry a storage policy
+ // (for example `no-store`, `private` for a shared cache, `no-cache`,
+ // `must-revalidate`, zero freshness, or `Vary: *`) that the final response
+ // body does not inherit. Honor it, otherwise a redirect that must not be
+ // stored (or that selects a private/authenticated target) could be
+ // laundered into a cacheable final response under the original key.
+ const now = Date.now()
+ const redirectHopInfo = this.#history?.[kRedirectCacheControl]
+ const redirectPolicy = redirectHopInfo?.length > 0
+ ? getRedirectPolicy(
+ this.#cacheType,
+ this.#cacheKey.method,
+ redirectHopInfo,
+ this.#cacheKey.headers,
+ this.#cacheByDefault,
+ now
+ )
+ : undefined
+ if (redirectHopInfo?.length > 0 && redirectPolicy === undefined) {
+ return downstreamOnHeaders()
+ }
+
const cacheControlHeader = resHeaders['cache-control']
const cacheControlDirectives = cacheControlHeader ? parseCacheControlHeader(cacheControlHeader) : {}
@@ -357,7 +425,6 @@ class CacheHandler {
return downstreamOnHeaders()
}
- const now = Date.now()
const resAge = Object.hasOwn(resHeaders, 'age') ? getAge(resHeaders.age) : undefined
if (resAge !== undefined && resAge >= MAX_RESPONSE_AGE) {
// Response considered stale
@@ -394,24 +461,65 @@ class CacheHandler {
}
const baseTime = now - currentAge
- const absoluteStaleAt = staleAt + baseTime
+ let absoluteStaleAt = staleAt + baseTime
+ if (redirectPolicy !== undefined) {
+ absoluteStaleAt = Math.min(absoluteStaleAt, redirectPolicy.staleAt)
+ }
if (now >= absoluteStaleAt && !revalidationOnly) {
// Response is already stale
deleteCachedValueIfNotModified(statusCode, this.#store, this.#cacheKey)
return downstreamOnHeaders()
}
+ // Union the `Vary` of every suppressed redirect hop with the final
+ // response's `Vary`. A 3xx hop that negotiates its target on `Authorization`
+ // / `Cookie` (or any other header) must make the stored entry vary on those
+ // headers too, or an authenticated user's private response would be served
+ // to unrelated callers.
let varyDirectives
- if (this.#cacheKey.headers && resHeaders.vary) {
- varyDirectives = parseVaryHeader(resHeaders.vary, this.#cacheKey.headers)
- if (!varyDirectives) {
- // Parse error
- return downstreamOnHeaders()
+ const combinedVary = []
+ if (redirectHopInfo) {
+ for (const hop of redirectHopInfo) {
+ appendVaryValues(combinedVary, hop?.headers?.vary)
+ }
+ }
+ appendVaryValues(combinedVary, resHeaders.vary)
+ if (combinedVary.length > 0) {
+ for (const varyHeader of combinedVary) {
+ if (hasVaryStar(varyHeader)) {
+ // `Vary: *` means the response can never be reused (RFC 9111 §4.1)
+ return downstreamOnHeaders()
+ }
+ }
+ if (this.#cacheKey.headers) {
+ varyDirectives = parseVaryHeader(combinedVary, this.#cacheKey.headers)
+ if (!varyDirectives) {
+ // Parse error
+ return downstreamOnHeaders()
+ }
+ }
+ }
+
+ // When the entry was folded from a redirect chain, the final response's
+ // stale-serving directives (RFC 5861) must not outlive the hops' freshness.
+ // A hop that grants no `stale-while-revalidate` / `stale-if-error` caps
+ // stale serving at its freshness, so the final response cannot launder a
+ // stale-serving window past the hop-bounded `staleAt`. Drop those directives
+ // (and keep the `proxy-revalidate` the hop policy may require) so the entry
+ // revalidates at `staleAt` instead of being served stale for the final
+ // response's window.
+ let effectiveCacheControlDirectives = cacheControlDirectives
+ if (redirectPolicy !== undefined) {
+ effectiveCacheControlDirectives = { ...cacheControlDirectives }
+ delete effectiveCacheControlDirectives['stale-while-revalidate']
+ delete effectiveCacheControlDirectives['stale-if-error']
+ if (redirectPolicy.requiresRevalidation === true) {
+ effectiveCacheControlDirectives['proxy-revalidate'] = true
}
}
const cachedAt = baseTime
- const deleteAt = determineDeleteAt(baseTime, now, cacheControlDirectives, absoluteStaleAt)
+ const deleteAt = determineDeleteAt(baseTime, now, effectiveCacheControlDirectives, absoluteStaleAt)
const strippedHeaders = stripNecessaryHeaders(resHeaders, cacheControlDirectives)
/**
@@ -422,7 +530,7 @@ class CacheHandler {
statusMessage,
headers: strippedHeaders,
vary: varyDirectives,
- cacheControlDirectives,
+ cacheControlDirectives: effectiveCacheControlDirectives,
cachedAt,
staleAt: absoluteStaleAt,
deleteAt
@@ -440,6 +548,22 @@ class CacheHandler {
this.#downstreamController.release()
}
+ // The cached body below is replayed to the downstream handler from later
+ // stream ticks (or an iterator loop), outside the dispatcher's own error
+ // handling, so a throwing downstream handler must be converged to a
+ // per-request error instead of escaping as an uncaught exception.
+ const failReplay = (err) => {
+ if (done) {
+ return
+ }
+ done = true
+ // Abort the revalidation request: this releases the paused origin
+ // response (freeing the connection) and routes the error through
+ // CacheHandler.onResponseError, which destroys the cache write stream
+ // and forwards the error downstream exactly once.
+ controller.abort(err)
+ }
+
const handle304 = (cachedValue) => {
if (!cachedValue) {
// Do not create a new cache entry, as a 304 won't have a body - so cannot be cached.
@@ -471,15 +595,19 @@ class CacheHandler {
if (done) {
return
}
- for (const chunk of bodyIterator) {
- const full = this.#writeStream?.write(chunk) === false
- this.#handler.onResponseData?.(this.#downstreamController, chunk)
- // when stream is full stop writing until we get a 'drain' event
- if (full) {
- return
+ try {
+ for (const chunk of bodyIterator) {
+ const full = this.#writeStream?.write(chunk) === false
+ this.#handler.onResponseData?.(this.#downstreamController, chunk)
+ // when stream is full stop writing until we get a 'drain' event
+ if (full) {
+ return
+ }
}
+ finish304()
+ } catch (err) {
+ failReplay(err)
}
- finish304()
}
this.#writeStream
@@ -505,8 +633,13 @@ class CacheHandler {
// Readable stream body (e.g. from async/remote cache stores)
cachedValue.body
.on('data', (chunk) => {
- this.#writeStream?.write(chunk)
- this.#handler.onResponseData?.(this.#downstreamController, chunk)
+ try {
+ this.#writeStream?.write(chunk)
+ this.#handler.onResponseData?.(this.#downstreamController, chunk)
+ } catch (err) {
+ cachedValue.body.destroy()
+ failReplay(err)
+ }
})
.on('end', () => {
this.#writeStream?.end()
@@ -713,6 +846,139 @@ function canCacheResponse (cacheType, method, statusCode, resHeaders, cacheContr
return true
}
+/**
+ * Determine the absolute freshness deadline imposed by all suppressed redirect
+ * hops. Returning `undefined` means at least one hop cannot safely be folded
+ * into the final response under the original request key.
+ *
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
+ * @param {string} method
+ * @param {RedirectHop[]} redirectHops
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} reqHeaders
+ * @param {number | undefined} cacheByDefault
+ * @param {number} now
+ * @returns {{ staleAt: number, requiresRevalidation: boolean } | undefined}
+ */
+function getRedirectPolicy (cacheType, method, redirectHops, reqHeaders, cacheByDefault, now) {
+ let staleAt = Infinity
+ let requiresRevalidation = false
+
+ for (const hop of redirectHops) {
+ const hopPolicy = getRedirectHopPolicy(cacheType, method, hop, reqHeaders, cacheByDefault)
+ if (hopPolicy === undefined || hopPolicy.staleAt <= now) {
+ return undefined
+ }
+
+ staleAt = Math.min(staleAt, hopPolicy.staleAt)
+ requiresRevalidation ||= hopPolicy.requiresRevalidation
+ }
+
+ return { staleAt, requiresRevalidation }
+}
+
+/**
+ * Calculate one redirect hop's absolute freshness deadline using the same age,
+ * date, expiration, invalid-directive, and heuristic rules as a final response.
+ *
+ * @param {import('../../types/cache-interceptor.d.ts').default.CacheOptions['type']} cacheType
+ * @param {string} method
+ * @param {RedirectHop} hop
+ * @param {import('../../types/header.d.ts').IncomingHttpHeaders} reqHeaders
+ * @param {number | undefined} cacheByDefault
+ * @returns {{ staleAt: number, requiresRevalidation: boolean } | undefined}
+ */
+function getRedirectHopPolicy (cacheType, method, hop, reqHeaders, cacheByDefault) {
+ if (!Number.isInteger(hop?.statusCode) || !Number.isFinite(hop?.receivedAt)) {
+ return undefined
+ }
+
+ const hopHeaders = hop.headers ?? {}
+ const cacheControlHeader = hopHeaders['cache-control']
+ const hopCacheControl = cacheControlHeader
+ ? parseCacheControlHeader(cacheControlHeader)
+ : {}
+
+ // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1-5
+ if (hopHeaders.vary && hasVaryStar(hopHeaders.vary)) {
+ return undefined
+ }
+
+ if (hopCacheControl['no-store'] === true) {
+ return undefined
+ }
+
+ // A qualified `private` directive still marks the named fields as private.
+ // Folding a redirect that says `private="location"` would preserve the
+ // private target choice in the final body even though a shared cache is not
+ // allowed to store that Location field.
+ if (cacheType === 'shared' && Object.hasOwn(hopCacheControl, 'private')) {
+ return undefined
+ }
+
+ // These directives require the hop to be revalidated before reuse. Since the
+ // cache stores only the final response, it cannot independently validate the
+ // suppressed redirect response.
+ if (
+ Object.hasOwn(hopCacheControl, 'no-cache') ||
+ hopCacheControl['must-revalidate'] === true ||
+ (cacheType === 'shared' && hopCacheControl['proxy-revalidate'] === true)
+ ) {
+ return undefined
+ }
+
+ const heuristicallyCacheable =
+ hopHeaders['last-modified'] &&
+ arrayIncludes(HEURISTICALLY_CACHEABLE_STATUS_CODES, hop.statusCode)
+ if (
+ !cacheControlHeader &&
+ !hopHeaders.expires &&
+ !heuristicallyCacheable &&
+ !cacheByDefault
+ ) {
+ return undefined
+ }
+
+ if (!canCacheResponse(cacheType, method, hop.statusCode, hopHeaders, hopCacheControl, reqHeaders)) {
+ return undefined
+ }
+
+ const age = Object.hasOwn(hopHeaders, 'age') ? getAge(hopHeaders.age) : undefined
+ if (age !== undefined && age >= MAX_RESPONSE_AGE) {
+ return undefined
+ }
+
+ const responseDate = Object.hasOwn(hopHeaders, 'date') ? getDate(hopHeaders.date) : undefined
+ if (responseDate === null) {
+ return undefined
+ }
+
+ const apparentAge = responseDate ? Math.max(0, hop.receivedAt - responseDate.getTime()) : 0
+ const currentAge = Math.max(apparentAge, age ?? 0)
+ const hasValidator =
+ (typeof hopHeaders.etag === 'string' && isEtagUsable(hopHeaders.etag)) ||
+ typeof hopHeaders['last-modified'] === 'string'
+ const freshnessLifetime =
+ determineStaleAt(
+ cacheType,
+ hop.receivedAt,
+ age,
+ hopHeaders,
+ responseDate,
+ hopCacheControl,
+ hasValidator
+ ) ?? cacheByDefault
+
+ if (freshnessLifetime === undefined) {
+ return undefined
+ }
+
+ return {
+ staleAt: hop.receivedAt - currentAge + freshnessLifetime,
+ // RFC 9111 §5.2.2.10: s-maxage implies proxy-revalidate for shared caches.
+ requiresRevalidation: cacheType === 'shared' && Object.hasOwn(hopCacheControl, 's-maxage')
+ }
+}
+
/**
* @param {string | string[]} dateHeader
* @returns {Date | null | undefined}
diff --git a/deps/undici/src/lib/handler/decorator-handler.js b/deps/undici/src/lib/handler/decorator-handler.js
index bc99893cda0..136685f91e8 100644
--- a/deps/undici/src/lib/handler/decorator-handler.js
+++ b/deps/undici/src/lib/handler/decorator-handler.js
@@ -32,9 +32,13 @@ module.exports = class DecoratorHandler {
onResponseStart (...args) {
assert(!this.#onCompleteCalled)
assert(!this.#onErrorCalled)
- assert(!this.#onResponseStartCalled)
- this.#onResponseStartCalled = true
+ // Interim 1xx responses (e.g. 103 Early Hints) come before the final one.
+ const statusCode = args[1]
+ if (statusCode >= 200) {
+ assert(!this.#onResponseStartCalled)
+ this.#onResponseStartCalled = true
+ }
return this.#handler.onResponseStart?.(...args)
}
diff --git a/deps/undici/src/lib/handler/redirect-handler.js b/deps/undici/src/lib/handler/redirect-handler.js
index 604be127637..a1459cc32c1 100644
--- a/deps/undici/src/lib/handler/redirect-handler.js
+++ b/deps/undici/src/lib/handler/redirect-handler.js
@@ -38,11 +38,12 @@ class RedirectHandler {
this.maxRedirections = maxRedirections
this.handler = handler
this.history = []
- // `cache-control` of each intermediate (suppressed) redirect hop. An outer
- // cache uses it to preserve a hop's storage policy (e.g. `no-store`) that
- // would otherwise be lost because the hop's response is not forwarded. It
- // travels as a non-enumerable property of the history array so it reaches
- // the cache without becoming part of the public request/response context.
+ // Per-hop metadata for each intermediate (suppressed) redirect hop: the
+ // hop's status code and response headers. An outer cache uses it to honor a
+ // hop's storage policy (e.g. `no-store`, `no-cache`, `Vary: *`) that would
+ // otherwise be lost because the hop's response is not forwarded. It travels
+ // as a non-enumerable property of the history array so it reaches the cache
+ // without becoming part of the public request/response context.
Object.defineProperty(this.history, kRedirectCacheControl, {
value: [],
enumerable: false
@@ -124,9 +125,13 @@ class RedirectHandler {
}
// This hop's response is suppressed (only its Location is used). Record its
- // cache-control so an outer cache can honor a restrictive policy that would
- // otherwise be lost when the final response is stored.
- this.history[kRedirectCacheControl].push(headers['cache-control'])
+ // status and headers so an outer cache can honor a restrictive policy that
+ // would otherwise be lost when the final response is stored.
+ this.history[kRedirectCacheControl].push({
+ statusCode,
+ headers: { ...headers },
+ receivedAt: Date.now()
+ })
const baseUrl = requestOrigin
? new URL(this.opts.path, requestOrigin)
@@ -241,9 +246,25 @@ function normalizeStripHeaders (headers, optionName) {
function cleanRequestHeaders (headers, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin) {
const ret = []
if (Array.isArray(headers)) {
- for (let i = 0; i < headers.length; i += 2) {
- if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
- ret.push(headers[i], headers[i + 1])
+ // Headers may be either a flat alternating [name, value, name, value, ...]
+ // list or an iterable of [name, value] pairs. A plain JavaScript array of
+ // tuples (e.g. `Object.entries(headers)`) satisfies the iterable-of-pairs
+ // public type, so we must distinguish the two representations before
+ // iterating. Treating every Array as a flat list misreads each tuple as a
+ // header name/value pair and defeats the cross-origin credential stripping.
+ if (headers.length > 0 && Array.isArray(headers[0])) {
+ // Array of [name, value] pairs.
+ for (const [key, value] of headers) {
+ if (!shouldRemoveHeader(key, removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
+ ret.push(key, value)
+ }
+ }
+ } else {
+ // Flat alternating [name, value, name, value, ...] list.
+ for (let i = 0; i < headers.length; i += 2) {
+ if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin, stripHeaders, stripHeadersOnCrossOrigin)) {
+ ret.push(headers[i], headers[i + 1])
+ }
}
}
} else if (headers && typeof headers === 'object') {
diff --git a/deps/undici/src/lib/interceptor/cache.js b/deps/undici/src/lib/interceptor/cache.js
index b16c8994338..60d2522d1ff 100644
--- a/deps/undici/src/lib/interceptor/cache.js
+++ b/deps/undici/src/lib/interceptor/cache.js
@@ -14,7 +14,8 @@ const {
normalizeHeaders,
parseCacheControlHeader,
isInvalidOrWildcardVaryHeader,
- parseVaryHeader
+ parseVaryHeader,
+ isEtagUsable
} = require('../util/cache.js')
const { AbortError } = require('../core/errors.js')
const { parseHttpDate } = require('../util/date.js')
@@ -202,6 +203,47 @@ function makeRevalidationHeaders (opts, result) {
return headers
}
+function revalidationTargetMatchesCacheKey (cacheKey, context) {
+ const history = context?.history
+ if (!Array.isArray(history) || history.length === 0) {
+ return true
+ }
+
+ try {
+ const cachedUrl = new URL(cacheKey.path, cacheKey.origin)
+ const responseUrl = history[history.length - 1]
+ return responseUrl.origin === cachedUrl.origin &&
+ responseUrl.pathname === cachedUrl.pathname &&
+ responseUrl.search === cachedUrl.search
+ } catch {
+ return false
+ }
+}
+
+function revalidationEtagMatchesCachedValue (result, headers) {
+ if (!Object.hasOwn(headers, 'etag')) {
+ return true
+ }
+
+ const responseEtag = headers.etag
+ const cachedEtag = result.etag
+ if (
+ typeof responseEtag !== 'string' ||
+ typeof cachedEtag !== 'string' ||
+ !isEtagUsable(responseEtag) ||
+ !isEtagUsable(cachedEtag)
+ ) {
+ return false
+ }
+
+ if (!responseEtag.startsWith('W/')) {
+ return responseEtag === cachedEtag
+ }
+
+ const cachedOpaqueTag = cachedEtag.startsWith('W/') ? cachedEtag.slice(2) : cachedEtag
+ return responseEtag.slice(2) === cachedOpaqueTag
+}
+
/**
* @param {import('../../types/cache-interceptor.d.ts').default.GetResult} result
* @param {import('../../types/cache-interceptor.d.ts').default.CacheControlDirectives | undefined} cacheControlDirectives
@@ -366,21 +408,35 @@ function sendCachedValue (handler, opts, result, age, context, isStale) {
stream
.on('error', function (err) {
+ // Never rethrow: this runs from a stream event on a later tick, where a
+ // throw would crash the process. Like core's Request.onResponseError,
+ // the error is dropped if the handler has no onResponseError.
if (!this.readableEnded) {
- if (typeof handler.onResponseError === 'function') {
- handler.onResponseError(controller, err)
- } else {
- throw err
- }
+ handler.onResponseError?.(controller, err)
}
})
.on('close', function () {
if (!this.errored) {
- handler.onResponseEnd?.(controller, {})
+ try {
+ handler.onResponseEnd?.(controller, {})
+ } catch (err) {
+ // The stream is already closed, so the error can't be routed
+ // through it; report it to the handler directly instead.
+ handler.onResponseError?.(controller, err)
+ }
}
})
- handler.onRequestStart?.(controller, context)
+ // Downstream handler calls below run outside the dispatcher's own error
+ // handling (on later ticks for the body, or inside a promise callback for
+ // async stores), so a throw must be converged to a per-request error
+ // instead of escaping as an uncaught exception.
+ try {
+ handler.onRequestStart?.(controller, context)
+ } catch (err) {
+ stream.destroy(err)
+ return
+ }
if (stream.destroyed) {
return
@@ -398,13 +454,22 @@ function sendCachedValue (handler, opts, result, age, context, isStale) {
controller.rawHeaders = util.toRawHeaders(headers)
- handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage)
+ try {
+ handler.onResponseStart?.(controller, result.statusCode, headers, result.statusMessage)
+ } catch (err) {
+ stream.destroy(err)
+ return
+ }
if (opts.method === 'HEAD') {
stream.destroy()
} else {
stream.on('data', function (chunk) {
- handler.onResponseData?.(controller, chunk)
+ try {
+ handler.onResponseData?.(controller, chunk)
+ } catch (err) {
+ stream.destroy(err)
+ }
})
}
}
@@ -502,6 +567,14 @@ function handleResult (
}
const headers = makeRevalidationHeaders(opts, result)
+ const refetch = () => {
+ if (util.isStream(result.body)) {
+ result.body.on('error', nop).destroy()
+ }
+
+ deleteCachedValue(globalOpts.store, cacheKey)
+ return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
+ }
// We need to revalidate the response
return dispatch(
@@ -513,24 +586,18 @@ function handleResult (
(success, context, statusCode, headers) => {
if (success) {
if (statusCode === 304) {
- if (revalidationResponseDisallowsCachedReuse(globalOpts.type, headers)) {
- if (util.isStream(result.body)) {
- result.body.on('error', nop).destroy()
- }
-
- deleteCachedValue(globalOpts.store, cacheKey)
- return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
+ if (
+ !revalidationTargetMatchesCacheKey(cacheKey, context) ||
+ !revalidationEtagMatchesCachedValue(result, headers) ||
+ revalidationResponseDisallowsCachedReuse(globalOpts.type, headers)
+ ) {
+ return refetch()
}
if (revalidationResponseUpdatesCacheControl(headers)) {
deleteCachedValue(globalOpts.store, cacheKey)
} else if (revalidationResponseAddsVary(result, headers.vary ? parseVaryHeader(headers.vary, opts.headers) : undefined)) {
- if (util.isStream(result.body)) {
- result.body.on('error', nop).destroy()
- }
-
- deleteCachedValue(globalOpts.store, cacheKey)
- return dispatch(opts, new CacheHandler(globalOpts, cacheKey, handler))
+ return refetch()
}
}
@@ -666,6 +733,28 @@ module.exports = (opts = {}) => {
reqCacheControl,
result
))
+ .catch((err) => {
+ // Any throw escaping handleResult on the async-store path (e.g. a
+ // rejection from store.get() itself, or a throw in a code path not
+ // converged by sendCachedValue) would otherwise surface as an
+ // unhandled promise rejection and crash the process. Route it to
+ // the handler instead, matching the synchronous store path where
+ // the error propagates to the request() call site.
+ if (typeof handler.onResponseError === 'function') {
+ handler.onResponseError({
+ rawHeaders: [],
+ rawTrailers: [],
+ pause () {},
+ resume () {},
+ get paused () { return false },
+ get aborted () { return false },
+ get reason () { return null },
+ abort () {}
+ }, err)
+ } else {
+ throw err
+ }
+ })
} else {
return handleResult(
dispatch,
diff --git a/deps/undici/src/lib/mock/mock-utils.js b/deps/undici/src/lib/mock/mock-utils.js
index 00c1a0bbe13..cf53e3ad5b0 100644
--- a/deps/undici/src/lib/mock/mock-utils.js
+++ b/deps/undici/src/lib/mock/mock-utils.js
@@ -240,6 +240,13 @@ function removeTrailingSlash (path) {
return path
}
+ // Only strip slashes from the pathname, not from a query string after it
+ const queryIndex = path.indexOf('?')
+ const search = queryIndex === -1 ? '' : path.slice(queryIndex)
+ if (queryIndex !== -1) {
+ path = path.slice(0, queryIndex)
+ }
+
while (path.endsWith('/')) {
path = path.slice(0, -1)
}
@@ -248,7 +255,7 @@ function removeTrailingSlash (path) {
path = '/'
}
- return path
+ return path + search
}
function buildKey (opts) {
@@ -379,6 +386,9 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler, re
paused: false,
rawHeaders: null,
rawTrailers: null,
+ get aborted () {
+ return aborted
+ },
pause () {
this.paused = true
},
diff --git a/deps/undici/src/lib/web/fetch/body.js b/deps/undici/src/lib/web/fetch/body.js
index 28124d018c0..b4ea3cbfe41 100644
--- a/deps/undici/src/lib/web/fetch/body.js
+++ b/deps/undici/src/lib/web/fetch/body.js
@@ -229,16 +229,36 @@ function extractBody (object, keepalive = false) {
)
}
- stream = webidl.is.ReadableStream(object)
- ? object
- : ReadableStream.from(object).pipeThrough(new TransformStream({
- transform (chunk, controller) {
- const bytes = isUint8Array(chunk) ? chunk : Buffer.from(chunk)
- if (bytes.byteLength) {
- controller.enqueue(bytes)
+ if (webidl.is.ReadableStream(object)) {
+ stream = object
+ } else {
+ const reader = ReadableStream.from(object).getReader()
+ let cancelled = false
+ // Do not pull from the iterable until the body is read.
+ stream = new ReadableStream({
+ async pull (controller) {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (cancelled) {
+ return
+ }
+ if (done) {
+ controller.close()
+ return
+ }
+ const bytes = isUint8Array(value) ? value : Buffer.from(value)
+ if (bytes.byteLength) {
+ controller.enqueue(bytes)
+ return
+ }
}
+ },
+ cancel (reason) {
+ cancelled = true
+ return reader.cancel(reason)
}
- }))
+ }, { highWaterMark: 0 })
+ }
}
// 18. If source is a byte sequence, then set action to a
diff --git a/deps/undici/src/lib/web/fetch/index.js b/deps/undici/src/lib/web/fetch/index.js
index a9382ca325b..b90f6c8d866 100644
--- a/deps/undici/src/lib/web/fetch/index.js
+++ b/deps/undici/src/lib/web/fetch/index.js
@@ -290,10 +290,7 @@ function fetch (input, init = undefined) {
cleanupAbortListeners()
},
processResponse,
- dispatcher: getRequestDispatcher(requestObject), // undici
- // Keep requestObject alive to prevent its AbortController from being GC'd
- // See https://github.com/nodejs/undici/issues/4627
- requestObject
+ dispatcher: getRequestDispatcher(requestObject) // undici
})
// 14. Return p.
@@ -412,8 +409,7 @@ function fetching ({
processResponseEndOfBody,
processResponseConsumeBody,
useParallelQueue = false,
- dispatcher = getGlobalDispatcher(), // undici
- requestObject = null // Keep alive to prevent AbortController GC, see #4627
+ dispatcher = getGlobalDispatcher() // undici
}) {
// Ensure that the dispatcher is set accordingly
assert(dispatcher)
@@ -467,9 +463,7 @@ function fetching ({
processResponseConsumeBody,
processResponseEndOfBody,
taskDestination,
- crossOriginIsolatedCapability,
- // Keep requestObject alive to prevent its AbortController from being GC'd
- requestObject
+ crossOriginIsolatedCapability
}
// 7. If request’s body is a byte sequence, then set request’s body to
diff --git a/deps/undici/src/lib/web/fetch/request.js b/deps/undici/src/lib/web/fetch/request.js
index 66f8150cb82..cce8b8e323e 100644
--- a/deps/undici/src/lib/web/fetch/request.js
+++ b/deps/undici/src/lib/web/fetch/request.js
@@ -26,60 +26,98 @@ const { webidl } = require('../webidl')
const { URLSerializer } = require('./data-url')
const { kConstruct } = require('../../core/symbols')
const assert = require('node:assert')
-const { getMaxListeners, setMaxListeners, defaultMaxListeners } = require('node:events')
-const kAbortController = Symbol('abortController')
-
-const requestFinalizer = new FinalizationRegistry(({ signal, abort }) => {
- signal.removeEventListener('abort', abort)
+const requestFinalizer = new FinalizationRegistry(({ signal, reference }) => {
+ removeSignalFollower(signal, reference)
})
-const dependentControllerMap = new WeakMap()
+/**
+ * @typedef {object} RequestSignalState
+ * @property {AbortController} controller
+ * @property {AbortSignal} source
+ * @property {WeakRef<AbortController>} reference
+ */
+
+/** @type {WeakMap<AbortSignal, RequestSignalState>} */
+const requestSignals = new WeakMap()
+
+/** @type {WeakMap<AbortSignal, WeakRef<AbortController> | Set<WeakRef<AbortController>>>} */
+const signalFollowers = new WeakMap()
-let abortSignalHasEventHandlerLeakWarning
+/**
+ * @param {AbortSignal} signal
+ * @param {WeakRef<AbortController>} reference
+ */
+function removeSignalFollower (signal, reference) {
+ const followers = signalFollowers.get(signal)
+ if (followers instanceof Set) {
+ followers.delete(reference)
+ if (followers.size !== 0) return
+ }
-try {
- abortSignalHasEventHandlerLeakWarning = getMaxListeners(new AbortController().signal) > 0
-} catch {
- abortSignalHasEventHandlerLeakWarning = false
+ signal.removeEventListener('abort', abortSignalFollowers)
+ signalFollowers.delete(signal)
}
-function buildAbort (acRef) {
- return abort
-
- function abort () {
- const ac = acRef.deref()
- if (ac !== undefined) {
- // Currently, there is a problem with FinalizationRegistry.
- // https://github.com/nodejs/node/issues/49344
- // https://github.com/nodejs/node/issues/47748
- // In the case of abort, the first step is to unregister from it.
- // If the controller can refer to it, it is still registered.
- // It will be removed in the future.
- requestFinalizer.unregister(abort)
-
- // Unsubscribe a listener.
- // FinalizationRegistry will no longer be called, so this must be done.
- this.removeEventListener('abort', abort)
-
- ac.abort(this.reason)
-
- const controllerList = dependentControllerMap.get(ac.signal)
-
- if (controllerList !== undefined) {
- if (controllerList.size !== 0) {
- for (const ref of controllerList) {
- const ctrl = ref.deref()
- if (ctrl !== undefined) {
- ctrl.abort(this.reason)
- }
- }
- controllerList.clear()
- }
- dependentControllerMap.delete(ac.signal)
- }
+/**
+ * @param {WeakRef<AbortController>} reference
+ * @param {unknown} reason
+ */
+function abortSignalFollower (reference, reason) {
+ requestFinalizer.unregister(reference)
+ const ac = reference.deref()
+ if (ac !== undefined) {
+ requestSignals.delete(ac.signal)
+ ac.abort(reason)
+ }
+}
+
+/** @this {AbortSignal} */
+function abortSignalFollowers () {
+ this.removeEventListener('abort', abortSignalFollowers)
+ const followers = signalFollowers.get(this)
+ if (followers instanceof Set) {
+ for (const reference of followers) {
+ abortSignalFollower(reference, this.reason)
}
+ followers.clear()
+ } else {
+ abortSignalFollower(followers, this.reason)
+ }
+ signalFollowers.delete(this)
+}
+
+/**
+ * @param {AbortSignal | null | undefined} signal
+ */
+function createDependentSignal (signal) {
+ const ac = new AbortController()
+ if (signal == null) return ac.signal
+
+ if (signal.aborted) {
+ ac.abort(signal.reason)
+ return ac.signal
+ }
+
+ const reference = new WeakRef(ac)
+ const followers = signalFollowers.get(signal)
+ if (followers === undefined) {
+ util.addAbortListener(signal, abortSignalFollowers)
+ signalFollowers.set(signal, reference)
+ } else if (followers instanceof WeakRef) {
+ const controllers = new Set()
+ controllers.add(followers)
+ controllers.add(reference)
+ signalFollowers.set(signal, controllers)
+ } else {
+ followers.add(reference)
}
+
+ requestFinalizer.register(ac, { signal, reference }, reference)
+
+ // The signal must keep its controller alive even after its Request is collected.
+ requestSignals.set(ac.signal, { controller: ac, source: signal, reference })
+ return ac.signal
}
let patchMethodWarning = false
@@ -98,13 +136,6 @@ class Request {
#state
- /**
- * Removes the `abort` listener that makes this request's signal follow the
- * passed signal. `null` when no such listener was registered.
- * @type {(() => void) | null}
- */
- #abortCleanup = null
-
// https://fetch.spec.whatwg.org/#dom-request
constructor (input, init = undefined) {
webidl.util.markAsUncloneable(this)
@@ -418,51 +449,8 @@ class Request {
// 27. Set this’s request to request.
this.#state = request
- // 28. Set this’s signal to a new AbortSignal object with this’s relevant
- // Realm.
- // TODO: could this be simplified with AbortSignal.any
- // (https://dom.spec.whatwg.org/#dom-abortsignal-any)
- const ac = new AbortController()
- this.#signal = ac.signal
-
- // 29. If signal is not null, then make this’s signal follow signal.
- if (signal != null) {
- if (signal.aborted) {
- ac.abort(signal.reason)
- } else {
- // Keep a strong ref to ac while request object
- // is alive. This is needed to prevent AbortController
- // from being prematurely garbage collected.
- // See, https://github.com/nodejs/undici/issues/1926.
- this[kAbortController] = ac
-
- const acRef = new WeakRef(ac)
- const abort = buildAbort(acRef)
-
- // If the max amount of listeners is equal to the default, increase it
- if (abortSignalHasEventHandlerLeakWarning && getMaxListeners(signal) === defaultMaxListeners) {
- setMaxListeners(1500, signal)
- }
-
- const removeAbortListener = util.addAbortListener(signal, abort)
- // The third argument must be a registry key to be unregistered.
- // Without it, you cannot unregister.
- // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
- // abort is used as the unregister key. (because it is unique)
- requestFinalizer.register(ac, { signal, abort }, abort)
-
- // Allow the listener to be removed deterministically once the fetch
- // that owns this request has settled, instead of relying solely on the
- // FinalizationRegistry (i.e. garbage collection). Reusing a single
- // signal across many requests would otherwise leak listeners.
- // See https://github.com/nodejs/undici/issues/5285
- this.#abortCleanup = () => {
- requestFinalizer.unregister(abort)
- removeAbortListener()
- this.#abortCleanup = null
- }
- }
- }
+ // 28-29. Create a signal that follows the supplied signal, if any.
+ this.#signal = createDependentSignal(signal)
// 30. Set this’s headers to a new Headers object with this’s relevant
// Realm, whose header list is request’s header list and guard is
@@ -792,25 +780,10 @@ class Request {
// 3. Let clonedRequestObject be the result of creating a Request object,
// given clonedRequest, this’s headers’s guard, and this’s relevant Realm.
// 4. Make clonedRequestObject’s signal follow this’s signal.
- const ac = new AbortController()
- if (this.signal.aborted) {
- ac.abort(this.signal.reason)
- } else {
- let list = dependentControllerMap.get(this.signal)
- if (list === undefined) {
- list = new Set()
- dependentControllerMap.set(this.signal, list)
- }
- const acRef = new WeakRef(ac)
- list.add(acRef)
- util.addAbortListener(
- ac.signal,
- buildAbort(acRef)
- )
- }
+ const signal = createDependentSignal(this.#signal)
// 4. Return clonedRequestObject.
- return fromInnerRequest(clonedRequest, this.#dispatcher, ac.signal, getHeadersGuard(this.#headers))
+ return fromInnerRequest(clonedRequest, this.#dispatcher, signal, getHeadersGuard(this.#headers))
}
[nodeUtil.inspect.custom] (depth, options) {
@@ -894,7 +867,12 @@ class Request {
* @param {Request} request
*/
removeRequestAbortListener = (request) => {
- request.#abortCleanup?.()
+ const state = requestSignals.get(request.#signal)
+ if (state !== undefined) {
+ requestSignals.delete(request.#signal)
+ requestFinalizer.unregister(state.reference)
+ removeSignalFollower(state.source, state.reference)
+ }
}
webidl.is.Request = (arg) => {
diff --git a/deps/undici/src/lib/web/subresource-integrity/subresource-integrity.js b/deps/undici/src/lib/web/subresource-integrity/subresource-integrity.js
index f7d165a9534..a10e08ecc75 100644
--- a/deps/undici/src/lib/web/subresource-integrity/subresource-integrity.js
+++ b/deps/undici/src/lib/web/subresource-integrity/subresource-integrity.js
@@ -197,8 +197,8 @@ function parseMetadata (metadata) {
/** @type {MetadataList} */
const result = []
- // 2. For each item returned by splitting metadata on spaces:
- for (const item of metadata.split(' ')) {
+ // 2. For each item returned by splitting metadata on ASCII whitespace:
+ for (const item of metadata.split(/[\t\n\f\r ]+/)) {
// 1. Let expression-and-options be the result of splitting item on U+003F (?).
const expressionAndOptions = item.split('?', 1)
diff --git a/deps/undici/src/package-lock.json b/deps/undici/src/package-lock.json
index c7745ffcba5..63b2cd1746a 100644
--- a/deps/undici/src/package-lock.json
+++ b/deps/undici/src/package-lock.json
@@ -1,15 +1,15 @@
{
"name": "undici",
- "version": "8.12.3",
+ "version": "8.12.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "undici",
- "version": "8.12.3",
+ "version": "8.12.4",
"license": "MIT",
"devDependencies": {
- "@fastify/busboy": "3.2.3",
+ "@fastify/busboy": "3.2.4",
"@matteo.collina/tspl": "^0.2.0",
"@metcoder95/https-pem": "^1.0.0",
"@sinonjs/fake-timers": "^12.0.0",
@@ -1196,9 +1196,9 @@
}
},
"node_modules/@fastify/busboy": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.3.tgz",
- "integrity": "sha512-fmo/IKgkenPzz3qTKG+sIUXdF/Kv83AkvvOXJHPTHJeKJbat8SsL1ihUaFaDotqoEl4UZ1Lf8Eb/3vK6eaWX2A==",
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.4.tgz",
+ "integrity": "sha512-lQS8xcXT54rdJb+wlG/Q7SMSdtkZEpLPHooSDUZyyZm9LJD7X1kONPhVoPaHq+dsPtWUGGLIz9gBNhs9qKbylw==",
"dev": true,
"license": "MIT"
},
@@ -4557,9 +4557,9 @@
}
},
"node_modules/eslint": {
- "version": "10.12.0",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz",
- "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==",
+ "version": "10.13.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
+ "integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -6733,9 +6733,9 @@
}
},
"node_modules/jsondiffpatch": {
- "version": "0.7.6",
- "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.7.6.tgz",
- "integrity": "sha512-zE9+AXFq+MkTolDor2Cw1nJzLC0aleqPkYf52Kb4Kn4mJcka/gFHpGI2JBVEJCfWOvBl0OoxZS+wuLdislQcqg==",
+ "version": "0.7.7",
+ "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.7.7.tgz",
+ "integrity": "sha512-C3375q+xluwQYS6Je613YqXpZhW9uSi2YFRR+8QTJVYhdk0GZiDr7HD9bXNrdV9PJCpmwsCgJM9ZXXESSZPCzA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7103,9 +7103,9 @@
"license": "MIT"
},
"node_modules/neostandard": {
- "version": "0.14.0",
- "resolved": "https://registry.npmjs.org/neostandard/-/neostandard-0.14.0.tgz",
- "integrity": "sha512-11ZZgQdQrzdMqAo494FJzrSh5/lNtUyrx1mz7BVXcA9EpXCEBOz4WhVz5sG79VvoBG/4L5lZ8xjfHxng95CtFQ==",
+ "version": "0.14.1",
+ "resolved": "https://registry.npmjs.org/neostandard/-/neostandard-0.14.1.tgz",
+ "integrity": "sha512-cEGi7ADMWJRTcdsZeDXCyA1ZeJLQPBq6HP9z9CE1ysu+CJOKYt59ADG0BT6ETonuF6xNXZbMiNc8S8prYys4aQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7195,9 +7195,9 @@
"license": "MIT"
},
"node_modules/node-forge": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
- "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.2.tgz",
+ "integrity": "sha512-rRtAR5XLJ8gCKd9du4qrKAsgCG1ObGP4gLeNJMa4vYSeq4bmBMOJk5IHkrviBV0q7uoAJlVd8HEQYQSVUb4FGQ==",
"dev": true,
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
@@ -8762,9 +8762,9 @@
}
},
"node_modules/undici": {
- "version": "6.29.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
- "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
+ "version": "6.29.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz",
+ "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==",
"dev": true,
"license": "MIT",
"engines": {
diff --git a/deps/undici/src/package.json b/deps/undici/src/package.json
index 0f32b36afb4..d05d075a005 100644
--- a/deps/undici/src/package.json
+++ b/deps/undici/src/package.json
@@ -1,6 +1,6 @@
{
"name": "undici",
- "version": "8.12.3",
+ "version": "8.12.4",
"description": "An HTTP/1.1 client, written from scratch for Node.js",
"homepage": "https://undici.nodejs.org",
"bugs": {
@@ -111,7 +111,7 @@
"prepare": "husky && node ./scripts/platform-shell.js"
},
"devDependencies": {
- "@fastify/busboy": "3.2.3",
+ "@fastify/busboy": "3.2.4",
"@matteo.collina/tspl": "^0.2.0",
"@metcoder95/https-pem": "^1.0.0",
"@sinonjs/fake-timers": "^12.0.0",
diff --git a/deps/undici/src/types/dispatcher.d.ts b/deps/undici/src/types/dispatcher.d.ts
index 1c2e3357c0b..86f51f409cb 100644
--- a/deps/undici/src/types/dispatcher.d.ts
+++ b/deps/undici/src/types/dispatcher.d.ts
@@ -231,7 +231,7 @@ declare namespace Dispatcher {
onRequestSent?(): void;
}
export type PipelineHandler<TOpaque = null> = (data: PipelineHandlerData<TOpaque>) => Readable
- export type HttpMethod = Autocomplete<'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH'>
+ export type HttpMethod = Autocomplete<'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'CONNECT' | 'OPTIONS' | 'TRACE' | 'PATCH' | 'QUERY'>
/**
* @link https://fetch.spec.whatwg.org/#body-mixin
diff --git a/deps/undici/src/types/formdata.d.ts b/deps/undici/src/types/formdata.d.ts
index b9819a7e725..67d4532b2a1 100644
--- a/deps/undici/src/types/formdata.d.ts
+++ b/deps/undici/src/types/formdata.d.ts
@@ -76,33 +76,33 @@ export declare class FormData {
/**
* Executes given callback function for each field of the FormData instance
*/
- forEach: (
+ forEach (
callbackfn: (value: FormDataEntryValue, key: string, iterable: FormData) => void,
thisArg?: unknown
- ) => void
+ ): void
/**
* Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through all keys contained in this `FormData` object.
* Each key is a `string`.
*/
- keys: () => SpecIterableIterator<string>
+ keys (): SpecIterableIterator<string>
/**
* Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through all values contained in this object `FormData` object.
* Each value is a [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue).
*/
- values: () => SpecIterableIterator<FormDataEntryValue>
+ values (): SpecIterableIterator<FormDataEntryValue>
/**
* Returns an [`iterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols) allowing to go through the `FormData` key/value pairs.
* The key of each pair is a string; the value is a [`FormDataValue`](https://developer.mozilla.org/en-US/docs/Web/API/FormDataEntryValue).
*/
- entries: () => SpecIterableIterator<[string, FormDataEntryValue]>
+ entries (): SpecIterableIterator<[string, FormDataEntryValue]>
/**
* An alias for FormData#entries()
*/
- [Symbol.iterator]: () => SpecIterableIterator<[string, FormDataEntryValue]>
+ [Symbol.iterator] (): SpecIterableIterator<[string, FormDataEntryValue]>
readonly [Symbol.toStringTag]: string
}
diff --git a/deps/undici/src/types/retry-handler.d.ts b/deps/undici/src/types/retry-handler.d.ts
index 3bc484b2d07..180cde92166 100644
--- a/deps/undici/src/types/retry-handler.d.ts
+++ b/deps/undici/src/types/retry-handler.d.ts
@@ -37,7 +37,7 @@ declare namespace RetryHandler {
export interface RetryOptions {
/**
* If true, the retry handler will throw an error if the request fails,
- * this will prevent the folling handlers from being called, and will destroy the socket.
+ * this will prevent the following handlers from being called, and will destroy the socket.
*
* @type {boolean}
* @memberof RetryOptions
@@ -98,14 +98,14 @@ declare namespace RetryHandler {
*
* @type {Dispatcher.HttpMethod[]}
* @memberof RetryOptions
- * @default ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
+ * @default ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY'],
*/
methods?: Dispatcher.HttpMethod[];
/**
* Error codes to be retried. e.g. `ECONNRESET`, `ENOTFOUND`, `ETIMEDOUT`, `ECONNREFUSED`, etc.
*
* @type {string[]}
- * @default ['ECONNRESET','ECONNREFUSED','ENOTFOUND','ENETDOWN','ENETUNREACH','EHOSTDOWN','EHOSTUNREACH','EPIPE']
+ * @default ['ECONNRESET','ECONNREFUSED','ENOTFOUND','ENETDOWN','ENETUNREACH','EHOSTDOWN','EHOSTUNREACH','EPIPE','UND_ERR_SOCKET']
*/
errorCodes?: string[];
/**
diff --git a/deps/undici/undici.js b/deps/undici/undici.js
index 52529820e91..552dafce800 100644
--- a/deps/undici/undici.js
+++ b/deps/undici/undici.js
@@ -7045,14 +7045,35 @@ Content-Type: ${value.type || "application/octet-stream"}\r
"Response body object should not be disturbed or locked"
);
}
- stream = webidl.is.ReadableStream(object) ? object : ReadableStream.from(object).pipeThrough(new TransformStream({
- transform(chunk, controller) {
- const bytes = isUint8Array(chunk) ? chunk : Buffer.from(chunk);
- if (bytes.byteLength) {
- controller.enqueue(bytes);
+ if (webidl.is.ReadableStream(object)) {
+ stream = object;
+ } else {
+ const reader = ReadableStream.from(object).getReader();
+ let cancelled = false;
+ stream = new ReadableStream({
+ async pull(controller) {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (cancelled) {
+ return;
+ }
+ if (done) {
+ controller.close();
+ return;
+ }
+ const bytes = isUint8Array(value) ? value : Buffer.from(value);
+ if (bytes.byteLength) {
+ controller.enqueue(bytes);
+ return;
+ }
+ }
+ },
+ cancel(reason) {
+ cancelled = true;
+ return reader.cancel(reason);
}
- }
- }));
+ }, { highWaterMark: 0 });
+ }
}
if (typeof source === "string" || isUint8Array(source)) {
action = /* @__PURE__ */ __name(() => source, "action");
@@ -7292,7 +7313,9 @@ var require_client_h1 = __commonJS({
const llhttpWasmData = process.env.JEST_WORKER_ID ? require_llhttp_wasm() : void 0;
let mod;
let useWasmSIMD = true;
- if (process.arch === "ppc64") {
+ if (process.arch === "riscv64") {
+ useWasmSIMD = false;
+ } else if (process.arch === "ppc64") {
const [major, minor] = process.versions.node.split(".").map((n) => parseInt(n, 10));
if (major < 24 || major === 24 && minor < 12) {
useWasmSIMD = false;
@@ -8638,7 +8661,6 @@ var require_client_h2 = __commonJS({
} = require_symbols();
var { channels } = require_diagnostics();
var kOpenStreams = /* @__PURE__ */ Symbol("open streams");
- var kRequestStreamId = /* @__PURE__ */ Symbol("request stream id");
var kRequestStream = /* @__PURE__ */ Symbol("request stream");
var kRequestStreamCleanup = /* @__PURE__ */ Symbol("request stream cleanup");
var kRequestStreamState = /* @__PURE__ */ Symbol("request stream state");
@@ -8646,6 +8668,7 @@ var require_client_h2 = __commonJS({
var kGoAwayReplayAttempts = /* @__PURE__ */ Symbol("goaway replay attempts");
var kRefusedStreamRetry = /* @__PURE__ */ Symbol("refused stream retry");
var MAX_GOAWAY_REPLAY_ATTEMPTS = 1;
+ var HTTP2_FRAME_TYPE_HEADERS = 1;
var MIN_PROBE_TIMEOUT = 1e3;
var extractBody;
var http2;
@@ -8699,7 +8722,8 @@ var require_client_h2 = __commonJS({
if (request == null) {
continue;
}
- if (typeof request[kRequestStreamId] !== "number" || request[kRequestStreamId] > maxAcceptedStreamID) {
+ const streamId = request[kRequestStream]?.id;
+ if (typeof streamId !== "number" || streamId > maxAcceptedStreamID) {
return i;
}
}
@@ -8707,7 +8731,6 @@ var require_client_h2 = __commonJS({
}
__name(getGoAwayPendingIdx, "getGoAwayPendingIdx");
function detachRequestFromStream(request) {
- request[kRequestStreamId] = null;
request[kRequestStream] = null;
request[kRequestStreamCleanup] = null;
}
@@ -8717,7 +8740,6 @@ var require_client_h2 = __commonJS({
const previousStream = request[kRequestStream];
detachRequestFromStream(request);
previousCleanup?.(previousStream);
- request[kRequestStreamId] = stream.id;
request[kRequestStream] = stream;
request[kRequestStreamCleanup] = cleanup;
}
@@ -8795,7 +8817,8 @@ var require_client_h2 = __commonJS({
closeStreamSession(stream);
}
if (!stream.destroyed && !stream.closed) {
- stream.once("error", noop);
+ stream.off("error", noop);
+ stream.on("error", noop);
}
}
__name(severRequestStream, "severRequestStream");
@@ -9151,14 +9174,12 @@ var require_client_h2 = __commonJS({
}
}
}
- for (let i = 0; i < streamsToClose.length; i++) {
- closeStream(streamsToClose[i]);
- }
if (pendingIdx !== previousPendingIdx) {
const remainingPendingRequests = client[kQueue].slice(previousPendingIdx);
client[kQueue].length = pendingIdx;
client[kQueue].push(...retriableRequests, ...remainingPendingRequests);
}
+ client[kPendingIdx] = pendingIdx;
if (client[kHTTP2Session] === this) {
client[kSocket] = null;
client[kHTTPContext] = null;
@@ -9170,10 +9191,12 @@ var require_client_h2 = __commonJS({
clearHttp2IdleTimeout(this);
clearNoStreamsTimeout(this);
clearHttp2ProbeTimeout(this);
+ for (let i = 0; i < streamsToClose.length; i++) {
+ closeStream(streamsToClose[i]);
+ }
if (!this.closed && !this.destroyed) {
this.close();
}
- client[kPendingIdx] = pendingIdx;
client.emit("disconnect", client[kUrl], [client], err);
client[kResume]();
}
@@ -9257,7 +9280,6 @@ var require_client_h2 = __commonJS({
}
__name(closeStreamSession, "closeStreamSession");
function onUpgradeStreamClose() {
- this.off("error", noop);
const state = this[kRequestStreamState];
this[kRequestStreamState] = null;
failUpgradeStream(state, new InformationalError("HTTP/2: stream closed before response headers"));
@@ -9335,7 +9357,7 @@ var require_client_h2 = __commonJS({
}
removeUpgradeStreamListeners(stream);
if (!stream.destroyed && !stream.closed) {
- stream.once("error", noop);
+ stream.on("error", noop);
}
}
__name(releaseUpgradeStream, "releaseUpgradeStream");
@@ -9640,7 +9662,7 @@ var require_client_h2 = __commonJS({
}
if (!stream.destroyed && !stream.closed) {
removeRequestStreamListeners(stream);
- stream.once("error", noop);
+ stream.on("error", noop);
}
}
__name(releaseRequestStream, "releaseRequestStream");
@@ -9760,15 +9782,29 @@ var require_client_h2 = __commonJS({
return false;
}
request[kRefusedStreamRetry] = true;
+ requeueStreamRequest(client, state);
+ return true;
+ }
+ __name(retryRefusedStream, "retryRefusedStream");
+ function retryUnsentStream(state) {
+ const { client, request, session } = state;
+ if (!session[kReceivedGoAway] || state.responseReceived || request.aborted || request.completed || !canReplayRequest(request) || !registerGoAwayRefusal(request)) {
+ return false;
+ }
+ requeueStreamRequest(client, state);
+ return true;
+ }
+ __name(retryUnsentStream, "retryUnsentStream");
+ function requeueStreamRequest(client, state) {
+ const { request } = state;
detachRequestStreamForClose(request);
state.stream = null;
state.requestFinalized = true;
completeRequest(client, request);
client[kQueue].splice(client[kPendingIdx], 0, request);
client[kResume]();
- return true;
}
- __name(retryRefusedStream, "retryRefusedStream");
+ __name(requeueStreamRequest, "requeueStreamRequest");
function onError(err) {
const stream = this;
const state = stream[kRequestStreamState];
@@ -9776,6 +9812,7 @@ var require_client_h2 = __commonJS({
return;
}
stream.off("error", onError);
+ stream.on("error", noop);
if (state.responseContentLength != null && state.bytesRead !== state.responseContentLength) {
err = new ResponseContentLengthMismatchError();
}
@@ -9795,6 +9832,9 @@ var require_client_h2 = __commonJS({
return;
}
stream.off("frameError", onFrameError);
+ if (type === HTTP2_FRAME_TYPE_HEADERS && retryUnsentStream(state)) {
+ return;
+ }
state.abort(new InformationalError(`HTTP/2: "frameError" received - type ${type}, code ${code}`));
}
__name(onFrameError, "onFrameError");
@@ -13311,44 +13351,69 @@ var require_request2 = __commonJS({
var { URLSerializer } = require_data_url();
var { kConstruct } = require_symbols();
var assert = require("node:assert");
- var { getMaxListeners, setMaxListeners, defaultMaxListeners } = require("node:events");
- var kAbortController = /* @__PURE__ */ Symbol("abortController");
- var requestFinalizer = new FinalizationRegistry(({ signal, abort }) => {
- signal.removeEventListener("abort", abort);
+ var requestFinalizer = new FinalizationRegistry(({ signal, reference }) => {
+ removeSignalFollower(signal, reference);
});
- var dependentControllerMap = /* @__PURE__ */ new WeakMap();
- var abortSignalHasEventHandlerLeakWarning;
- try {
- abortSignalHasEventHandlerLeakWarning = getMaxListeners(new AbortController().signal) > 0;
- } catch {
- abortSignalHasEventHandlerLeakWarning = false;
- }
- function buildAbort(acRef) {
- return abort;
- function abort() {
- const ac = acRef.deref();
- if (ac !== void 0) {
- requestFinalizer.unregister(abort);
- this.removeEventListener("abort", abort);
- ac.abort(this.reason);
- const controllerList = dependentControllerMap.get(ac.signal);
- if (controllerList !== void 0) {
- if (controllerList.size !== 0) {
- for (const ref of controllerList) {
- const ctrl = ref.deref();
- if (ctrl !== void 0) {
- ctrl.abort(this.reason);
- }
- }
- controllerList.clear();
- }
- dependentControllerMap.delete(ac.signal);
- }
- }
+ var requestSignals = /* @__PURE__ */ new WeakMap();
+ var signalFollowers = /* @__PURE__ */ new WeakMap();
+ function removeSignalFollower(signal, reference) {
+ const followers = signalFollowers.get(signal);
+ if (followers instanceof Set) {
+ followers.delete(reference);
+ if (followers.size !== 0) return;
+ }
+ signal.removeEventListener("abort", abortSignalFollowers);
+ signalFollowers.delete(signal);
+ }
+ __name(removeSignalFollower, "removeSignalFollower");
+ function abortSignalFollower(reference, reason) {
+ requestFinalizer.unregister(reference);
+ const ac = reference.deref();
+ if (ac !== void 0) {
+ requestSignals.delete(ac.signal);
+ ac.abort(reason);
+ }
+ }
+ __name(abortSignalFollower, "abortSignalFollower");
+ function abortSignalFollowers() {
+ this.removeEventListener("abort", abortSignalFollowers);
+ const followers = signalFollowers.get(this);
+ if (followers instanceof Set) {
+ for (const reference of followers) {
+ abortSignalFollower(reference, this.reason);
+ }
+ followers.clear();
+ } else {
+ abortSignalFollower(followers, this.reason);
}
- __name(abort, "abort");
+ signalFollowers.delete(this);
}
- __name(buildAbort, "buildAbort");
+ __name(abortSignalFollowers, "abortSignalFollowers");
+ function createDependentSignal(signal) {
+ const ac = new AbortController();
+ if (signal == null) return ac.signal;
+ if (signal.aborted) {
+ ac.abort(signal.reason);
+ return ac.signal;
+ }
+ const reference = new WeakRef(ac);
+ const followers = signalFollowers.get(signal);
+ if (followers === void 0) {
+ util.addAbortListener(signal, abortSignalFollowers);
+ signalFollowers.set(signal, reference);
+ } else if (followers instanceof WeakRef) {
+ const controllers = /* @__PURE__ */ new Set();
+ controllers.add(followers);
+ controllers.add(reference);
+ signalFollowers.set(signal, controllers);
+ } else {
+ followers.add(reference);
+ }
+ requestFinalizer.register(ac, { signal, reference }, reference);
+ requestSignals.set(ac.signal, { controller: ac, source: signal, reference });
+ return ac.signal;
+ }
+ __name(createDependentSignal, "createDependentSignal");
var patchMethodWarning = false;
var setRequestSignal;
var getRequestDispatcher;
@@ -13368,12 +13433,6 @@ var require_request2 = __commonJS({
/** @type {Headers} */
#headers;
#state;
- /**
- * Removes the `abort` listener that makes this request's signal follow the
- * passed signal. `null` when no such listener was registered.
- * @type {(() => void) | null}
- */
- #abortCleanup = null;
// https://fetch.spec.whatwg.org/#dom-request
constructor(input, init = void 0) {
webidl.util.markAsUncloneable(this);
@@ -13559,27 +13618,7 @@ var require_request2 = __commonJS({
signal = init.signal;
}
this.#state = request;
- const ac = new AbortController();
- this.#signal = ac.signal;
- if (signal != null) {
- if (signal.aborted) {
- ac.abort(signal.reason);
- } else {
- this[kAbortController] = ac;
- const acRef = new WeakRef(ac);
- const abort = buildAbort(acRef);
- if (abortSignalHasEventHandlerLeakWarning && getMaxListeners(signal) === defaultMaxListeners) {
- setMaxListeners(1500, signal);
- }
- const removeAbortListener = util.addAbortListener(signal, abort);
- requestFinalizer.register(ac, { signal, abort }, abort);
- this.#abortCleanup = () => {
- requestFinalizer.unregister(abort);
- removeAbortListener();
- this.#abortCleanup = null;
- };
- }
- }
+ this.#signal = createDependentSignal(signal);
this.#headers = new Headers(kConstruct);
setHeadersList(this.#headers, request.headersList);
setHeadersGuard(this.#headers, "request");
@@ -13773,23 +13812,8 @@ var require_request2 = __commonJS({
throw new TypeError("unusable");
}
const clonedRequest = cloneRequest(this.#state);
- const ac = new AbortController();
- if (this.signal.aborted) {
- ac.abort(this.signal.reason);
- } else {
- let list = dependentControllerMap.get(this.signal);
- if (list === void 0) {
- list = /* @__PURE__ */ new Set();
- dependentControllerMap.set(this.signal, list);
- }
- const acRef = new WeakRef(ac);
- list.add(acRef);
- util.addAbortListener(
- ac.signal,
- buildAbort(acRef)
- );
- }
- return fromInnerRequest(clonedRequest, this.#dispatcher, ac.signal, getHeadersGuard(this.#headers));
+ const signal = createDependentSignal(this.#signal);
+ return fromInnerRequest(clonedRequest, this.#dispatcher, signal, getHeadersGuard(this.#headers));
}
[nodeUtil.inspect.custom](depth, options) {
if (options.depth === null) {
@@ -13835,7 +13859,12 @@ var require_request2 = __commonJS({
request.#state = newState;
}, "setRequestState");
removeRequestAbortListener = /* @__PURE__ */ __name((request) => {
- request.#abortCleanup?.();
+ const state = requestSignals.get(request.#signal);
+ if (state !== void 0) {
+ requestSignals.delete(request.#signal);
+ requestFinalizer.unregister(state.reference);
+ removeSignalFollower(state.source, state.reference);
+ }
}, "removeRequestAbortListener");
webidl.is.Request = (arg) => {
return arg != null && typeof arg === "object" && #state in arg;
@@ -14121,7 +14150,7 @@ var require_subresource_integrity = __commonJS({
__name(getStrongestMetadata, "getStrongestMetadata");
function parseMetadata(metadata) {
const result = [];
- for (const item of metadata.split(" ")) {
+ for (const item of metadata.split(/[\t\n\f\r ]+/)) {
const expressionAndOptions = item.split("?", 1);
const algorithmExpression = expressionAndOptions[0];
let base64Value = "";
@@ -14379,11 +14408,8 @@ var require_fetch = __commonJS({
cleanupAbortListeners();
}, "processResponseEndOfBody"),
processResponse,
- dispatcher: getRequestDispatcher(requestObject),
+ dispatcher: getRequestDispatcher(requestObject)
// undici
- // Keep requestObject alive to prevent its AbortController from being GC'd
- // See https://github.com/nodejs/undici/issues/4627
- requestObject
});
return p.promise;
}
@@ -14454,10 +14480,8 @@ var require_fetch = __commonJS({
processResponseEndOfBody,
processResponseConsumeBody,
useParallelQueue = false,
- dispatcher = getGlobalDispatcher2(),
+ dispatcher = getGlobalDispatcher2()
// undici
- requestObject = null
- // Keep alive to prevent AbortController GC, see #4627
}) {
assert(dispatcher);
let taskDestination = null;
@@ -14480,9 +14504,7 @@ var require_fetch = __commonJS({
processResponseConsumeBody,
processResponseEndOfBody,
taskDestination,
- crossOriginIsolatedCapability,
- // Keep requestObject alive to prevent its AbortController from being GC'd
- requestObject
+ crossOriginIsolatedCapability
};
assert(!request.body || request.body.stream);
if (request.window === "client") {
diff --git a/src/undici_version.h b/src/undici_version.h
index f16582bcb61..e0a4578df6c 100644
--- a/src/undici_version.h
+++ b/src/undici_version.h
@@ -2,5 +2,5 @@
// Refer to tools/dep_updaters/update-undici.sh
#ifndef SRC_UNDICI_VERSION_H_
#define SRC_UNDICI_VERSION_H_
-#define UNDICI_VERSION "8.12.3"
+#define UNDICI_VERSION "8.12.4"
#endif // SRC_UNDICI_VERSION_H_