Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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`. 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) - Default `524288`. 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
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]
Copy link
Copy Markdown
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 != 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
16 changes: 15 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,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] = 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
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
Loading