Skip to content
Draft
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
1 change: 1 addition & 0 deletions lib/core/symbols.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
14 changes: 6 additions & 8 deletions lib/dispatcher/client-h2.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,7 @@ const {
kStrictContentLength,
kOnError,
kMaxConcurrentStreams,
kPingInterval,
kHTTP2Session,
kHTTP2InitialWindowSize,
kHTTP2ConnectionWindowSize,
kHostAuthority,
kResume,
kSize,
Expand All @@ -41,7 +38,8 @@ const {
kEnableConnectProtocol,
kRemoteSettings,
kHTTP2Stream,
kHTTP2SessionState
kHTTP2SessionState,
kHTTP2Options
} = require('../core/symbols.js')
const { channels } = require('../core/diagnostics.js')

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
70 changes: 44 additions & 26 deletions lib/dispatcher/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 18 additions & 1 deletion test/http2-late-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const {
kOnError,
kResume,
kRunning,
kPingInterval
kPingInterval,
kHTTP2Options
} = require('../lib/core/symbols')

class FakeSocket extends EventEmitter {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion test/http2-ping-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 () {}
}

Expand Down
17 changes: 10 additions & 7 deletions test/http2-window-size.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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()
Expand Down
35 changes: 35 additions & 0 deletions types/client.d.ts
Original file line number Diff line number Diff line change
@@ -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<TOpaque = null> = Omit<Dispatcher.ConnectOptions<TOpaque>, '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.
*/
Expand Down Expand Up @@ -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
Expand All @@ -129,6 +142,28 @@ export declare namespace Client {
*/
maxPayloadSize?: number;
}

export interface H2Options extends Omit<SessionOptions, keyof buildConnector.BuildOptions> {
/**
* @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 SETTINGS frame object. Default to 'node:http2' defaults
*/
settings?: Omit<SessionOptions['settings'], 'enablePush' | 'maxConcurrentStreams' | 'enableConnectProtocol'>
}
}

export default Client
Loading