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
3 changes: 3 additions & 0 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ 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.
* **http2**: `object` (optional) - HTTP/2 session options.
* **initialWindowSize**: `number` (optional) - Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE). Must be a positive integer greater than 0. See [RFC 7540 Section 6.9.2](https://datatracker.ietf.org/doc/html/rfc7540#section-6.9.2) for more details.
* **connectionWindowSize**: `number` (optional) - Sets the HTTP/2 connection-level flow-control window size using `ClientHttp2Session.setLocalWindowSize()`. Must be a positive integer greater than 0. This controls the total amount of data that can be sent on the connection before receiving flow control updates from the peer. 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
21 changes: 20 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]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can have slightly better defaults than Node.js core for these.
See @ronag in nodejs/node#61036


const session = http2.connect(client[kUrl], {
createConnection: () => socket,
peerMaxConcurrentStreams: client[kMaxConcurrentStreams],
settings: {
// TODO(metcoder95): add support for PUSH
enablePush: false
enablePush: false,
...(http2InitialWindowSize ? { initialWindowSize: http2InitialWindowSize } : null)
}
})

Expand All @@ -107,6 +113,19 @@ 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', () => {
try {
if (typeof session.setLocalWindowSize === 'function') {
session.setLocalWindowSize(http2ConnectionWindowSize)
}
} catch {
// Best-effort only.
}
})
}

util.addListener(session, 'error', onHttp2SessionError)
util.addListener(session, 'frameError', onHttp2FrameError)
util.addListener(session, 'end', onHttp2SessionEnd)
Expand Down
18 changes: 17 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,8 @@ class Client extends DispatcherBase {
// h2
maxConcurrentStreams,
allowH2,
useH2c
useH2c,
http2
} = {}) {
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
Expand Down Expand Up @@ -204,6 +207,17 @@ class Client extends DispatcherBase {
throw new InvalidArgumentError('useH2c must be a valid boolean value')
}

const http2InitialWindowSize = http2?.initialWindowSize
const http2ConnectionWindowSize = http2?.connectionWindowSize

if (http2InitialWindowSize != null && (!Number.isInteger(http2InitialWindowSize) || http2InitialWindowSize < 1)) {
throw new InvalidArgumentError('http2.initialWindowSize must be a positive integer, greater than 0')
}

if (http2ConnectionWindowSize != null && (!Number.isInteger(http2ConnectionWindowSize) || http2ConnectionWindowSize < 1)) {
throw new InvalidArgumentError('http2.connectionWindowSize must be a positive integer, greater than 0')
}

super()

if (typeof connect !== 'function') {
Expand Down Expand Up @@ -239,6 +253,8 @@ 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
this[kHTTP2InitialWindowSize] = http2InitialWindowSize ?? null
this[kHTTP2ConnectionWindowSize] = http2ConnectionWindowSize ?? null
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 http2.initialWindowSize and http2.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
})
15 changes: 15 additions & 0 deletions types/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ export declare namespace Client {
* @default 100
*/
maxConcurrentStreams?: number;
/**
* @description HTTP/2 session options.
*/
http2?: {
Copy link

@manucorporat manucorporat Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the undici team probably prefer to avoid the http2 namespace, as currently maxConcurrentStreams is a http2-only property as well and included at the root level. But let's see what they say.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, remove the http2 namespacing.

/**
* @description Sets the HTTP/2 stream-level flow-control window size (SETTINGS_INITIAL_WINDOW_SIZE).
* @default undefined
*/
initialWindowSize?: number;
/**
* @description Sets the HTTP/2 connection-level flow-control window size (ClientHttp2Session.setLocalWindowSize).
* @default undefined
*/
connectionWindowSize?: number;
};
}
export interface SocketInfo {
localAddress?: string
Expand Down
Loading