From 2dc5c7348a066c119a408e01f140b9b5fdce2e39 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 3 Jul 2026 13:00:34 +0200 Subject: [PATCH 1/6] feat: namespace h2 options --- types/client.d.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/types/client.d.ts b/types/client.d.ts index 0b4d482dad3..266e4d82301 100644 --- a/types/client.d.ts +++ b/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,23 @@ 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 SETTINGS frame object. Default to 'node:http2' defaults + */ + settings?: Omit +} } export default Client From d877ac9b56e079ce68c32afd185631ac1b2b926f Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 5 Jul 2026 11:03:55 +0200 Subject: [PATCH 2/6] fix: linting --- types/client.d.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/types/client.d.ts b/types/client.d.ts index 266e4d82301..f9f20c5555b 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -144,21 +144,21 @@ export declare namespace Client { } 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 SETTINGS frame object. Default to 'node:http2' defaults - */ - settings?: 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 SETTINGS frame object. Default to 'node:http2' defaults + */ + settings?: Omit + } } export default Client From cc0e1202f53968bdf087f9712fb215ebf9926411 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Sun, 5 Jul 2026 13:15:20 +0200 Subject: [PATCH 3/6] fix: testing --- lib/core/symbols.js | 1 + lib/dispatcher/client-h2.js | 14 ++++---- lib/dispatcher/client.js | 70 +++++++++++++++++++++++-------------- test/http2-late-data.js | 19 +++++++++- test/http2-ping-errors.js | 11 +++++- test/http2-window-size.js | 17 +++++---- types/client.d.ts | 7 +++- 7 files changed, 95 insertions(+), 44 deletions(-) diff --git a/lib/core/symbols.js b/lib/core/symbols.js index 8bad25eed9f..badecb70908 100644 --- a/lib/core/symbols.js +++ b/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/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index e22234de8d6..e93ef175335 100644 --- a/lib/dispatcher/client-h2.js +++ b/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') @@ -203,12 +201,12 @@ function detachRequestStreamForClose (request) { 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, @@ -228,7 +226,7 @@ function connectH2 (client, socket) { // 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 diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index 8a4f65171bd..1148c2a40c4 100644 --- a/lib/dispatcher/client.js +++ b/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') @@ -128,7 +126,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 +215,39 @@ 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') - } - 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') - } + // Prioritise new h2Options object, otherwise fallback to prior configuration options + if (h2Options != null) { + if (h2Options.maxConcurrentStreams != null && (typeof h2Options.maxConcurrentStreams !== 'number' || h2Options.maxConcurrentStreams < 1)) { + throw new InvalidArgumentError('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 (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 (h2Options.pingInterval != null && (typeof h2Options.pingInterval !== 'number' || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) { + throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') + } + } else { + if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { + throw new InvalidArgumentError('maxConcurrentStreams 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 (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') + } } super({ webSocket }) @@ -280,16 +294,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. diff --git a/test/http2-late-data.js b/test/http2-late-data.js index 00464c899bf..c130e351d0b 100644 --- a/test/http2-late-data.js +++ b/test/http2-late-data.js @@ -20,7 +20,8 @@ const { kOnError, kResume, kRunning, - kPingInterval + kPingInterval, + kHTTP2Options } = require('../lib/core/symbols') class FakeSocket extends EventEmitter { @@ -116,6 +117,14 @@ test('Should ignore late http2 data after request completion', async (t) => { [kResume] () { resumeCalls++ }, + [kHTTP2Options]: { + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize: 262144 + } + }, emit () {}, destroyed: false } @@ -197,6 +206,14 @@ test('Should remove request-owned http2 stream listeners after completion', asyn [kOnError] (err) { t.ifError(err) }, + [kHTTP2Options]: { + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize: 262144 + } + }, [kResume] () {}, emit () {}, destroyed: false diff --git a/test/http2-ping-errors.js b/test/http2-ping-errors.js index c4641027a17..67f84a2264b 100644 --- a/test/http2-ping-errors.js +++ b/test/http2-ping-errors.js @@ -14,7 +14,8 @@ const { kOnError, kPingInterval, kSocket, - kUrl + kUrl, + kHTTP2Options } = require('../lib/core/symbols') test('Should record http2 ping failures on the socket', async (t) => { @@ -77,6 +78,14 @@ test('Should record http2 ping failures on the socket', async (t) => { [kPingInterval]: 1, [kSocket]: null, [kUrl]: new URL('https://localhost'), + [kHTTP2Options]: { + pingInterval: 5, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize: 262144 + } + }, emit () {} } diff --git a/test/http2-window-size.js b/test/http2-window-size.js index 302f79092ff..45c27e612cf 100644 --- a/test/http2-window-size.js +++ b/test/http2-window-size.js @@ -7,10 +7,8 @@ const connectH2 = require('../lib/dispatcher/client-h2') const { kUrl, kSocket, - kMaxConcurrentStreams, kHTTP2Session, - kHTTP2InitialWindowSize, - kHTTP2ConnectionWindowSize + kHTTP2Options } = require('../lib/core/symbols') test('Should plumb initialWindowSize and connectionWindowSize into the HTTP/2 session creation path', async (t) => { @@ -68,11 +66,16 @@ test('Should plumb initialWindowSize and connectionWindowSize into the HTTP/2 se const client = { [kUrl]: new URL('https://localhost'), - [kMaxConcurrentStreams]: 100, - [kHTTP2InitialWindowSize]: initialWindowSize, - [kHTTP2ConnectionWindowSize]: connectionWindowSize, [kSocket]: null, - [kHTTP2Session]: null + [kHTTP2Session]: null, + [kHTTP2Options]: { + connectionWindowSize, + pingInterval: 60e3, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize + } + } } const socket = new FakeSocket() diff --git a/types/client.d.ts b/types/client.d.ts index f9f20c5555b..c7c43151789 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -116,7 +116,7 @@ export declare namespace Client { /** * @description HTTP/2 configuration options */ - h2Options: Client.H2Options; + h2Options?: Client.H2Options; } export interface SocketInfo { localAddress?: string @@ -154,6 +154,11 @@ export declare namespace Client { * @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 SETTINGS frame object. Default to 'node:http2' defaults */ From 36f3d1823f056f74ede5a6722e116a69f9bd8ba1 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 24 Jul 2026 10:47:34 +0200 Subject: [PATCH 4/6] test: fixup --- test/http2-late-data.js | 24 ++++++++++++++++++++++++ test/http2-response-after-completion.js | 9 ++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/test/http2-late-data.js b/test/http2-late-data.js index 558f7dfcb9e..21830d0e73a 100644 --- a/test/http2-late-data.js +++ b/test/http2-late-data.js @@ -215,6 +215,14 @@ test('Should complete the response and release the stream on end without a close t.ifError(err) }, [kResume] () {}, + [kHTTP2Options]: { + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize: 262144 + } + }, emit () {}, destroyed: false } @@ -297,6 +305,14 @@ test('Should complete only once when both end and a late close fire', async (t) t.ifError(err) }, [kResume] () {}, + [kHTTP2Options]: { + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize: 262144 + } + }, emit () {}, destroyed: false } @@ -453,6 +469,14 @@ test('Should finalize an already-aborted request when its stream closes', async t.ifError(err) }, [kResume] () {}, + [kHTTP2Options]: { + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + sessionOptions: { + initialWindowSize: 262144 + } + }, emit () {}, destroyed: false } diff --git a/test/http2-response-after-completion.js b/test/http2-response-after-completion.js index 3413b884c70..e593ce3b22b 100644 --- a/test/http2-response-after-completion.js +++ b/test/http2-response-after-completion.js @@ -36,7 +36,8 @@ const { kOnError, kResume, kRunning, - kPingInterval + kPingInterval, + kHTTP2Options } = require('../lib/core/symbols') class FakeSocket extends EventEmitter { @@ -126,6 +127,12 @@ test('Should ignore a late http2 "response" delivered after request completion', t.ifError(err) }, [kResume] () {}, + [kHTTP2Options]: { + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, // Max peerConcurrentStreams for a Node h2 server + sessionOptions: { initialWindowSize: 262144 } + }, emit () {}, destroyed: false } From ae0bdd58407f50b0a56ad7a653ec6714ac985e91 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 24 Jul 2026 10:47:34 +0200 Subject: [PATCH 5/6] test: fixup --- lib/dispatcher/client.js | 80 +++++++++++------ test/node-test/client-errors.js | 148 ++++++++++++++++++++++++++++++++ test/types/client.test-d.ts | 15 ++++ types/client.d.ts | 5 ++ 4 files changed, 221 insertions(+), 27 deletions(-) diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index 1148c2a40c4..705b43b88bd 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -74,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 @@ -215,38 +225,54 @@ class Client extends DispatcherBase { throw new InvalidArgumentError('allowH2 must be a valid boolean value') } - if (useH2c != null && typeof useH2c !== 'boolean') { - throw new InvalidArgumentError('useH2c must be a valid boolean value') - } + // 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') + } - // Prioritise new h2Options object, otherwise fallback to prior configuration options - if (h2Options != null) { - if (h2Options.maxConcurrentStreams != null && (typeof h2Options.maxConcurrentStreams !== 'number' || h2Options.maxConcurrentStreams < 1)) { - throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0') - } + 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.connectionWindowSize != null && (!Number.isInteger(h2Options.connectionWindowSize) || h2Options.connectionWindowSize < 1)) { - throw new InvalidArgumentError('h2Options.connectionWindowSize 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.pingInterval != null && (typeof h2Options.pingInterval !== 'number' || !Number.isInteger(h2Options.pingInterval) || h2Options.pingInterval < 0)) { - throw new InvalidArgumentError('pingInterval must be a positive integer, greater or equal to 0') - } - } else { - if (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) { - throw new InvalidArgumentError('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 (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) { - throw new InvalidArgumentError('initialWindowSize 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 (connectionWindowSize != null && (!Number.isInteger(connectionWindowSize) || connectionWindowSize < 1)) { - throw new InvalidArgumentError('connectionWindowSize must be a positive integer, greater than 0') - } + 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') + } } } @@ -257,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 diff --git a/test/node-test/client-errors.js b/test/node-test/client-errors.js index 90813539d8a..a009e940c09 100644 --- a/test/node-test/client-errors.js +++ b/test/node-test/client-errors.js @@ -678,6 +678,154 @@ test('invalid options throws', (t, done) => { assert.strictEqual(err.message, 'connectionWindowSize must be a positive integer, greater than 0') } + try { + new Client(new URL('http://localhost:200'), { // eslint-disable-line + h2Options: { + settings: { initialWindowSize: 'foo' } + } + }) + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.settings.initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { settings: { initialWindowSize: 0 }} }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.settings.initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { settings: { initialWindowSize: -1 }} }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.settings.initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { settings: { initialWindowSize: 1.5 }} }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.settings.initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { connectionWindowSize: 'foo' } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { connectionWindowSize: 0 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { connectionWindowSize: -1 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { connectionWindowSize: 1.5 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { useH2c: 'foo' } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.useH2c must be a valid boolean value') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { useH2c: 0 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.useH2c must be a valid boolean value') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { useH2c: -1 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.useH2c must be a valid boolean value') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { pingInterval: 'foo' } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.pingInterval must be a positive integer, greater or equal to 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { pingInterval: -1 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.pingInterval must be a positive integer, greater or equal to 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { pingInterval: 1.5 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.pingInterval must be a positive integer, greater or equal to 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { maxConcurrentStreams: 'foo' } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.maxConcurrentStreams must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { maxConcurrentStreams: 0 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.maxConcurrentStreams must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { maxConcurrentStreams: -1 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.maxConcurrentStreams must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { h2Options: { maxConcurrentStreams: 1.5 } }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'h2Options.maxConcurrentStreams must be a positive integer, greater than 0') + } + done() }) diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts index 69e4a680802..38aa601b4e9 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -105,6 +105,8 @@ expectAssignable( autoSelectFamilyAttemptTimeout: 300e3 }) ) + +// TODO: (@metcoder95) - remove in further major expectAssignable( new Client('', { allowH2: true @@ -130,6 +132,19 @@ expectAssignable( pingInterval: 60e3 }) ) +expectAssignable( + new Client('', { + h2Options: { + useH2c: false, + pingInterval: 60e3, + connectionWindowSize: 524288, + maxConcurrentStreams: 100, + settings: { + initialWindowSize: 262144 + } + } + }) +) { const client = new Client('') diff --git a/types/client.d.ts b/types/client.d.ts index 3de72225bba..064d3d69f2c 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -159,6 +159,11 @@ export declare namespace Client { * @default 100 */ maxConcurrentStreams?: number; + /** + * @description Enable support for H2C (plain text) + * @default false + */ + useH2c?: boolean; /** * @description SETTINGS frame object. Default to 'node:http2' defaults */ From 1f385db24f231009c7bdeb507502bed90e9aa85b Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 24 Jul 2026 11:30:21 +0200 Subject: [PATCH 6/6] docs: adjust docs --- docs/docs/api/Client.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index dc6ab7a6d5f..d48b2303f28 100644 --- a/docs/docs/api/Client.md +++ b/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`.