diff --git a/deps/undici/src/README.md b/deps/undici/src/README.md index 479059fbc1ab..7662b905bdff 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 4e3c12931309..61c0d64f53d0 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 726620b72f27..bcc8582b9083 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 fa028ef1d59c..3557ab733345 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 bb402702ee7d..dee24547d53d 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 d17b13241b1b..1024cb6a791d 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 cbbdaaf35431..a26b9c014e87 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 f0d3a9ca38af..9857b80b1e23 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 263d67e7763b..c1440170fd24 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 fed01a1aa0c5..20c96def2670 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 bc99893cda0b..136685f91e8f 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 604be1276374..a1459cc32c15 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 b16c8994338f..60d2522d1ffe 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 00c1a0bbe13c..cf53e3ad5b08 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 28124d018c08..b4ea3cbfe41b 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 a9382ca325b1..b90f6c8d866d 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 66f8150cb829..cce8b8e323ea 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} reference + */ + +/** @type {WeakMap} */ +const requestSignals = new WeakMap() + +/** @type {WeakMap | Set>>} */ +const signalFollowers = new WeakMap() -let abortSignalHasEventHandlerLeakWarning +/** + * @param {AbortSignal} signal + * @param {WeakRef} 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} 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 f7d165a9534c..a10e08ecc75a 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 c7745ffcba5e..63b2cd1746ad 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 0f32b36afb44..d05d075a005b 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 1c2e3357c0b8..86f51f409cb1 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 = (data: PipelineHandlerData) => 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 b9819a7e725d..67d4532b2a1b 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 + keys (): SpecIterableIterator /** * 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 + values (): SpecIterableIterator /** * 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 3bc484b2d070..180cde921664 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 52529820e915..552dafce800f 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 f16582bcb618..e0a4578df6c0 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_