Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions lib/core/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
23 changes: 22 additions & 1 deletion lib/dispatcher/client-h2.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const {
kOnError,
kMaxConcurrentStreams,
kHTTP2Session,
kHTTP2InitialWindowSize,
kHTTP2ConnectionWindowSize,
kResume,
kSize,
kHTTPContext,
Expand Down Expand Up @@ -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)
}
})

Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand Down
22 changes: 21 additions & 1 deletion lib/dispatcher/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ const {
kOnError,
kHTTPContext,
kMaxConcurrentStreams,
kHTTP2InitialWindowSize,
kHTTP2ConnectionWindowSize,
kResume
} = require('../core/symbols.js')
const connectH1 = require('./client-h1.js')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions test/client-node-max-header-size.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
101 changes: 101 additions & 0 deletions test/http2-window-size.js
Original file line number Diff line number Diff line change
@@ -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
})
64 changes: 64 additions & 0 deletions test/node-test/client-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down
20 changes: 20 additions & 0 deletions test/types/client.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ expectAssignable<Client>(
autoSelectFamilyAttemptTimeout: 300e3
})
)
expectAssignable<Client>(
new Client('', {
allowH2: true
})
)
expectAssignable<Client>(
new Client('', {
maxConcurrentStreams: 100
})
)
expectAssignable<Client>(
new Client('', {
initialWindowSize: 262144
})
)
expectAssignable<Client>(
new Client('', {
connectionWindowSize: 524288
})
)
expectAssignable<Client>(
new Client('', {
interceptors: {
Expand Down
10 changes: 10 additions & 0 deletions types/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down