diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index 4a29e5d16bd..a4c0712f471 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -32,6 +32,8 @@ Returns: `Client` * **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation. * **useH2c**: `boolean` - Default: `false`. Enforces h2c for non-https connections. * **maxConcurrentStreams**: `number` - Default: `100`. Dictates the maximum number of concurrent streams for a single H2 session. It can be overridden by a SETTINGS remote frame. +* **initialWindowSize**: `number` (optional) - Default: `262144` (256KB). Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). Must be a positive integer greater than 0. This default is higher than Node.js core's default (65535 bytes) to improve throughput, Node's choice is very conservative for current high-bandwith networks. See [RFC 7540 Section 6.9.2](https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2) for more details. +* **connectionWindowSize**: `number` (optional) - Default `524288` (512KB). Sets the HTTP/2 connection-level flow-control window size using `ClientHttp2Session.setLocalWindowSize()`. Must be a positive integer greater than 0. This provides better flow control for the entire connection across multiple streams. See [Node.js HTTP/2 documentation](https://nodejs.org/api/http2.html#clienthttp2sessionsetlocalwindowsize) for more details. > **Notes about HTTP/2** > - It only works under TLS connections. h2c is not supported. diff --git a/lib/core/symbols.js b/lib/core/symbols.js index 00c4f8ff23b..ec45d7951ef 100644 --- a/lib/core/symbols.js +++ b/lib/core/symbols.js @@ -62,6 +62,8 @@ module.exports = { kListeners: Symbol('listeners'), kHTTPContext: Symbol('http context'), kMaxConcurrentStreams: Symbol('max concurrent streams'), + kHTTP2InitialWindowSize: Symbol('http2 initial window size'), + kHTTP2ConnectionWindowSize: Symbol('http2 connection window size'), kEnableConnectProtocol: Symbol('http2session connect protocol'), kRemoteSettings: Symbol('http2session remote settings'), kHTTP2Stream: Symbol('http2session client stream'), diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index 6406b9fd438..b77c4cffee5 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -25,6 +25,8 @@ const { kOnError, kMaxConcurrentStreams, kHTTP2Session, + kHTTP2InitialWindowSize, + kHTTP2ConnectionWindowSize, kResume, kSize, kHTTPContext, @@ -87,12 +89,16 @@ function parseH2Headers (headers) { function connectH2 (client, socket) { client[kSocket] = socket + const http2InitialWindowSize = client[kHTTP2InitialWindowSize] + const http2ConnectionWindowSize = client[kHTTP2ConnectionWindowSize] + const session = http2.connect(client[kUrl], { createConnection: () => socket, peerMaxConcurrentStreams: client[kMaxConcurrentStreams], settings: { // TODO(metcoder95): add support for PUSH - enablePush: false + enablePush: false, + ...(http2InitialWindowSize != null ? { initialWindowSize: http2InitialWindowSize } : null) } }) @@ -107,6 +113,11 @@ function connectH2 (client, socket) { // States whether or not we have received the remote settings from the server session[kRemoteSettings] = false + // Apply connection-level flow control once connected (if supported). + if (http2ConnectionWindowSize) { + util.addListener(session, 'connect', applyConnectionWindowSize.bind(session, http2ConnectionWindowSize)) + } + util.addListener(session, 'error', onHttp2SessionError) util.addListener(session, 'frameError', onHttp2FrameError) util.addListener(session, 'end', onHttp2SessionEnd) @@ -211,6 +222,16 @@ function resumeH2 (client) { } } +function applyConnectionWindowSize (connectionWindowSize) { + try { + if (typeof this.setLocalWindowSize === 'function') { + this.setLocalWindowSize(connectionWindowSize) + } + } catch { + // Best-effort only. + } +} + function onHttp2RemoteSettings (settings) { // Fallbacks are a safe bet, remote setting will always override this[kClient][kMaxConcurrentStreams] = settings.maxConcurrentStreams ?? this[kClient][kMaxConcurrentStreams] diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index b73cd259019..e603cfebd39 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -52,6 +52,8 @@ const { kOnError, kHTTPContext, kMaxConcurrentStreams, + kHTTP2InitialWindowSize, + kHTTP2ConnectionWindowSize, kResume } = require('../core/symbols.js') const connectH1 = require('./client-h1.js') @@ -108,7 +110,9 @@ class Client extends DispatcherBase { // h2 maxConcurrentStreams, allowH2, - useH2c + useH2c, + initialWindowSize, + connectionWindowSize } = {}) { if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -204,6 +208,14 @@ class Client extends DispatcherBase { 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') + } + super() if (typeof connect !== 'function') { @@ -239,6 +251,14 @@ class Client extends DispatcherBase { this[kClosedResolve] = null this[kMaxResponseSize] = maxResponseSize > -1 ? maxResponseSize : -1 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[kHTTPContext] = null // kQueue is built up of 3 sections separated by diff --git a/test/client-node-max-header-size.js b/test/client-node-max-header-size.js index d4461c35a1e..575305eca68 100644 --- a/test/client-node-max-header-size.js +++ b/test/client-node-max-header-size.js @@ -36,7 +36,12 @@ describe("Node.js' --max-http-header-size cli option", () => { exec(command, { stdio: 'pipe' }, (err, stdout, stderr) => { t.ifError(err) t.strictEqual(stdout, '') - t.strictEqual(stderr, '', 'default max-http-header-size should not throw') + // Filter out debugger messages that may appear when running with --inspect + const filteredStderr = stderr.replace(/Debugger listening on ws:\/\/.*?\n/g, '') + .replace(/For help, see:.*?\n/g, '') + .replace(/Debugger attached\.\n/g, '') + .replace(/Waiting for the debugger to disconnect\.\.\.\n/g, '') + t.strictEqual(filteredStderr, '', 'default max-http-header-size should not throw') }) await t.completed @@ -55,7 +60,12 @@ describe("Node.js' --max-http-header-size cli option", () => { exec(command, { stdio: 'pipe' }, (err, stdout, stderr) => { t.ifError(err) t.strictEqual(stdout, '') - t.strictEqual(stderr, '', 'default max-http-header-size should not throw') + // Filter out debugger messages that may appear when running with --inspect + const filteredStderr = stderr.replace(/Debugger listening on ws:\/\/.*?\n/g, '') + .replace(/For help, see:.*?\n/g, '') + .replace(/Debugger attached\.\n/g, '') + .replace(/Waiting for the debugger to disconnect\.\.\.\n/g, '') + t.strictEqual(filteredStderr, '', 'default max-http-header-size should not throw') }) await t.completed diff --git a/test/http2-window-size.js b/test/http2-window-size.js new file mode 100644 index 00000000000..302f79092ff --- /dev/null +++ b/test/http2-window-size.js @@ -0,0 +1,101 @@ +'use strict' + +const { tspl } = require('@matteo.collina/tspl') +const { test, after } = require('node:test') +const { EventEmitter } = require('node:events') +const connectH2 = require('../lib/dispatcher/client-h2') +const { + kUrl, + kSocket, + kMaxConcurrentStreams, + kHTTP2Session, + kHTTP2InitialWindowSize, + kHTTP2ConnectionWindowSize +} = require('../lib/core/symbols') + +test('Should plumb initialWindowSize and connectionWindowSize into the HTTP/2 session creation path', async (t) => { + t = tspl(t, { plan: 6 }) + + const http2 = require('node:http2') + const originalConnect = http2.connect + + /** @type {any} */ + let seenConnectOptions = null + /** @type {number[]} */ + const setLocalWindowSizeCalls = [] + + class FakeSession extends EventEmitter { + unref () {} + ref () {} + close () {} + destroy () {} + request () { + throw new Error('not implemented') + } + + setLocalWindowSize (size) { + setLocalWindowSizeCalls.push(size) + } + } + + class FakeSocket extends EventEmitter { + constructor () { + super() + this.destroyed = false + } + + unref () {} + ref () {} + destroy () { + this.destroyed = true + return this + } + } + + const fakeSession = new FakeSession() + + http2.connect = function connectStub (_authority, options) { + seenConnectOptions = options + return fakeSession + } + + after(() => { + http2.connect = originalConnect + }) + + const initialWindowSize = 12345 + const connectionWindowSize = 77777 + + const client = { + [kUrl]: new URL('https://localhost'), + [kMaxConcurrentStreams]: 100, + [kHTTP2InitialWindowSize]: initialWindowSize, + [kHTTP2ConnectionWindowSize]: connectionWindowSize, + [kSocket]: null, + [kHTTP2Session]: null + } + + const socket = new FakeSocket() + + connectH2(client, socket) + + t.ok(seenConnectOptions && seenConnectOptions.settings) + t.strictEqual(seenConnectOptions.settings.enablePush, false) + t.strictEqual( + seenConnectOptions.settings.initialWindowSize, + initialWindowSize + ) + t.strictEqual(client[kHTTP2Session], fakeSession) + + // Emit 'connect' event + process.nextTick(() => { + fakeSession.emit('connect') + }) + + await new Promise((resolve) => process.nextTick(resolve)) + + t.strictEqual(setLocalWindowSizeCalls.length, 1) + t.strictEqual(setLocalWindowSizeCalls[0], connectionWindowSize) + + await t.completed +}) diff --git a/test/node-test/client-errors.js b/test/node-test/client-errors.js index 3bd29158226..4021bb37955 100644 --- a/test/node-test/client-errors.js +++ b/test/node-test/client-errors.js @@ -594,6 +594,70 @@ test('invalid options throws', (t, done) => { assert.strictEqual(err.message, 'autoSelectFamilyAttemptTimeout must be a positive number') } + try { + new Client(new URL('http://localhost:200'), { initialWindowSize: 'foo' }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { initialWindowSize: 0 }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { initialWindowSize: -1 }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { initialWindowSize: 1.5 }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'initialWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { connectionWindowSize: 'foo' }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { connectionWindowSize: 0 }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { connectionWindowSize: -1 }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'connectionWindowSize must be a positive integer, greater than 0') + } + + try { + new Client(new URL('http://localhost:200'), { connectionWindowSize: 1.5 }) // eslint-disable-line + assert.ok(0) + } catch (err) { + assert.ok(err instanceof errors.InvalidArgumentError) + assert.strictEqual(err.message, 'connectionWindowSize 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 504f66805a2..a6e181ac650 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -105,6 +105,26 @@ expectAssignable( autoSelectFamilyAttemptTimeout: 300e3 }) ) +expectAssignable( + new Client('', { + allowH2: true + }) +) +expectAssignable( + new Client('', { + maxConcurrentStreams: 100 + }) +) +expectAssignable( + new Client('', { + initialWindowSize: 262144 + }) +) +expectAssignable( + new Client('', { + connectionWindowSize: 524288 + }) +) expectAssignable( new Client('', { interceptors: { diff --git a/types/client.d.ts b/types/client.d.ts index 04b8f29f1bb..f5ccde2be43 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -92,6 +92,16 @@ export declare namespace Client { * @default 100 */ maxConcurrentStreams?: number; + /** + * @description Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). + * @default 262144 + */ + initialWindowSize?: number; + /** + * @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize). + * @default 524288 + */ + connectionWindowSize?: number; } export interface SocketInfo { localAddress?: string