From 4cc89003c0cfe033a4281c6f2f51253929168211 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Sun, 9 Aug 2026 00:32:07 +0000 Subject: [PATCH] deps: update undici to 8.10.0 --- deps/undici/src/docs/docs/api/Client.md | 29 +- deps/undici/src/lib/api/readable.js | 76 ++--- deps/undici/src/lib/core/connect.js | 18 +- deps/undici/src/lib/core/symbols.js | 1 + deps/undici/src/lib/dispatcher/client-h1.js | 6 +- deps/undici/src/lib/dispatcher/client-h2.js | 189 +++++++++- deps/undici/src/lib/dispatcher/client.js | 103 ++++-- .../lib/dispatcher/env-http-proxy-agent.js | 34 +- .../src/lib/dispatcher/socks5-proxy-agent.js | 16 +- deps/undici/src/lib/handler/retry-handler.js | 9 +- deps/undici/src/lib/interceptor/cache.js | 7 +- .../undici/src/lib/interceptor/deduplicate.js | 2 +- deps/undici/src/lib/llhttp/wasm_build_env.txt | 2 +- deps/undici/src/lib/mock/mock-utils.js | 237 +++++++++++-- deps/undici/src/lib/util/cache.js | 6 +- .../undici/src/lib/web/websocket/websocket.js | 22 ++ deps/undici/src/package-lock.json | 4 +- deps/undici/src/package.json | 2 +- deps/undici/src/types/client.d.ts | 40 +++ deps/undici/undici.js | 323 ++++++++++++++---- src/undici_version.h | 2 +- 21 files changed, 916 insertions(+), 212 deletions(-) diff --git a/deps/undici/src/docs/docs/api/Client.md b/deps/undici/src/docs/docs/api/Client.md index dc6ab7a6d5f9..d48b2303f287 100644 --- a/deps/undici/src/docs/docs/api/Client.md +++ b/deps/undici/src/docs/docs/api/Client.md @@ -111,22 +111,35 @@ added: v1.0.0 `autoSelectFamily` is enabled. **Default:** `250`. * `allowH2` {boolean} Enables HTTP/2 support when the server assigns it a higher priority through ALPN negotiation. **Default:** `true`. - * `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS - connections. **Default:** `false`. - * `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2 + * `useH2c` {boolean} _Deprecated: use h2Options.useH2c instead_ Enforces h2c (HTTP/2 cleartext) for non-HTTPS + connections. **Default:** `false`. + * `maxConcurrentStreams` {number} _Deprecated: use h2Options.useH2c instead_ The maximum number of concurrent HTTP/2 streams for a single session. Once h2 is negotiated this — not `pipelining`, which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests. It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS` frame. **Default:** `100`. - * `initialWindowSize` {number} The HTTP/2 stream-level flow-control window - size (`SETTINGS_INITIAL_WINDOW_SIZE`). Must be a positive integer. - **Default:** `262144`. - * `connectionWindowSize` {number} The HTTP/2 connection-level flow-control + * `connectionWindowSize` {number} _Deprecated: use h2Options.connectionWindowSize instead_ The HTTP/2 connection-level flow-control window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a positive integer. **Default:** `524288`. - * `pingInterval` {number} The time interval, in milliseconds, between HTTP/2 + * `pingInterval` {number} _Deprecated: use h2Options.pingInterval instead_ The time interval, in milliseconds, between HTTP/2 PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2 connections and emits a `ping` event on the client. **Default:** `60e3`. + * `h2Options` {object} Set of options for HTTP/2 sessions + * `useH2c` {boolean} Enforces h2c (HTTP/2 cleartext) for non-HTTPS + connections. **Default:** `false`. + * `maxConcurrentStreams` {number} The maximum number of concurrent HTTP/2 + streams for a single session. Once h2 is negotiated this — not `pipelining`, + which is HTTP/1.1 only — is the ceiling used to dispatch in-flight requests. + It may be overridden by the server's `SETTINGS_MAX_CONCURRENT_STREAMS` + frame. **Default:** `100`. + * `connectionWindowSize` {number} The HTTP/2 connection-level flow-control + window size set via `ClientHttp2Session.setLocalWindowSize()`. Must be a + positive integer. **Default:** `524288`. + * `pingInterval` {number} The time interval, in milliseconds, between HTTP/2 + PING frames. Set to `0` to disable PING frames. Applies only to HTTP/2 + connections and emits a `ping` event on the client. **Default:** `60e3`. + * `settings` {object} `SETTINGS` frame options. For full reference, take a + look to [HTTP/2#Settings Object](https://nodejs.org/api/http2.html#settings-object) * `webSocket` {Object} (optional) WebSocket-specific configuration. * `maxFragments` {number} The maximum number of fragments in a message. Set to `0` to disable the limit. **Default:** `131072`. diff --git a/deps/undici/src/lib/api/readable.js b/deps/undici/src/lib/api/readable.js index 71d90d457b34..e3dd3dcce4b7 100644 --- a/deps/undici/src/lib/api/readable.js +++ b/deps/undici/src/lib/api/readable.js @@ -15,7 +15,6 @@ const kContentType = Symbol('kContentType') const kContentLength = Symbol('kContentLength') const kUsed = Symbol('kUsed') const kBytesRead = Symbol('kBytesRead') -const kPreservedBuffer = Symbol('kPreservedBuffer') const noop = () => {} @@ -326,36 +325,14 @@ class BodyReadable extends Readable { */ setEncoding (encoding) { if (Buffer.isEncoding(encoding)) { - // Preserve raw Buffer chunks for the consume path (body.text(), - // body.json(), etc.) before super.setEncoding() replaces them - // with decoded strings. Without this, the consume path would - // lose access to the original bytes — some of which may be held - // by the decoder for incomplete multi-byte sequences, and the - // rest converted to strings that can't be safely concatenated - // byte-wise. - const state = this._readableState - const buffer = state.buffer - if (buffer && state.length > 0) { - const bufferIndex = state.bufferIndex ?? 0 - const preserved = [] - const source = typeof buffer.slice === 'function' - ? buffer.slice(bufferIndex) - : buffer - for (const data of source) { - if (Buffer.isBuffer(data)) { - preserved.push(data) - } - } - if (preserved.length > 0) { - this[kPreservedBuffer] = (this[kPreservedBuffer] || []).concat(preserved) - } - } - // Delegate to Node.js Readable.setEncoding() which initializes a // StringDecoder and re-encodes already-buffered chunks. This properly // handles multi-byte sequences split at chunk boundaries for the // for-await / on('data') paths. Without this, Node.js uses // buf.toString(encoding) on each chunk, producing U+FFFD for split chars. + // + // The consume path (body.text(), body.json(), ...) copes with the + // decoded strings this leaves in state.buffer, see consumeStart(). super.setEncoding(encoding) } return this @@ -464,17 +441,7 @@ function consumeStart (consume) { const { _readableState: state } = consume.stream - // If setEncoding() was called, state.buffer may contain decoded strings - // (which would break Buffer.concat in chunksDecode). Use the preserved - // raw Buffers (saved before super.setEncoding() in setEncoding()) for - // byte-level accurate consumption. Otherwise read from state.buffer. - const preserved = consume.stream[kPreservedBuffer] - if (preserved && preserved.length > 0) { - for (const chunk of preserved) { - consumePush(consume, chunk) - } - consume.stream[kPreservedBuffer] = null - } else if (state.bufferIndex) { + if (state.bufferIndex) { const start = state.bufferIndex const end = state.buffer.length for (let n = start; n < end; n++) { @@ -486,14 +453,29 @@ function consumeStart (consume) { } } + // If setEncoding() was called, state.buffer holds decoded strings, which + // consumePush() turns back into bytes. The trailing bytes of a multi-byte + // sequence split across a chunk boundary are not part of any of those + // strings, they are held inside the decoder until the rest arrives, so + // take them from there. + const decoder = state.decoder + if (decoder != null && decoder.lastNeed > 0) { + consumePush(consume, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed))) + } + if (state.endEmitted) { - consumeEnd(this[kConsume], this._readableState.encoding) - } else { - consume.stream.on('end', function () { - consumeEnd(this[kConsume], this._readableState.encoding) - }) + // No `this` to read the consume off here: consumeStart is a free function, called from + // the queueMicrotask above. The callback below does have one, because the emitter passes + // the stream as its receiver. Returning matters too - consumeEnd() clears consume.stream, + // which the resume() below would then dereference. + consumeEnd(consume, state.encoding) + return } + consume.stream.on('end', function () { + consumeEnd(this[kConsume], this._readableState.encoding) + }) + consume.stream.resume() while (consume.stream.read() != null) { @@ -583,7 +565,7 @@ function consumeEnd (consume, encoding) { /** * @param {Consume} consume - * @param {Buffer} chunk + * @param {Buffer|string} chunk * @returns {void} */ function consumePush (consume, chunk) { @@ -591,6 +573,14 @@ function consumePush (consume, chunk) { return } + if (typeof chunk === 'string') { + // Buffered before the consume started, while an encoding was set. + // consume.length has to stay a byte count and chunksDecode()/chunksConcat() + // only work on bytes, so re-encode. A string's own length is in UTF-16 code + // units and Uint8Array.prototype.set() ignores a string argument entirely. + chunk = Buffer.from(chunk, consume.stream._readableState.encoding) + } + consume.length += chunk.length consume.body.push(chunk) } diff --git a/deps/undici/src/lib/core/connect.js b/deps/undici/src/lib/core/connect.js index ad962c31944a..f729dfb0526d 100644 --- a/deps/undici/src/lib/core/connect.js +++ b/deps/undici/src/lib/core/connect.js @@ -105,13 +105,27 @@ function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketP port = port || 80 - socket = net.connect({ + const connectOptions = { highWaterMark: 64 * 1024, // Same as nodejs fs streams. ...options, localAddress, port, host: hostname - }) + } + + const family = net.isIP(hostname) + if (family !== 0 && servername && servername !== hostname) { + connectOptions.host = servername + connectOptions.lookup = (_hostname, lookupOptions, cb) => { + if (lookupOptions.all) { + cb(null, [{ address: hostname, family }]) + } else { + cb(null, hostname, family) + } + } + } + + socket = net.connect(connectOptions) if (useH2c === true) { socket.alpnProtocol = 'h2' } diff --git a/deps/undici/src/lib/core/symbols.js b/deps/undici/src/lib/core/symbols.js index 8bad25eed9fd..badecb709086 100644 --- a/deps/undici/src/lib/core/symbols.js +++ b/deps/undici/src/lib/core/symbols.js @@ -56,6 +56,7 @@ module.exports = { kCounter: Symbol('socket request counter'), kMaxResponseSize: Symbol('max response size'), kHTTP2Session: Symbol('http2Session'), + kHTTP2Options: Symbol('http2 options'), kHTTP2SessionState: Symbol('http2Session state'), kRetryHandlerDefaultRetry: Symbol('retry agent default retry'), kConstruct: Symbol('constructable'), diff --git a/deps/undici/src/lib/dispatcher/client-h1.js b/deps/undici/src/lib/dispatcher/client-h1.js index abf381f65a46..9f6f17c1579b 100644 --- a/deps/undici/src/lib/dispatcher/client-h1.js +++ b/deps/undici/src/lib/dispatcher/client-h1.js @@ -1052,7 +1052,7 @@ function onSocketClose () { function clearIdleSocketValidation (socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]) + clearTimeout(socket[kIdleSocketValidationTimeout]) socket[kIdleSocketValidationTimeout] = null } @@ -1061,14 +1061,14 @@ function clearIdleSocketValidation (socket) { function scheduleIdleSocketValidation (client, socket) { socket[kIdleSocketValidation] = 1 - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null socket[kIdleSocketValidation] = 2 if (client[kSocket] === socket && !socket.destroyed) { client[kResume]() } - }) + }, 0) socket[kIdleSocketValidationTimeout].unref?.() } diff --git a/deps/undici/src/lib/dispatcher/client-h2.js b/deps/undici/src/lib/dispatcher/client-h2.js index 19622db68ace..bc401008f004 100644 --- a/deps/undici/src/lib/dispatcher/client-h2.js +++ b/deps/undici/src/lib/dispatcher/client-h2.js @@ -26,10 +26,7 @@ const { kStrictContentLength, kOnError, kMaxConcurrentStreams, - kPingInterval, kHTTP2Session, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kHostAuthority, kResume, kSize, @@ -41,7 +38,8 @@ const { kEnableConnectProtocol, kRemoteSettings, kHTTP2Stream, - kHTTP2SessionState + kHTTP2SessionState, + kHTTP2Options } = require('../core/symbols.js') const { channels } = require('../core/diagnostics.js') @@ -51,6 +49,14 @@ const kRequestStream = Symbol('request stream') const kRequestStreamCleanup = Symbol('request stream cleanup') const kRequestStreamState = Symbol('request stream state') const kReceivedGoAway = Symbol('received goaway') +const kGoAwayReplayAttempts = Symbol('goaway replay attempts') +const kRefusedStreamRetry = Symbol('refused stream retry') + +// RFC 9113 section 8.7: a client SHOULD NOT automatically retry a request more +// than once. Without a budget a peer that keeps refusing turns one request into +// an unbounded connect/refuse/reconnect loop that never settles and starves the +// event loop. +const MAX_GOAWAY_REPLAY_ATTEMPTS = 1 let extractBody @@ -179,12 +185,24 @@ function completeRequest (client, request, resetPendingIdx = false) { } } -function canRetryRequestAfterGoAway (request) { +function canReplayRequest (request) { const { body } = request return body == null || util.isBuffer(body) || util.isBlobLike(body) } +// Count a GOAWAY refusal against the request's replay budget. A peer that +// refuses every connection must eventually surface an error to the caller +// rather than being retried forever. Kept separate from canReplayRequest so +// that the REFUSED_STREAM retry, which has its own single-attempt limit, does +// not consume this budget just by asking whether the body can be replayed. +function registerGoAwayRefusal (request) { + const attempts = (request[kGoAwayReplayAttempts] ?? 0) + 1 + request[kGoAwayReplayAttempts] = attempts + + return attempts <= MAX_GOAWAY_REPLAY_ATTEMPTS +} + function closeStream (stream, code = NGHTTP2_REFUSED_STREAM) { if (stream != null && !stream.destroyed && !stream.closed) { try { @@ -197,19 +215,44 @@ function detachRequestStreamForClose (request) { const stream = request[kRequestStream] clearRequestStream(request) + severRequestStream(stream) return stream } +// Unbind a stream from its request for good. releaseRequestStream() alone +// leaves the 'close' listener attached and kRequestStreamState populated, so a +// stream abandoned here would still run completeRequestStream() later — and +// splice out the request that has since been requeued onto another session. +function severRequestStream (stream) { + if (stream == null || stream[kRequestStreamState] == null) { + return + } + + stream[kRequestStreamState] = null + stream.off('close', completeRequestStream) + // Upgrade streams use their own close cleanup, which would otherwise release + // the session a second time after the stream has been severed for GOAWAY. + stream.off('close', onUpgradeStreamClose) + + if (stream[kHTTP2Session] != null) { + closeStreamSession(stream) + } + + if (!stream.destroyed && !stream.closed) { + stream.once('error', noop) + } +} + function connectH2 (client, socket) { client[kSocket] = socket - const http2InitialWindowSize = client[kHTTP2InitialWindowSize] - const http2ConnectionWindowSize = client[kHTTP2ConnectionWindowSize] + const http2InitialWindowSize = client[kHTTP2Options].sessionOptions?.initialWindowSize + const http2ConnectionWindowSize = client[kHTTP2Options].connectionWindowSize const session = http2.connect(client[kUrl], { createConnection: () => socket, - peerMaxConcurrentStreams: client[kMaxConcurrentStreams], + peerMaxConcurrentStreams: client[kHTTP2Options].maxConcurrentStreams, settings: { // TODO(metcoder95): add support for PUSH enablePush: false, @@ -223,13 +266,16 @@ function connectH2 (client, socket) { session[kSocket] = socket session[kHTTP2SessionState] = { idleTimeout: null, + // Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have + // work that cannot start. See setNoStreamsTimeout. + noStreamsTimeout: null, // Sockets start out ref'd. Session ref/unref proxies to the socket, so a // single cached flag lets us skip redundant uv ref/unref calls, provided // every ref/unref of the session or its socket goes through // refH2Session/unrefH2Session. refed: true, ping: { - interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref() + interval: client[kHTTP2Options].pingInterval === 0 ? null : setInterval(onHttp2SendPing, client[kHTTP2Options].pingInterval, session).unref() } } session[kReceivedGoAway] = false @@ -369,7 +415,74 @@ function resumeH2 (client) { } else { clearHttp2IdleTimeout(session) } + + if (client[kMaxConcurrentStreams] === 0 && client[kRunning] === 0 && client[kPending] > 0) { + setNoStreamsTimeout(session) + } else { + clearNoStreamsTimeout(session) + } + } +} + +function clearNoStreamsTimeout (session) { + const state = session[kHTTP2SessionState] + + if (state?.noStreamsTimeout != null) { + clearTimeout(state.noStreamsTimeout) + state.noStreamsTimeout = null + } +} + +// A peer is allowed to advertise SETTINGS_MAX_CONCURRENT_STREAMS = 0 to refuse +// new streams (RFC 9113 §6.5.2), and is expected to raise it again later. Until +// it does, busy() reports the client as permanently busy and queued requests +// cannot open a stream — which means no per-stream timeout covers them, and no +// reconnect can happen either, so the SETTINGS frame that would lift the limit +// can never arrive. Give the peer headersTimeout to start honouring requests +// before failing them; a request that cannot even be sent has missed the same +// deadline as one whose headers never arrive. +function setNoStreamsTimeout (session) { + const client = session[kClient] + const state = session[kHTTP2SessionState] + const timeout = client[kHeadersTimeout] + + if (!timeout || state.noStreamsTimeout != null) { + return + } + + state.noStreamsTimeout = setTimeout(onNoStreamsTimeout, timeout, session).unref() +} + +function onNoStreamsTimeout (session) { + const client = session[kClient] + const state = session[kHTTP2SessionState] + + state.noStreamsTimeout = null + + if ( + client[kHTTP2Session] !== session || + client[kMaxConcurrentStreams] !== 0 || + client[kRunning] !== 0 || + client[kPending] === 0 + ) { + return + } + + const err = new HeadersTimeoutError( + `HTTP/2: server did not accept a new stream within ${client[kHeadersTimeout]}` + ) + + const requests = client[kQueue].splice(client[kPendingIdx]) + for (let i = 0; i < requests.length; i++) { + if (requests[i] != null) { + util.errorRequest(client, requests[i], err) + } } + + // Drop the unusable session so the next request gets a fresh connection, + // whose SETTINGS may well allow streams again. + session[kError] = err + resetHttp2Session(session, err) } function clearHttp2IdleTimeout (session) { @@ -527,7 +640,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) { if (request != null) { streamsToClose.push(detachRequestStreamForClose(request)) - if (canRetryRequestAfterGoAway(request)) { + if (canReplayRequest(request) && registerGoAwayRefusal(request)) { retriableRequests.push(request) } else { util.errorRequest(client, request, err) @@ -552,6 +665,7 @@ function onHttp2SessionGoAway (errorCode, lastStreamID) { } clearHttp2IdleTimeout(this) + clearNoStreamsTimeout(this) if (!this.closed && !this.destroyed) { this.close() @@ -576,6 +690,7 @@ function onHttp2SessionClose () { } clearHttp2IdleTimeout(this) + clearNoStreamsTimeout(this) if (state.ping.interval != null) { clearInterval(state.ping.interval) @@ -687,6 +802,16 @@ function completeRequestStream () { if (state.pendingEnd && !state.request.aborted && !state.request.completed) { state.request.onResponseEnd(state.trailers || {}) + } else if (!state.request.aborted && !state.request.completed) { + // The stream closed without a complete response and without reporting an + // error. finalizeRequest() below frees the queue slot either way, so + // without this the request would simply vanish and its caller would never + // hear back. + util.errorRequest( + state.client, + state.request, + new InformationalError('HTTP/2: stream closed before the response was complete') + ) } finalizeRequest(state) @@ -1286,6 +1411,38 @@ function onEnd () { } } +function retryRefusedStream (stream, state) { + const { client, request } = state + + if ( + state.responseReceived || + request.aborted || + request.completed || + request[kRefusedStreamRetry] || + !canReplayRequest(request) + ) { + return false + } + + // RFC 9113 section 8.7 permits retrying REFUSED_STREAM, but says clients + // SHOULD NOT automatically retry the same request more than once. + request[kRefusedStreamRetry] = true + + // 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. + detachRequestStreamForClose(request) + state.stream = null + state.requestFinalized = true + + completeRequest(client, request) + client[kQueue].splice(client[kPendingIdx], 0, request) + client[kResume]() + + return true +} + function onError (err) { const stream = this const state = stream[kRequestStreamState] @@ -1295,6 +1452,18 @@ function onError (err) { } stream.off('error', onError) + + if (typeof stream.rstCode === 'number' && stream.rstCode !== NGHTTP2_NO_ERROR) { + err.http2ErrorCode = stream.rstCode + } + + if ( + stream.rstCode === NGHTTP2_REFUSED_STREAM && + retryRefusedStream(stream, state) + ) { + return + } + state.abort(err) } diff --git a/deps/undici/src/lib/dispatcher/client.js b/deps/undici/src/lib/dispatcher/client.js index 8a4f65171bd9..d620e8310cfb 100644 --- a/deps/undici/src/lib/dispatcher/client.js +++ b/deps/undici/src/lib/dispatcher/client.js @@ -53,10 +53,8 @@ const { kHTTPContext, kMaxConcurrentStreams, kHostAuthority, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kResume, - kPingInterval + kHTTP2Options } = require('../core/symbols.js') const connectH1 = require('./client-h1.js') const connectH2 = require('./client-h2.js') @@ -76,6 +74,16 @@ function getPipelining (client) { return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1 } +let h2NamespaceOptsWarning = false +function emitH2OptionsNamespaceWarning (optName) { + if (h2NamespaceOptsWarning === true) return + + process.emitWarning(`Use h2Options.${optName} instead. ${optName} for H2 will be deprecated in future major.`, { + code: 'UNDICI-H2-OPTIONS' + }) + h2NamespaceOptsWarning = true +} + // Protocol-aware dispatch ceiling. h1 RFC7230 pipelining is unrelated to h2 // stream multiplexing — over h2 the ceiling is the (server-confirmed) // maxConcurrentStreams. Before a context is attached we use the h1 @@ -128,7 +136,8 @@ class Client extends DispatcherBase { initialWindowSize, connectionWindowSize, pingInterval, - webSocket + webSocket, + h2Options } = {}) { if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -216,24 +225,55 @@ class Client extends DispatcherBase { throw new InvalidArgumentError('allowH2 must be a valid boolean value') } - if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { - throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0') - } + // We validate only if allowH2 is enabled or null (enabled by default) + if (allowH2 !== false) { + // Prioritise new h2Options object, otherwise fallback to prior configuration options + if (h2Options != null) { + if (h2Options.useH2c != null && typeof h2Options.useH2c !== 'boolean') { + throw new InvalidArgumentError('h2Options.useH2c must be a valid boolean value') + } - if (useH2c != null && typeof useH2c !== 'boolean') { - throw new InvalidArgumentError('useH2c must be a valid boolean value') - } + if (h2Options.settings?.initialWindowSize != null && (!Number.isInteger(h2Options.settings.initialWindowSize) || h2Options.settings.initialWindowSize < 1)) { + throw new InvalidArgumentError('h2Options.settings.initialWindowSize must be a positive integer, greater than 0') + } - if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { - throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0') - } + if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.maxConcurrentStreams < 1)) { + throw new InvalidArgumentError('h2Options.maxConcurrentStreams must be a positive integer, greater than 0') + } - if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { - throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0') - } + if (h2Options.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) { + throw new InvalidArgumentError('h2Options.connectionWindowSize must be a positive integer, greater than 0') + } + + if (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== 'number' || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) { + throw new InvalidArgumentError('h2Options.pingInterval must be a positive integer, greater or equal to 0') + } + } else { + if (useH2c != null && typeof useH2c !== 'boolean') { + emitH2OptionsNamespaceWarning('useH2c') + throw new InvalidArgumentError('useH2c must be a valid boolean value') + } + + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { + emitH2OptionsNamespaceWarning('maxConcurrentStreams') + throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0') + } - if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) { - throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') + if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { + emitH2OptionsNamespaceWarning('initialWindowSize') + throw new InvalidArgumentError('initialWindowSize must be a positive integer, greater than 0') + } + + if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { + emitH2OptionsNamespaceWarning('connectionWindowSize') + throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0') + } + + if (pingInterval != null && (typeof pingInterval !== 'number' || !Number.isInteger(pingInterval) || pingInterval < 0)) { + emitH2OptionsNamespaceWarning('pingInterval') + throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') + } + } } super({ webSocket }) @@ -243,8 +283,8 @@ class Client extends DispatcherBase { ...tls, maxCachedSessions, allowH2, - useH2c, socketPath, + useH2c: h2Options?.useH2c ?? useH2c, timeout: connectTimeout, ...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined), ...connect @@ -280,16 +320,20 @@ class Client extends DispatcherBase { this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1 this[kHTTPContext] = null // h2 - this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100 // Max peerConcurrentStreams for a Node h2 server - // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance: - // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1) - // Allows more data to be sent before requiring acknowledgment, improving throughput - // especially on high-latency networks. This matches common production HTTP/2 servers. - // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) - // Provides better flow control for the entire connection across multiple streams. - this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144 - this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288 - this[kPingInterval] = pingInterval != null ? pingInterval : 60e3 // Default ping interval for h2 - 1 minute + this[kHTTP2Options] = { + pingInterval: h2Options?.pingInterval ?? pingInterval ?? 60e3, + connectionWindowSize: h2Options?.connectionWindowSize ?? connectionWindowSize ?? 524288, + maxConcurrentStreams: h2Options?.maxConcurrentStreams ?? maxConcurrentStreams ?? 100, // Max peerConcurrentStreams for a Node h2 server + sessionOptions: { + // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance: + // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1) + // Allows more data to be sent before requiring acknowledgment, improving throughput + // especially on high-latency networks. This matches common production HTTP/2 servers. + // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) + // Provides better flow control for the entire connection across multiple streams. + initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144 + } + } // kQueue is built up of 3 sections separated by // the kRunningIdx and kPendingIdx indices. @@ -672,6 +716,7 @@ function _resume (client, sync) { } if (!client[kHTTPContext]) { + client[kServerName] = request.servername connect(client) return } diff --git a/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js b/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js index f88437f1936a..51c50601714b 100644 --- a/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js +++ b/deps/undici/src/lib/dispatcher/env-http-proxy-agent.js @@ -65,9 +65,10 @@ class EnvHttpProxyAgent extends DispatcherBase { #getProxyAgentForUrl (url) { let { protocol, host: hostname, port } = url - // Stripping ports in this way instead of using parsedUrl.hostname to make - // sure that the brackets around IPv6 addresses are kept. - hostname = hostname.replace(/:\d*$/, '').toLowerCase() + // Remove the port suffix (e.g. ":8080") and then strip surrounding + // brackets from IPv6 literals (e.g. "[::1]" -> "::1") so that the + // result matches the unbracketed form stored by #parseNoProxy. + hostname = hostname.replace(/:\d*$/, '').replace(/^\[(.+)\]$/, '$1').toLowerCase() port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0 if (!this.#shouldProxy(hostname, port)) { return this[kNoProxyAgent] @@ -119,11 +120,32 @@ class EnvHttpProxyAgent extends DispatcherBase { if (!entry) { continue } - const parsed = entry.match(/^(.+):(\d+)$/) + + // An IPv6 entry with a port must be bracketed: [::1]:443. + // A bare IPv6 address like ::1 contains colons that must not be + // confused with a host:port separator, so we handle it separately. + let hostname, port + const ipv6WithPort = entry.match(/^\[(.+)\]:(\d+)$/) + if (ipv6WithPort) { + hostname = ipv6WithPort[1] + port = Number.parseInt(ipv6WithPort[2], 10) + } else { + // Bracketed IPv6 without port, or plain hostname[:port], or bare IPv6. + // Strip optional brackets first. + const unbracketed = entry.replace(/^\[(.+)\]$/, '$1') + // A bare IPv6 address contains multiple colons; a hostname:port entry + // has exactly one colon followed by digits. Only attempt host:port + // splitting when that is unambiguously the case. + const colonCount = (unbracketed.match(/:/g) || []).length + const parsed = colonCount === 1 && unbracketed.match(/^(.+):(\d+)$/) + hostname = parsed ? parsed[1] : unbracketed + port = parsed ? Number.parseInt(parsed[2], 10) : 0 + } + noProxyEntries.push({ // strip leading dot or asterisk with dot - hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, '').toLowerCase(), - port: parsed ? Number.parseInt(parsed[2], 10) : 0 + hostname: hostname.replace(/^\*?\./, '').toLowerCase(), + port }) } diff --git a/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js b/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js index bb46b7cfa184..909c7f502478 100644 --- a/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js +++ b/deps/undici/src/lib/dispatcher/socks5-proxy-agent.js @@ -6,7 +6,7 @@ let tls // include tls conditionally since it is not always available const DispatcherBase = require('./dispatcher-base') const { InvalidArgumentError } = require('../core/errors') const { Socks5Client, STATES } = require('../core/socks5-client') -const { kDispatch, kClose, kDestroy } = require('../core/symbols') +const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symbols') const Pool = require('./pool') const buildConnector = require('../core/connect') const { debuglog } = require('node:util') @@ -226,6 +226,20 @@ class Socks5ProxyAgent extends DispatcherBase { } }) this[kPools].set(originKey, pool) + + const closePoolIfUnused = () => { + if (this[kPools].get(originKey) !== pool || pool[kConnected] > 0 || pool[kBusy]) { + return + } + + this[kPools].delete(originKey) + if (!pool.destroyed) { + pool.close() + } + } + + pool.on('disconnect', closePoolIfUnused) + pool.on('connectionError', closePoolIfUnused) } // Dispatch the request through the per-origin pool diff --git a/deps/undici/src/lib/handler/retry-handler.js b/deps/undici/src/lib/handler/retry-handler.js index 3fc26229a1cc..c098b510c26c 100644 --- a/deps/undici/src/lib/handler/retry-handler.js +++ b/deps/undici/src/lib/handler/retry-handler.js @@ -241,6 +241,11 @@ class RetryHandler { } onResponseStart (controller, statusCode, headers, statusMessage) { + if (statusCode < 200) { + this.handler.onResponseStart?.(this.controllerProxy, statusCode, headers, statusMessage) + return + } + this.error = null this.retryCount += 1 this.statusCode = statusCode @@ -305,7 +310,7 @@ class RetryHandler { // First time we receive 206 const range = parseRangeHeader(headers['content-range']) - if (range == null) { + if (range == null || range.end == null) { this.headersSent = true this.handler.onResponseStart?.( this.controllerProxy, @@ -330,7 +335,7 @@ class RetryHandler { } // We make our best to checkpoint the body for further range headers - if (this.end == null) { + if (this.end == null && this.opts.method !== 'HEAD') { const contentLength = headers['content-length'] this.end = contentLength != null ? Number(contentLength) - 1 : null } diff --git a/deps/undici/src/lib/interceptor/cache.js b/deps/undici/src/lib/interceptor/cache.js index f50c1b7b67dc..2d7d01f130aa 100644 --- a/deps/undici/src/lib/interceptor/cache.js +++ b/deps/undici/src/lib/interceptor/cache.js @@ -540,13 +540,16 @@ module.exports = (opts = {}) => { return dispatch => { return (opts, handler) => { - if (!opts.origin || arrayIncludes(safeMethodsToNotCache, opts.method)) { - // Not a method we want to cache or we don't have the origin, skip + if (arrayIncludes(safeMethodsToNotCache, opts.method)) { + // Not a method we want to cache, skip return dispatch(opts, handler) } // Check if origin is in whitelist if (origins !== undefined) { + if (!opts.origin) { + return dispatch(opts, handler) + } const requestOrigin = opts.origin.toString().toLowerCase() let isAllowed = false diff --git a/deps/undici/src/lib/interceptor/deduplicate.js b/deps/undici/src/lib/interceptor/deduplicate.js index e81525ac5ea7..bacfeb3fb37e 100644 --- a/deps/undici/src/lib/interceptor/deduplicate.js +++ b/deps/undici/src/lib/interceptor/deduplicate.js @@ -59,7 +59,7 @@ module.exports = (opts = {}) => { return dispatch => { return (opts, handler) => { - if (!opts.origin || methods.includes(opts.method) === false) { + if (opts.upgrade || methods.includes(opts.method) === false) { return dispatch(opts, handler) } diff --git a/deps/undici/src/lib/llhttp/wasm_build_env.txt b/deps/undici/src/lib/llhttp/wasm_build_env.txt index e4cfa0c37626..82569ca62dd1 100644 --- a/deps/undici/src/lib/llhttp/wasm_build_env.txt +++ b/deps/undici/src/lib/llhttp/wasm_build_env.txt @@ -1,5 +1,5 @@ -> undici@8.9.0 build:wasm +> undici@8.10.0 build:wasm > node build/wasm.js --docker > docker run --rm --platform=linux/x86_64 --user 1001:1001 --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/lib/llhttp,target=/home/node/build/lib/llhttp --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/build,target=/home/node/build/build --mount type=bind,source=/home/runner/work/node/node/deps/undici/src/deps,target=/home/node/build/deps -t ghcr.io/nodejs/wasm-builder@sha256:975f391d907e42a75b8c72eb77c782181e941608687d4d8694c3e9df415a0970 node build/wasm.js diff --git a/deps/undici/src/lib/mock/mock-utils.js b/deps/undici/src/lib/mock/mock-utils.js index 111a860e9aee..e43f7218d0b4 100644 --- a/deps/undici/src/lib/mock/mock-utils.js +++ b/deps/undici/src/lib/mock/mock-utils.js @@ -17,6 +17,7 @@ const { } } = require('node:util') const { InvalidArgumentError } = require('../core/errors') +const requestAborted = Symbol('request aborted') function matchValue (match, value) { if (typeof match === 'string') { @@ -153,6 +154,11 @@ function getResponseData (data) { return data } else if (data instanceof ArrayBuffer) { return data + } else if (ArrayBuffer.isView(data)) { + // A DataView, or any non-Uint8Array typed array, is a byte container + // rather than a plain object. Buffer.from() cannot read one directly, so + // expose the bytes it covers instead of letting it reach JSON.stringify. + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) } else if (typeof data === 'object') { return JSON.stringify(data) } else if (data) { @@ -225,9 +231,15 @@ function deleteMockDispatch (mockDispatches, key) { } /** - * @param {string} path Path to remove trailing slash from + * @param {string|RegExp|Function} path Path, or path matcher, to remove trailing slash from */ function removeTrailingSlash (path) { + // Registered path matchers may be a RegExp or a function, which have no + // trailing slash to strip; hand those back for matchValue to apply. + if (typeof path !== 'string') { + return path + } + while (path.endsWith('/')) { path = path.slice(0, -1) } @@ -302,9 +314,13 @@ function mockDispatch (opts, handler) { mockDispatch.consumed = !mockDispatch.persist && timesInvoked >= times mockDispatch.pending = timesInvoked < times + const hasBodyHooks = typeof handler.onBodySent === 'function' || + typeof handler.onRequestSent === 'function' + // Here's where we resolve a callback if a callback is present for the dispatch data. - if (mockDispatch.data.callback) { - const callbackResult = mockDispatch.data.callback(opts) + if (mockDispatch.data.callback && (!hasBodyHooks || opts.body == null)) { + const { callback, ...responseDefaults } = mockDispatch.data + const callbackResult = callback(opts) // An asynchronous reply options callback resolves to the reply data, so // the dispatch can only continue once the returned promise settles. @@ -313,18 +329,25 @@ function mockDispatch (opts, handler) { if (isPromise(callbackResult)) { callbackResult.then( (resolvedData) => { - mockDispatch.data = { ...mockDispatch.data, ...resolvedData } + if (resolvedData == null || typeof resolvedData !== 'object') { + handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object')) + return + } + mockDispatch.data = { ...responseDefaults, ...resolvedData } dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler) }, (error) => { - deleteMockDispatch(mockDispatches, key) handler.onResponseError(null, error) } ) return true } - mockDispatch.data = { ...mockDispatch.data, ...callbackResult } + if (callbackResult == null || typeof callbackResult !== 'object') { + throw new InvalidArgumentError('reply options callback must return an object') + } + + mockDispatch.data = { ...responseDefaults, ...callbackResult } } return dispatchMockReply(mockDispatches, mockDispatch, key, opts, handler) @@ -335,12 +358,12 @@ function mockDispatch (opts, handler) { */ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { // Parse mockDispatch data - const { data: { statusCode, data, headers, trailers, error }, delay } = mockDispatch + const { data: response, delay } = mockDispatch // If specified, trigger dispatch error - if (error !== null) { + if (response.error !== null) { deleteMockDispatch(mockDispatches, key) - handler.onResponseError(null, error) + handler.onResponseError(null, response.error) return true } @@ -375,32 +398,107 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { } } + let replyOpts = opts + const dispatches = mockDispatches + // Call onRequestStart to allow the handler to receive the controller handler.onRequestStart?.(controller, null) - // Handle the request with a delay if necessary - if (typeof delay === 'number' && delay > 0) { - timer = setTimeout(() => { - timer = null - handleReply(mockDispatches) - }, delay) - } else { - handleReply(mockDispatches) + if (aborted) { + return true + } + + const requestBody = dispatchRequestBody(opts.body, handler, controller, () => aborted) + + if (isPromise(requestBody)) { + requestBody.then((body) => { + if (body === requestAborted) { + return + } + + if (body !== opts.body) { + replyOpts = { ...opts, body } + } + + sendReply() + }, (error) => controller.abort(error)) + return true + } + + if (requestBody === requestAborted) { + return true + } + + if (requestBody !== opts.body) { + replyOpts = { ...opts, body: requestBody } + } + + sendReply() + + function sendReply () { + if (response.callback) { + const { callback, ...responseDefaults } = response + let callbackResult + try { + callbackResult = callback(replyOpts) + } catch (err) { + deleteMockDispatch(mockDispatches, key) + handler.onResponseError(null, err) + return + } + + if (isPromise(callbackResult)) { + callbackResult.then( + (resolvedData) => { + if (resolvedData == null || typeof resolvedData !== 'object') { + handler.onResponseError(null, new InvalidArgumentError('reply options callback must return an object')) + return + } + mockDispatch.data = { ...responseDefaults, ...resolvedData } + handleReply(dispatches, mockDispatch.data) + }, + (err) => { + handler.onResponseError(null, err) + } + ) + return + } + + if (callbackResult == null || typeof callbackResult !== 'object') { + throw new InvalidArgumentError('reply options callback must return an object') + } + + mockDispatch.data = { ...responseDefaults, ...callbackResult } + handleReply(dispatches, mockDispatch.data) + return + } + + // Handle the request with a delay if necessary + if (typeof delay === 'number' && delay > 0) { + timer = setTimeout(() => { + timer = null + handleReply(dispatches) + }, delay) + } else { + handleReply(dispatches) + } } - function handleReply (mockDispatches, _data = data) { + function handleReply (mockDispatches, _response = response) { // Don't send response if the request was aborted if (aborted) { return } + const { statusCode, data, headers, trailers } = _response + // fetch's HeadersList is a 1D string array const optsHeaders = Array.isArray(opts.headers) ? buildHeadersFromArray(opts.headers) : opts.headers - const body = typeof _data === 'function' - ? _data({ ...opts, headers: optsHeaders }) - : _data + const body = typeof data === 'function' + ? data({ ...replyOpts, headers: optsHeaders }) + : data // util.types.isPromise is likely needed for jest. if (isPromise(body)) { @@ -409,7 +507,7 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { // synchronously throw the error, which breaks some tests. // Rather, we wait for the callback to resolve if it is a // promise, and then re-run handleReply with the new body. - return body.then((newData) => handleReply(mockDispatches, newData)) + return body.then((newData) => handleReply(mockDispatches, { ..._response, data: newData })) } // Check again if aborted after async body resolution @@ -418,8 +516,8 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { } const responseData = getResponseData(body) - const responseHeaders = generateKeyValues(headers) - const responseTrailers = generateKeyValues(trailers) + const responseHeaders = generateKeyValues(headers ?? {}) + const responseTrailers = generateKeyValues(trailers ?? {}) // Update the controller with response data controller.rawHeaders = responseHeaders @@ -434,6 +532,97 @@ function dispatchMockReply (mockDispatches, mockDispatch, key, opts, handler) { return true } +function dispatchRequestBody (body, handler, controller, isAborted) { + if (typeof handler.onBodySent !== 'function' && typeof handler.onRequestSent !== 'function') { + return body + } + + if (body == null) { + return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted + } + + if (body && typeof body[Symbol.asyncIterator] === 'function') { + return dispatchAsyncIterableBody(body, handler, controller, isAborted) + } + + if (isIterableBody(body)) { + const chunks = [] + + for (const chunk of body) { + if (isAborted()) { + return requestAborted + } + chunks.push(chunk) + if (!callOnBodySent(handler, controller, chunk) || isAborted()) { + return requestAborted + } + } + + return callOnRequestSent(handler, controller, isAborted) ? chunks : requestAborted + } + + if (isAborted()) { + return requestAborted + } + + if (!callOnBodySent(handler, controller, body)) { + return requestAborted + } + + return callOnRequestSent(handler, controller, isAborted) ? body : requestAborted +} + +async function dispatchAsyncIterableBody (body, handler, controller, isAborted) { + const chunks = [] + + for await (const chunk of body) { + if (isAborted()) { + return requestAborted + } + chunks.push(chunk) + if (!callOnBodySent(handler, controller, chunk) || isAborted()) { + return requestAborted + } + } + + if (!callOnRequestSent(handler, controller, isAborted)) { + return requestAborted + } + + return { + async * [Symbol.asyncIterator] () { + yield * chunks + } + } +} + +function callOnBodySent (handler, controller, chunk) { + try { + handler.onBodySent?.(chunk) + return true + } catch (error) { + controller.abort(error) + return false + } +} + +function callOnRequestSent (handler, controller, isAborted) { + try { + handler.onRequestSent?.() + return !isAborted() + } catch (error) { + controller.abort(error) + return false + } +} + +function isIterableBody (body) { + return typeof body !== 'string' && + !Buffer.isBuffer(body) && + !ArrayBuffer.isView(body) && + typeof body[Symbol.iterator] === 'function' +} + function buildMockDispatch () { const agent = this[kMockAgent] const origin = this[kOrigin] diff --git a/deps/undici/src/lib/util/cache.js b/deps/undici/src/lib/util/cache.js index d156731d1b5d..1fac28af5d97 100644 --- a/deps/undici/src/lib/util/cache.js +++ b/deps/undici/src/lib/util/cache.js @@ -148,9 +148,7 @@ function getMalformedRestrictiveDirectiveName (key) { * @param {import('../../types/dispatcher.d.ts').default.DispatchOptions} opts */ function makeCacheKey (opts) { - if (!opts.origin) { - throw new Error('opts.origin is undefined') - } + const origin = opts.origin ? opts.origin.toString() : '' let fullPath = opts.path || '/' @@ -159,7 +157,7 @@ function makeCacheKey (opts) { } return { - origin: opts.origin.toString(), + origin, method: opts.method, path: fullPath, headers: opts.headers diff --git a/deps/undici/src/lib/web/websocket/websocket.js b/deps/undici/src/lib/web/websocket/websocket.js index e473a1bc4917..45dbce1bea93 100644 --- a/deps/undici/src/lib/web/websocket/websocket.js +++ b/deps/undici/src/lib/web/websocket/websocket.js @@ -25,6 +25,9 @@ const { SendQueue } = require('./sender') const { WebsocketFrameSend } = require('./frame') const { channels } = require('../../core/diagnostics') +const kRef = Symbol.for('nodejs.ref') +const kUnref = Symbol.for('nodejs.unref') + function getSocketAddress (socket) { if (typeof socket?.address === 'function') { return socket.address() @@ -68,6 +71,7 @@ class WebSocket extends EventTarget { #bufferedAmount = 0 #protocol = '' #extensions = '' + #refed = true /** @type {SendQueue} */ #sendQueue @@ -194,6 +198,20 @@ class WebSocket extends EventTarget { this.#binaryType = 'blob' } + [kRef] () { + webidl.brandCheck(this, WebSocket) + + this.#refed = true + this.#handler.socket?.ref?.() + } + + [kUnref] () { + webidl.brandCheck(this, WebSocket) + + this.#refed = false + this.#handler.socket?.unref?.() + } + /** * @see https://websockets.spec.whatwg.org/#dom-websocket-close * @param {number|undefined} code @@ -468,6 +486,10 @@ class WebSocket extends EventTarget { // once this happens, the connection is open this.#handler.socket = response.socket + if (!this.#refed) { + this.#handler.socket.unref?.() + } + // Get options from dispatcher options const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize diff --git a/deps/undici/src/package-lock.json b/deps/undici/src/package-lock.json index ecc36b1137b0..28d9db8de52e 100644 --- a/deps/undici/src/package-lock.json +++ b/deps/undici/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "undici", - "version": "8.9.0", + "version": "8.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "undici", - "version": "8.9.0", + "version": "8.10.0", "license": "MIT", "devDependencies": { "@fastify/busboy": "3.2.0", diff --git a/deps/undici/src/package.json b/deps/undici/src/package.json index f6feb3f0a832..270b572dfc89 100644 --- a/deps/undici/src/package.json +++ b/deps/undici/src/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "8.9.0", + "version": "8.10.0", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { diff --git a/deps/undici/src/types/client.d.ts b/deps/undici/src/types/client.d.ts index e3b121962ef0..064d3d69f2c1 100644 --- a/deps/undici/src/types/client.d.ts +++ b/deps/undici/src/types/client.d.ts @@ -1,10 +1,15 @@ import { URL } from 'node:url' +import { SessionOptions } from 'node:http2' import Dispatcher from './dispatcher' import buildConnector from './connector' import TClientStats from './client-stats' type ClientConnectOptions = Omit, 'origin'> +// TODO: Pendings +// 1. Reflect this on Client instantiation +// 2. Client H2 should use this namespaced options instead. + /** * A basic HTTP/1.1 client, mapped on top a single TCP/TLS connection. Pipelining is disabled by default. */ @@ -87,23 +92,31 @@ export declare namespace Client { /** * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. * @default 100 + * @deprecated Use h2Options.maxConcurrentStreams instead */ maxConcurrentStreams?: number; /** * @description Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). * @default 262144 + * @deprecated Use h2Options.settings.initialWindowSize instead */ initialWindowSize?: number; /** * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize). * @default 524288 + * @deprecated Use h2Options.connectionWindowSize instead */ connectionWindowSize?: number; /** * @description Time interval between PING frames dispatch * @default 60000 + * @deprecated Use h2Options.connectionWindowSize instead */ pingInterval?: number; + /** + * @description HTTP/2 configuration options + */ + h2Options?: Client.H2Options; } export interface SocketInfo { localAddress?: string @@ -129,6 +142,33 @@ export declare namespace Client { */ maxPayloadSize?: number; } + + export interface H2Options extends Omit { + /** + * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize). + * @default 524288 + */ + connectionWindowSize?: number; + /** + * @description Time interval between PING frames dispatch + * @default 60000 + */ + pingInterval?: number; + /** + * @description Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. + * @default 100 + */ + maxConcurrentStreams?: number; + /** + * @description Enable support for H2C (plain text) + * @default false + */ + useH2c?: boolean; + /** + * @description SETTINGS frame object. Default to 'node:http2' defaults + */ + settings?: Omit + } } export default Client diff --git a/deps/undici/undici.js b/deps/undici/undici.js index c0505480ffe9..bb398a0686ce 100644 --- a/deps/undici/undici.js +++ b/deps/undici/undici.js @@ -574,6 +574,7 @@ var require_symbols = __commonJS({ kCounter: /* @__PURE__ */ Symbol("socket request counter"), kMaxResponseSize: /* @__PURE__ */ Symbol("max response size"), kHTTP2Session: /* @__PURE__ */ Symbol("http2Session"), + kHTTP2Options: /* @__PURE__ */ Symbol("http2 options"), kHTTP2SessionState: /* @__PURE__ */ Symbol("http2Session state"), kRetryHandlerDefaultRetry: /* @__PURE__ */ Symbol("retry agent default retry"), kConstruct: /* @__PURE__ */ Symbol("constructable"), @@ -3266,14 +3267,26 @@ var require_connect = __commonJS({ } else { assert(!httpSocket, "httpSocket can only be sent on TLS update"); port = port || 80; - socket = net.connect({ + const connectOptions = { highWaterMark: 64 * 1024, // Same as nodejs fs streams. ...options, localAddress, port, host: hostname - }); + }; + const family = net.isIP(hostname); + if (family !== 0 && servername && servername !== hostname) { + connectOptions.host = servername; + connectOptions.lookup = (_hostname, lookupOptions, cb) => { + if (lookupOptions.all) { + cb(null, [{ address: hostname, family }]); + } else { + cb(null, hostname, family); + } + }; + } + socket = net.connect(connectOptions); if (useH2c === true) { socket.alpnProtocol = "h2"; } @@ -7843,7 +7856,7 @@ var require_client_h1 = __commonJS({ __name(onSocketClose, "onSocketClose"); function clearIdleSocketValidation(socket) { if (socket[kIdleSocketValidationTimeout]) { - clearImmediate(socket[kIdleSocketValidationTimeout]); + clearTimeout(socket[kIdleSocketValidationTimeout]); socket[kIdleSocketValidationTimeout] = null; } socket[kIdleSocketValidation] = 0; @@ -7851,13 +7864,13 @@ var require_client_h1 = __commonJS({ __name(clearIdleSocketValidation, "clearIdleSocketValidation"); function scheduleIdleSocketValidation(client, socket) { socket[kIdleSocketValidation] = 1; - socket[kIdleSocketValidationTimeout] = setImmediate(() => { + socket[kIdleSocketValidationTimeout] = setTimeout(() => { socket[kIdleSocketValidationTimeout] = null; socket[kIdleSocketValidation] = 2; if (client[kSocket] === socket && !socket.destroyed) { client[kResume](); } - }); + }, 0); socket[kIdleSocketValidationTimeout].unref?.(); } __name(scheduleIdleSocketValidation, "scheduleIdleSocketValidation"); @@ -8394,10 +8407,7 @@ var require_client_h2 = __commonJS({ kStrictContentLength, kOnError, kMaxConcurrentStreams, - kPingInterval, kHTTP2Session, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kHostAuthority, kResume, kSize, @@ -8409,7 +8419,8 @@ var require_client_h2 = __commonJS({ kEnableConnectProtocol, kRemoteSettings, kHTTP2Stream, - kHTTP2SessionState + kHTTP2SessionState, + kHTTP2Options } = require_symbols(); var { channels } = require_diagnostics(); var kOpenStreams = /* @__PURE__ */ Symbol("open streams"); @@ -8418,6 +8429,9 @@ var require_client_h2 = __commonJS({ var kRequestStreamCleanup = /* @__PURE__ */ Symbol("request stream cleanup"); var kRequestStreamState = /* @__PURE__ */ Symbol("request stream state"); var kReceivedGoAway = /* @__PURE__ */ Symbol("received goaway"); + var kGoAwayReplayAttempts = /* @__PURE__ */ Symbol("goaway replay attempts"); + var kRefusedStreamRetry = /* @__PURE__ */ Symbol("refused stream retry"); + var MAX_GOAWAY_REPLAY_ATTEMPTS = 1; var extractBody; var http2; try { @@ -8523,11 +8537,17 @@ var require_client_h2 = __commonJS({ } } __name(completeRequest, "completeRequest"); - function canRetryRequestAfterGoAway(request) { + function canReplayRequest(request) { const { body } = request; return body == null || util.isBuffer(body) || util.isBlobLike(body); } - __name(canRetryRequestAfterGoAway, "canRetryRequestAfterGoAway"); + __name(canReplayRequest, "canReplayRequest"); + function registerGoAwayRefusal(request) { + const attempts = (request[kGoAwayReplayAttempts] ?? 0) + 1; + request[kGoAwayReplayAttempts] = attempts; + return attempts <= MAX_GOAWAY_REPLAY_ATTEMPTS; + } + __name(registerGoAwayRefusal, "registerGoAwayRefusal"); function closeStream(stream, code = NGHTTP2_REFUSED_STREAM) { if (stream != null && !stream.destroyed && !stream.closed) { try { @@ -8540,16 +8560,32 @@ var require_client_h2 = __commonJS({ function detachRequestStreamForClose(request) { const stream = request[kRequestStream]; clearRequestStream(request); + severRequestStream(stream); return stream; } __name(detachRequestStreamForClose, "detachRequestStreamForClose"); + function severRequestStream(stream) { + if (stream == null || stream[kRequestStreamState] == null) { + return; + } + stream[kRequestStreamState] = null; + stream.off("close", completeRequestStream); + stream.off("close", onUpgradeStreamClose); + if (stream[kHTTP2Session] != null) { + closeStreamSession(stream); + } + if (!stream.destroyed && !stream.closed) { + stream.once("error", noop); + } + } + __name(severRequestStream, "severRequestStream"); function connectH2(client, socket) { client[kSocket] = socket; - const http2InitialWindowSize = client[kHTTP2InitialWindowSize]; - const http2ConnectionWindowSize = client[kHTTP2ConnectionWindowSize]; + const http2InitialWindowSize = client[kHTTP2Options].sessionOptions?.initialWindowSize; + const http2ConnectionWindowSize = client[kHTTP2Options].connectionWindowSize; const session = http2.connect(client[kUrl], { createConnection: /* @__PURE__ */ __name(() => socket, "createConnection"), - peerMaxConcurrentStreams: client[kMaxConcurrentStreams], + peerMaxConcurrentStreams: client[kHTTP2Options].maxConcurrentStreams, settings: { // TODO(metcoder95): add support for PUSH enablePush: false, @@ -8562,13 +8598,16 @@ var require_client_h2 = __commonJS({ session[kSocket] = socket; session[kHTTP2SessionState] = { idleTimeout: null, + // Armed while the peer advertises MAX_CONCURRENT_STREAMS = 0 and we have + // work that cannot start. See setNoStreamsTimeout. + noStreamsTimeout: null, // Sockets start out ref'd. Session ref/unref proxies to the socket, so a // single cached flag lets us skip redundant uv ref/unref calls, provided // every ref/unref of the session or its socket goes through // refH2Session/unrefH2Session. refed: true, ping: { - interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref() + interval: client[kHTTP2Options].pingInterval === 0 ? null : setInterval(onHttp2SendPing, client[kHTTP2Options].pingInterval, session).unref() } }; session[kReceivedGoAway] = false; @@ -8676,9 +8715,52 @@ var require_client_h2 = __commonJS({ } else { clearHttp2IdleTimeout(session); } + if (client[kMaxConcurrentStreams] === 0 && client[kRunning] === 0 && client[kPending] > 0) { + setNoStreamsTimeout(session); + } else { + clearNoStreamsTimeout(session); + } } } __name(resumeH2, "resumeH2"); + function clearNoStreamsTimeout(session) { + const state = session[kHTTP2SessionState]; + if (state?.noStreamsTimeout != null) { + clearTimeout(state.noStreamsTimeout); + state.noStreamsTimeout = null; + } + } + __name(clearNoStreamsTimeout, "clearNoStreamsTimeout"); + function setNoStreamsTimeout(session) { + const client = session[kClient]; + const state = session[kHTTP2SessionState]; + const timeout = client[kHeadersTimeout]; + if (!timeout || state.noStreamsTimeout != null) { + return; + } + state.noStreamsTimeout = setTimeout(onNoStreamsTimeout, timeout, session).unref(); + } + __name(setNoStreamsTimeout, "setNoStreamsTimeout"); + function onNoStreamsTimeout(session) { + const client = session[kClient]; + const state = session[kHTTP2SessionState]; + state.noStreamsTimeout = null; + if (client[kHTTP2Session] !== session || client[kMaxConcurrentStreams] !== 0 || client[kRunning] !== 0 || client[kPending] === 0) { + return; + } + const err = new HeadersTimeoutError( + `HTTP/2: server did not accept a new stream within ${client[kHeadersTimeout]}` + ); + const requests = client[kQueue].splice(client[kPendingIdx]); + for (let i = 0; i < requests.length; i++) { + if (requests[i] != null) { + util.errorRequest(client, requests[i], err); + } + } + session[kError] = err; + resetHttp2Session(session, err); + } + __name(onNoStreamsTimeout, "onNoStreamsTimeout"); function clearHttp2IdleTimeout(session) { const state = session[kHTTP2SessionState]; if (state?.idleTimeout != null) { @@ -8794,7 +8876,7 @@ var require_client_h2 = __commonJS({ const request = client[kQueue][i]; if (request != null) { streamsToClose.push(detachRequestStreamForClose(request)); - if (canRetryRequestAfterGoAway(request)) { + if (canReplayRequest(request) && registerGoAwayRefusal(request)) { retriableRequests.push(request); } else { util.errorRequest(client, request, err); @@ -8815,6 +8897,7 @@ var require_client_h2 = __commonJS({ client[kHTTP2Session] = null; } clearHttp2IdleTimeout(this); + clearNoStreamsTimeout(this); if (!this.closed && !this.destroyed) { this.close(); } @@ -8832,6 +8915,7 @@ var require_client_h2 = __commonJS({ client[kHTTP2Session] = null; } clearHttp2IdleTimeout(this); + clearNoStreamsTimeout(this); if (state.ping.interval != null) { clearInterval(state.ping.interval); state.ping.interval = null; @@ -8915,6 +8999,12 @@ var require_client_h2 = __commonJS({ releaseRequestStream(this); if (state.pendingEnd && !state.request.aborted && !state.request.completed) { state.request.onResponseEnd(state.trailers || {}); + } else if (!state.request.aborted && !state.request.completed) { + util.errorRequest( + state.client, + state.request, + new InformationalError("HTTP/2: stream closed before the response was complete") + ); } finalizeRequest(state); closeStreamSession(this); @@ -9333,6 +9423,21 @@ var require_client_h2 = __commonJS({ } } __name(onEnd, "onEnd"); + function retryRefusedStream(stream, state) { + const { client, request } = state; + if (state.responseReceived || request.aborted || request.completed || request[kRefusedStreamRetry] || !canReplayRequest(request)) { + return false; + } + request[kRefusedStreamRetry] = true; + 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"); function onError(err) { const stream = this; const state = stream[kRequestStreamState]; @@ -9340,6 +9445,12 @@ var require_client_h2 = __commonJS({ return; } stream.off("error", onError); + if (typeof stream.rstCode === "number" && stream.rstCode !== NGHTTP2_NO_ERROR) { + err.http2ErrorCode = stream.rstCode; + } + if (stream.rstCode === NGHTTP2_REFUSED_STREAM && retryRefusedStream(stream, state)) { + return; + } state.abort(err); } __name(onError, "onError"); @@ -9633,10 +9744,8 @@ var require_client = __commonJS({ kHTTPContext, kMaxConcurrentStreams, kHostAuthority, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize, kResume, - kPingInterval + kHTTP2Options } = require_symbols(); var connectH1 = require_client_h1(); var connectH2 = require_client_h2(); @@ -9650,6 +9759,15 @@ var require_client = __commonJS({ return client[kPipelining] ?? client[kHTTPContext]?.defaultPipelining ?? 1; } __name(getPipelining, "getPipelining"); + var h2NamespaceOptsWarning = false; + function emitH2OptionsNamespaceWarning(optName) { + if (h2NamespaceOptsWarning === true) return; + process.emitWarning(`Use h2Options.${optName} instead. ${optName} for H2 will be deprecated in future major.`, { + code: "UNDICI-H2-OPTIONS" + }); + h2NamespaceOptsWarning = true; + } + __name(emitH2OptionsNamespaceWarning, "emitH2OptionsNamespaceWarning"); function getMaxConcurrent(client) { if (client[kHTTPContext]?.version === "h2") { return client[kMaxConcurrentStreams]; @@ -9697,7 +9815,8 @@ var require_client = __commonJS({ initialWindowSize, connectionWindowSize, pingInterval, - webSocket + webSocket, + h2Options } = {}) { if (keepAlive !== void 0) { throw new InvalidArgumentError("unsupported keepAlive, use pipelining=0 instead"); @@ -9760,20 +9879,45 @@ var require_client = __commonJS({ if (allowH2 != null && typeof allowH2 !== "boolean") { throw new InvalidArgumentError("allowH2 must be a valid boolean value"); } - if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== "number" || maxConcurrentStreams < 1)) { - throw new InvalidArgumentError("maxConcurrentStreams must be a positive integer, greater than 0"); - } - if (useH2c != null && typeof useH2c !== "boolean") { - throw new InvalidArgumentError("useH2c must be a valid boolean value"); - } - if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { - throw new InvalidArgumentError("initialWindowSize must be a positive integer, greater than 0"); - } - if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { - throw new InvalidArgumentError("connectionWindowSize must be a positive integer, greater than 0"); - } - if (pingInterval != null && (typeof pingInterval !== "number" || !Number.isInteger(pingInterval) || pingInterval < 0)) { - throw new InvalidArgumentError("pingInterval must be a positive integer, greater or equal to 0"); + if (allowH2 !== false) { + if (h2Options != null) { + if (h2Options.useH2c != null && typeof h2Options.useH2c !== "boolean") { + throw new InvalidArgumentError("h2Options.useH2c must be a valid boolean value"); + } + if (h2Options.settings?.initialWindowSize != null && (!Number.isInteger(h2Options.settings.initialWindowSize) || h2Options.settings.initialWindowSize < 1)) { + throw new InvalidArgumentError("h2Options.settings.initialWindowSize must be a positive integer, greater than 0"); + } + if (h2Options.maxConcurrentStreams != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.maxConcurrentStreams < 1)) { + throw new InvalidArgumentError("h2Options.maxConcurrentStreams must be a positive integer, greater than 0"); + } + if (h2Options.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) { + throw new InvalidArgumentError("h2Options.connectionWindowSize must be a positive integer, greater than 0"); + } + if (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== "number" || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) { + throw new InvalidArgumentError("h2Options.pingInterval must be a positive integer, greater or equal to 0"); + } + } else { + if (useH2c != null && typeof useH2c !== "boolean") { + emitH2OptionsNamespaceWarning("useH2c"); + throw new InvalidArgumentError("useH2c must be a valid boolean value"); + } + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== "number" || maxConcurrentStreams < 1)) { + emitH2OptionsNamespaceWarning("maxConcurrentStreams"); + throw new InvalidArgumentError("maxConcurrentStreams must be a positive integer, greater than 0"); + } + if (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { + emitH2OptionsNamespaceWarning("initialWindowSize"); + throw new InvalidArgumentError("initialWindowSize must be a positive integer, greater than 0"); + } + if (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { + emitH2OptionsNamespaceWarning("connectionWindowSize"); + throw new InvalidArgumentError("connectionWindowSize must be a positive integer, greater than 0"); + } + if (pingInterval != null && (typeof pingInterval !== "number" || !Number.isInteger(pingInterval) || pingInterval < 0)) { + emitH2OptionsNamespaceWarning("pingInterval"); + throw new InvalidArgumentError("pingInterval must be a positive integer, greater or equal to 0"); + } + } } super({ webSocket }); if (typeof connect2 !== "function") { @@ -9781,8 +9925,8 @@ var require_client = __commonJS({ ...tls, maxCachedSessions, allowH2, - useH2c, socketPath, + useH2c: h2Options?.useH2c ?? useH2c, timeout: connectTimeout, ...typeof autoSelectFamily === "boolean" ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : void 0, ...connect2 @@ -9817,10 +9961,21 @@ var require_client = __commonJS({ this[kClosedResolve] = null; this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1; this[kHTTPContext] = null; - this[kMaxConcurrentStreams] = maxConcurrentStreams != null ? maxConcurrentStreams : 100; - this[kHTTP2InitialWindowSize] = initialWindowSize != null ? initialWindowSize : 262144; - this[kHTTP2ConnectionWindowSize] = connectionWindowSize != null ? connectionWindowSize : 524288; - this[kPingInterval] = pingInterval != null ? pingInterval : 6e4; + this[kHTTP2Options] = { + pingInterval: h2Options?.pingInterval ?? pingInterval ?? 6e4, + connectionWindowSize: h2Options?.connectionWindowSize ?? connectionWindowSize ?? 524288, + maxConcurrentStreams: h2Options?.maxConcurrentStreams ?? maxConcurrentStreams ?? 100, + // Max peerConcurrentStreams for a Node h2 server + sessionOptions: { + // HTTP/2 window sizes are set to higher defaults than Node.js core for better performance: + // - initialWindowSize: 262144 (256KB) vs Node.js default 65535 (64KB - 1) + // Allows more data to be sent before requiring acknowledgment, improving throughput + // especially on high-latency networks. This matches common production HTTP/2 servers. + // - connectionWindowSize: 524288 (512KB) vs Node.js default (none set) + // Provides better flow control for the entire connection across multiple streams. + initialWindowSize: h2Options?.initialWindowSize ?? initialWindowSize ?? 262144 + } + }; this[kQueue] = []; this[kRunningIdx] = 0; this[kPendingIdx] = 0; @@ -10110,6 +10265,7 @@ var require_client = __commonJS({ return; } if (!client[kHTTPContext]) { + client[kServerName] = request.servername; connect(client); return; } @@ -11076,7 +11232,7 @@ var require_socks5_proxy_agent = __commonJS({ var DispatcherBase = require_dispatcher_base(); var { InvalidArgumentError } = require_errors(); var { Socks5Client, STATES } = require_socks5_client(); - var { kDispatch, kClose, kDestroy } = require_symbols(); + var { kBusy, kConnected, kDispatch, kClose, kDestroy } = require_symbols(); var Pool = require_pool(); var buildConnector = require_connect(); var { debuglog } = require("node:util"); @@ -11237,6 +11393,17 @@ var require_socks5_proxy_agent = __commonJS({ }, "connect") }); this[kPools].set(originKey, pool); + const closePoolIfUnused = /* @__PURE__ */ __name(() => { + if (this[kPools].get(originKey) !== pool || pool[kConnected] > 0 || pool[kBusy]) { + return; + } + this[kPools].delete(originKey); + if (!pool.destroyed) { + pool.close(); + } + }, "closePoolIfUnused"); + pool.on("disconnect", closePoolIfUnused); + pool.on("connectionError", closePoolIfUnused); } return pool[kDispatch](opts, handler); } catch (err) { @@ -11638,7 +11805,7 @@ var require_env_http_proxy_agent = __commonJS({ } #getProxyAgentForUrl(url) { let { protocol, host: hostname, port } = url; - hostname = hostname.replace(/:\d*$/, "").toLowerCase(); + hostname = hostname.replace(/:\d*$/, "").replace(/^\[(.+)\]$/, "$1").toLowerCase(); port = Number.parseInt(port, 10) || DEFAULT_PORTS[protocol] || 0; if (!this.#shouldProxy(hostname, port)) { return this[kNoProxyAgent]; @@ -11681,11 +11848,22 @@ var require_env_http_proxy_agent = __commonJS({ if (!entry) { continue; } - const parsed = entry.match(/^(.+):(\d+)$/); + let hostname, port; + const ipv6WithPort = entry.match(/^\[(.+)\]:(\d+)$/); + if (ipv6WithPort) { + hostname = ipv6WithPort[1]; + port = Number.parseInt(ipv6WithPort[2], 10); + } else { + const unbracketed = entry.replace(/^\[(.+)\]$/, "$1"); + const colonCount = (unbracketed.match(/:/g) || []).length; + const parsed = colonCount === 1 && unbracketed.match(/^(.+):(\d+)$/); + hostname = parsed ? parsed[1] : unbracketed; + port = parsed ? Number.parseInt(parsed[2], 10) : 0; + } noProxyEntries.push({ // strip leading dot or asterisk with dot - hostname: (parsed ? parsed[1] : entry).replace(/^\*?\./, "").toLowerCase(), - port: parsed ? Number.parseInt(parsed[2], 10) : 0 + hostname: hostname.replace(/^\*?\./, "").toLowerCase(), + port }); } this.#noProxyValue = noProxyValue; @@ -16062,6 +16240,8 @@ var require_websocket = __commonJS({ var { SendQueue } = require_sender(); var { WebsocketFrameSend } = require_frame(); var { channels } = require_diagnostics(); + var kRef = /* @__PURE__ */ Symbol.for("nodejs.ref"); + var kUnref = /* @__PURE__ */ Symbol.for("nodejs.unref"); function getSocketAddress(socket) { if (typeof socket?.address === "function") { return socket.address(); @@ -16085,6 +16265,7 @@ var require_websocket = __commonJS({ #bufferedAmount = 0; #protocol = ""; #extensions = ""; + #refed = true; /** @type {SendQueue} */ #sendQueue; /** @type {Handler} */ @@ -16167,6 +16348,16 @@ var require_websocket = __commonJS({ this.#handler.readyState = _WebSocket.CONNECTING; this.#binaryType = "blob"; } + [kRef]() { + webidl.brandCheck(this, _WebSocket); + this.#refed = true; + this.#handler.socket?.ref?.(); + } + [kUnref]() { + webidl.brandCheck(this, _WebSocket); + this.#refed = false; + this.#handler.socket?.unref?.(); + } /** * @see https://websockets.spec.whatwg.org/#dom-websocket-close * @param {number|undefined} code @@ -16328,6 +16519,9 @@ var require_websocket = __commonJS({ */ #onConnectionEstablished(response, parsedExtensions) { this.#handler.socket = response.socket; + if (!this.#refed) { + this.#handler.socket.unref?.(); + } const maxFragments = this.#handler.controller.dispatcher?.webSocketOptions?.maxFragments; const maxPayloadSize = this.#handler.controller.dispatcher?.webSocketOptions?.maxPayloadSize; const parser = new ByteParser(this.#handler, parsedExtensions, { @@ -17247,7 +17441,6 @@ var require_readable = __commonJS({ var kContentLength = /* @__PURE__ */ Symbol("kContentLength"); var kUsed = /* @__PURE__ */ Symbol("kUsed"); var kBytesRead = /* @__PURE__ */ Symbol("kBytesRead"); - var kPreservedBuffer = /* @__PURE__ */ Symbol("kPreservedBuffer"); var noop = /* @__PURE__ */ __name(() => { }, "noop"); var BodyReadable = class extends Readable { @@ -17489,21 +17682,6 @@ var require_readable = __commonJS({ */ setEncoding(encoding) { if (Buffer.isEncoding(encoding)) { - const state = this._readableState; - const buffer = state.buffer; - if (buffer && state.length > 0) { - const bufferIndex = state.bufferIndex ?? 0; - const preserved = []; - const source = typeof buffer.slice === "function" ? buffer.slice(bufferIndex) : buffer; - for (const data of source) { - if (Buffer.isBuffer(data)) { - preserved.push(data); - } - } - if (preserved.length > 0) { - this[kPreservedBuffer] = (this[kPreservedBuffer] || []).concat(preserved); - } - } super.setEncoding(encoding); } return this; @@ -17557,13 +17735,7 @@ var require_readable = __commonJS({ return; } const { _readableState: state } = consume2.stream; - const preserved = consume2.stream[kPreservedBuffer]; - if (preserved && preserved.length > 0) { - for (const chunk of preserved) { - consumePush(consume2, chunk); - } - consume2.stream[kPreservedBuffer] = null; - } else if (state.bufferIndex) { + if (state.bufferIndex) { const start = state.bufferIndex; const end = state.buffer.length; for (let n = start; n < end; n++) { @@ -17574,13 +17746,17 @@ var require_readable = __commonJS({ consumePush(consume2, chunk); } } + const decoder = state.decoder; + if (decoder != null && decoder.lastNeed > 0) { + consumePush(consume2, Buffer.from(decoder.lastChar.subarray(0, decoder.lastTotal - decoder.lastNeed))); + } if (state.endEmitted) { - consumeEnd(this[kConsume], this._readableState.encoding); - } else { - consume2.stream.on("end", function() { - consumeEnd(this[kConsume], this._readableState.encoding); - }); + consumeEnd(consume2, state.encoding); + return; } + consume2.stream.on("end", function() { + consumeEnd(this[kConsume], this._readableState.encoding); + }); consume2.stream.resume(); while (consume2.stream.read() != null) { } @@ -17641,6 +17817,9 @@ var require_readable = __commonJS({ if (consume2.body === null) { return; } + if (typeof chunk === "string") { + chunk = Buffer.from(chunk, consume2.stream._readableState.encoding); + } consume2.length += chunk.length; consume2.body.push(chunk); } diff --git a/src/undici_version.h b/src/undici_version.h index e6ecc19f161c..8afd91ad6f9a 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.9.0" +#define UNDICI_VERSION "8.10.0" #endif // SRC_UNDICI_VERSION_H_