From 2dc5c7348a066c119a408e01f140b9b5fdce2e39 Mon Sep 17 00:00:00 2001 From: Carlos Fuentes Date: Fri, 3 Jul 2026 13:00:34 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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 */