diff --git a/docs/docs/api/Client.md b/docs/docs/api/Client.md index abc02d87d17..680375d1479 100644 --- a/docs/docs/api/Client.md +++ b/docs/docs/api/Client.md @@ -34,6 +34,7 @@ Returns: `Client` * **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. +* **pingInterval**: `number` - Default: `60e3`. The time interval in milliseconds between PING frames sent to the server. Set to `0` to disable PING frames. This is only applicable for HTTP/2 connections. This will emit a `ping` event on the client with the duration of the ping in milliseconds. > **Notes about HTTP/2** > - It only works under TLS connections. h2c is not supported. diff --git a/docs/docs/api/H2CClient.md b/docs/docs/api/H2CClient.md index d9ba3089135..6558ad5f4a4 100644 --- a/docs/docs/api/H2CClient.md +++ b/docs/docs/api/H2CClient.md @@ -48,6 +48,7 @@ Returns: `H2CClient` - **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable. - **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. - **pipelining** `number | null` (optional) - Default to `maxConcurrentStreams` - The amount of concurrent requests sent over a single HTTP/2 session in accordance with [RFC-7540](https://httpwg.org/specs/rfc7540.html#StreamsLayer) Stream specification. Streams can be closed up by remote server at any time. +- **pingInterval**: `number` - Default: `60e3`. The time interval in milliseconds between PING frames sent to the server. Set to `0` to disable PING frames. This is only applicable for HTTP/2 connections. - **connect** `ConnectOptions | null` (optional) - Default: `null`. - **strictContentLength** `Boolean` (optional) - Default: `true` - Whether to treat request content length mismatches as errors. If true, an error is thrown when the request content-length header doesn't match the length of the request body. **Security Warning:** Disabling this option can expose your application to HTTP Request Smuggling attacks, where mismatched content-length headers cause servers and proxies to interpret request boundaries differently. This can lead to cache poisoning, credential hijacking, and bypassing security controls. Only disable this in controlled environments where you fully trust the request source. - **autoSelectFamily**: `boolean` (optional) - Default: depends on local Node version, on Node 18.13.0 and above is `false`. Enables a family autodetection algorithm that loosely implements section 5 of [RFC 8305](https://tools.ietf.org/html/rfc8305#section-5). See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. This option is ignored if not supported by the current Node version. diff --git a/lib/core/symbols.js b/lib/core/symbols.js index ec45d7951ef..fd7af0c10e1 100644 --- a/lib/core/symbols.js +++ b/lib/core/symbols.js @@ -67,6 +67,7 @@ module.exports = { kEnableConnectProtocol: Symbol('http2session connect protocol'), kRemoteSettings: Symbol('http2session remote settings'), kHTTP2Stream: Symbol('http2session client stream'), + kPingInterval: Symbol('ping interval'), kNoProxyAgent: Symbol('no proxy agent'), kHttpProxyAgent: Symbol('http proxy agent'), kHttpsProxyAgent: Symbol('https proxy agent') diff --git a/lib/dispatcher/client-h2.js b/lib/dispatcher/client-h2.js index c9aec504af1..0969108911a 100644 --- a/lib/dispatcher/client-h2.js +++ b/lib/dispatcher/client-h2.js @@ -24,6 +24,7 @@ const { kStrictContentLength, kOnError, kMaxConcurrentStreams, + kPingInterval, kHTTP2Session, kHTTP2InitialWindowSize, kHTTP2ConnectionWindowSize, @@ -34,7 +35,8 @@ const { kBodyTimeout, kEnableConnectProtocol, kRemoteSettings, - kHTTP2Stream + kHTTP2Stream, + kHTTP2SessionState } = require('../core/symbols.js') const { channels } = require('../core/diagnostics.js') @@ -102,10 +104,15 @@ function connectH2 (client, socket) { } }) + client[kSocket] = socket session[kOpenStreams] = 0 session[kClient] = client session[kSocket] = socket - session[kHTTP2Session] = null + session[kHTTP2SessionState] = { + ping: { + interval: client[kPingInterval] === 0 ? null : setInterval(onHttp2SendPing, client[kPingInterval], session).unref() + } + } // We set it to true by default in a best-effort; however once connected to an H2 server // we will check if extended CONNECT protocol is supported or not // and set this value accordingly. @@ -253,6 +260,31 @@ function onHttp2RemoteSettings (settings) { this[kClient][kResume]() } +function onHttp2SendPing (session) { + const state = session[kHTTP2SessionState] + if ((session.closed || session.destroyed) && state.ping.interval != null) { + clearInterval(state.ping.interval) + state.ping.interval = null + return + } + + // If no ping sent, do nothing + session.ping(onPing.bind(session)) + + function onPing (err, duration) { + const client = this[kClient] + const socket = this[kClient] + + if (err != null) { + const error = new InformationalError(`HTTP/2: "PING" errored - type ${err.message}`) + socket[kError] = error + client[kOnError](error) + } else { + client.emit('ping', duration) + } + } +} + function onHttp2SessionError (err) { assert(err.code !== 'ERR_TLS_CERT_ALTNAME_INVALID') @@ -316,7 +348,7 @@ function onHttp2SessionGoAway (errorCode) { } function onHttp2SessionClose () { - const { [kClient]: client } = this + const { [kClient]: client, [kHTTP2SessionState]: state } = this const { [kSocket]: socket } = client const err = this[kSocket][kError] || this[kError] || new SocketError('closed', util.getSocketInfo(socket)) @@ -324,6 +356,11 @@ function onHttp2SessionClose () { client[kSocket] = null client[kHTTPContext] = null + if (state.ping.interval != null) { + clearInterval(state.ping.interval) + state.ping.interval = null + } + if (client.destroyed) { assert(client[kPending] === 0) diff --git a/lib/dispatcher/client.js b/lib/dispatcher/client.js index e603cfebd39..101acb60123 100644 --- a/lib/dispatcher/client.js +++ b/lib/dispatcher/client.js @@ -54,7 +54,8 @@ const { kMaxConcurrentStreams, kHTTP2InitialWindowSize, kHTTP2ConnectionWindowSize, - kResume + kResume, + kPingInterval } = require('../core/symbols.js') const connectH1 = require('./client-h1.js') const connectH2 = require('./client-h2.js') @@ -112,7 +113,8 @@ class Client extends DispatcherBase { allowH2, useH2c, initialWindowSize, - connectionWindowSize + connectionWindowSize, + pingInterval } = {}) { if (keepAlive !== undefined) { throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead') @@ -216,6 +218,10 @@ class Client extends DispatcherBase { 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() if (typeof connect !== 'function') { @@ -250,6 +256,8 @@ class Client extends DispatcherBase { this[kMaxRequests] = maxRequestsPerClient this[kClosedResolve] = null 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) @@ -259,7 +267,7 @@ class Client extends DispatcherBase { // 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 + this[kPingInterval] = pingInterval != null ? pingInterval : 60e3 // Default ping interval for h2 - 1 minute // kQueue is built up of 3 sections separated by // the kRunningIdx and kPendingIdx indices. diff --git a/test/http2-dispatcher.js b/test/http2-dispatcher.js index 1986f4567f6..0c85fc2afab 100644 --- a/test/http2-dispatcher.js +++ b/test/http2-dispatcher.js @@ -1,11 +1,13 @@ 'use strict' -const { tspl } = require('@matteo.collina/tspl') const { test, after } = require('node:test') const { createSecureServer } = require('node:http2') const { once } = require('node:events') +const { setTimeout: sleep } = require('node:timers/promises') const { Writable, pipeline, PassThrough, Readable } = require('node:stream') +const { tspl } = require('@matteo.collina/tspl') + const pem = require('@metcoder95/https-pem') const { Client } = require('..') @@ -425,3 +427,265 @@ test('Should handle h2 request without body', async t => { await t.completed }) + +test('Should only accept valid ping interval values', async t => { + const planner = tspl(t, { plan: 3 }) + + planner.throws(() => new Client('https://localhost', { + connect: { + rejectUnauthorized: false + }, + allowH2: true, + pingInterval: -1 + })) + + planner.throws(() => new Client('https://localhost', { + connect: { + rejectUnauthorized: false + }, + allowH2: true, + pingInterval: 'foo' + })) + + planner.throws(() => new Client('https://localhost', { + connect: { + rejectUnauthorized: false + }, + allowH2: true, + pingInterval: 1.1 + })) + + await planner.completed +}) + +test('Should send http2 PING frames', async t => { + const server = createSecureServer(pem) + let session = null + let pingCounter = 0 + + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }, + { + waitForTrailers: true + }) + + stream.on('wantTrailers', () => { + stream.sendTrailers({ + 'x-trailer': 'hello' + }) + }) + + stream.end('hello h2!') + }) + + server.on('session', (s) => { + session = s + session.on('ping', (payload) => { + pingCounter++ + }) + }) + + t = tspl(t, { plan: 2 }) + + server.listen(0, '127.0.0.1') + await once(server, 'listening') + + const client = new Client(`https://${server.address().address}:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true, + pingInterval: 100 + }) + + after(async () => { + server.close() + }) + + client.dispatch({ + path: '/', + method: 'PUT', + body: 'hello' + }, { + onConnect () { + + }, + onHeaders () { + return true + }, + onData () { + return true + }, + onComplete (trailers) { + t.strictEqual(trailers['x-trailer'], 'hello') + }, + onError (err) { + t.ifError(err) + } + }) + + await sleep(600) + await client.close() + t.equal(pingCounter, 5, 'Expected 5 PING frames to be sent') + + await t.completed +}) + +test('Should not send http2 PING frames if interval === 0', async t => { + const server = createSecureServer(pem) + let session = null + let pingCounter = 0 + + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }, + { + waitForTrailers: true + }) + + stream.on('wantTrailers', () => { + stream.sendTrailers({ + 'x-trailer': 'hello' + }) + }) + + stream.end('hello h2!') + }) + + server.on('session', (s) => { + session = s + session.on('ping', (payload) => { + pingCounter++ + }) + }) + + t = tspl(t, { plan: 2 }) + + server.listen(0, '127.0.0.1') + await once(server, 'listening') + + const client = new Client(`https://${server.address().address}:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true, + pingInterval: 0 + }) + + after(async () => { + server.close() + }) + + client.dispatch({ + path: '/', + method: 'PUT', + body: 'hello' + }, { + onConnect () { + + }, + onHeaders () { + return true + }, + onData () { + return true + }, + onComplete (trailers) { + t.strictEqual(trailers['x-trailer'], 'hello') + }, + onError (err) { + t.ifError(err) + } + }) + + await sleep(500) + await client.close() + t.equal(pingCounter, 0, 'Expected 0 PING frames to be sent') + + await t.completed +}) + +test('Should not send http2 PING frames after connection is closed', async t => { + const server = createSecureServer(pem) + let session = null + let pingCounter = 0 + + server.on('stream', async (stream, headers) => { + stream.respond({ + 'content-type': 'text/plain; charset=utf-8', + 'x-custom-h2': headers['x-my-header'], + ':status': 200 + }, + { + waitForTrailers: true + }) + + stream.on('wantTrailers', () => { + stream.sendTrailers({ + 'x-trailer': 'hello' + }) + }) + + stream.end('hello h2!') + }) + + server.on('session', (s) => { + session = s + session.on('ping', (payload) => { + pingCounter++ + }) + }) + + t = tspl(t, { plan: 2 }) + + server.listen(0, '127.0.0.1') + await once(server, 'listening') + + const client = new Client(`https://${server.address().address}:${server.address().port}`, { + connect: { + rejectUnauthorized: false + }, + allowH2: true, + pingInterval: 0 + }) + + after(async () => { + session.close() + server.close() + }) + + client.dispatch({ + path: '/', + method: 'PUT', + body: 'hello' + }, { + onConnect () { + + }, + onHeaders () { + return true + }, + onData () { + return true + }, + onComplete (trailers) { + t.strictEqual(trailers['x-trailer'], 'hello') + }, + onError (err) { + t.ifError(err) + } + }) + + await client.close() + await sleep(500) + t.equal(pingCounter, 0, 'Expected 0 PING frames to be sent') + + await t.completed +}) diff --git a/test/types/client.test-d.ts b/test/types/client.test-d.ts index a6e181ac650..ed50d85194d 100644 --- a/test/types/client.test-d.ts +++ b/test/types/client.test-d.ts @@ -125,6 +125,11 @@ expectAssignable( connectionWindowSize: 524288 }) ) +expectAssignable( + new Client('', { + pingInterval: 60e3 + }) +) expectAssignable( new Client('', { interceptors: { diff --git a/types/client.d.ts b/types/client.d.ts index f5ccde2be43..a6e20221f68 100644 --- a/types/client.d.ts +++ b/types/client.d.ts @@ -102,6 +102,11 @@ export declare namespace Client { * @default 524288 */ connectionWindowSize?: number; + /** + * @description Time interval between PING frames dispatch + * @default 60000 + */ + pingInterval?: number; } export interface SocketInfo { localAddress?: string