Skip to content
Merged
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
29 changes: 21 additions & 8 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
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 @@ -204,12 +202,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 @@ -229,7 +227,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
102 changes: 73 additions & 29 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 All @@ -76,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
Expand Down Expand Up @@ -128,7 +136,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 +225,55 @@ 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')
}
// 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')
}

if (useH2c != null && typeof useH2c !== 'boolean') {
throw new InvalidArgumentError('useH2c must be a valid boolean value')
}
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 (initialWindowSize != null && (!Number.isInteger(initialWindowSize) || initialWindowSize < 1)) {
throw new InvalidArgumentError('initialWindowSize 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 (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('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 (maxConcurrentStreams != null && (typeof maxConcurrentStreams !== 'number' || maxConcurrentStreams < 1)) {
emitH2OptionsNamespaceWarning('maxConcurrentStreams')
throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 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')
}
}
}

super({ webSocket })
Expand All @@ -243,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
Expand Down Expand Up @@ -280,16 +320,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
43 changes: 42 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 @@ -206,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
}
Expand Down Expand Up @@ -288,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
}
Expand Down Expand Up @@ -360,6 +385,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 Expand Up @@ -436,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
}
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
9 changes: 8 additions & 1 deletion test/http2-response-after-completion.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ const {
kOnError,
kResume,
kRunning,
kPingInterval
kPingInterval,
kHTTP2Options
} = require('../lib/core/symbols')

class FakeSocket extends EventEmitter {
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading