diff --git a/docs/docs/api/H2CClient.md b/docs/docs/api/H2CClient.md new file mode 100644 index 00000000000..c9bbb3f17e4 --- /dev/null +++ b/docs/docs/api/H2CClient.md @@ -0,0 +1,262 @@ +# Class: H2CClient + +Extends: `undici.Dispatcher` + +A basic H2C client. + +**Example** + +```js +const { createServer } = require('node:http2') +const { once } = require('node:events') +const { H2CClient } = require('undici') + +const server = createServer((req, res) => { + res.writeHead(200) + res.end('Hello, world!') +}) + +server.listen() +once(server, 'listening').then(() => { + const client = new H2CClient(`http://localhost:${server.address().port}/`) + + const response = await client.request({ path: '/', method: 'GET' }) + console.log(response.statusCode) // 200 + response.body.text.then((text) => { + console.log(text) // Hello, world! + }) +}) +``` + +## `new H2CClient(url[, options])` + +Arguments: + +- **url** `URL | string` - Should only include the **protocol, hostname, and port**. It only supports `http` protocol. +- **options** `H2CClientOptions` (optional) + +Returns: `H2CClient` + +### Parameter: `H2CClientOptions` + +- **bodyTimeout** `number | null` (optional) - Default: `300e3` - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 300 seconds. Please note the `timeout` will be reset if you keep writing data to the socket everytime. +- **headersTimeout** `number | null` (optional) - Default: `300e3` - The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers while not sending the request. Defaults to 300 seconds. +- **keepAliveMaxTimeout** `number | null` (optional) - Default: `600e3` - The maximum allowed `keepAliveTimeout`, in milliseconds, when overridden by _keep-alive_ hints from the server. Defaults to 10 minutes. +- **keepAliveTimeout** `number | null` (optional) - Default: `4e3` - The timeout, in milliseconds, after which a socket without active requests will time out. Monitors time between activity on a connected socket. This value may be overridden by _keep-alive_ hints from the server. See [MDN: HTTP - Headers - Keep-Alive directives](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive#directives) for more details. Defaults to 4 seconds. +- **keepAliveTimeoutThreshold** `number | null` (optional) - Default: `2e3` - A number of milliseconds subtracted from server _keep-alive_ hints when overriding `keepAliveTimeout` to account for timing inaccuracies caused by e.g. transport latency. Defaults to 2 seconds. +- **maxHeaderSize** `number | null` (optional) - Default: `--max-http-header-size` or `16384` - The maximum length of request headers in bytes. Defaults to Node.js' --max-http-header-size or 16KiB. +- **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. +- **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. +- **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. +- **autoSelectFamilyAttemptTimeout**: `number` - Default: depends on local Node version, on Node 18.13.0 and above is `250`. The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. See [here](https://nodejs.org/api/net.html#socketconnectoptions-connectlistener) for more details. + +#### Parameter: `H2CConnectOptions` + +- **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe. +- **timeout** `number | null` (optional) - In milliseconds, Default `10e3`. +- **servername** `string | null` (optional) +- **keepAlive** `boolean | null` (optional) - Default: `true` - TCP keep-alive enabled +- **keepAliveInitialDelay** `number | null` (optional) - Default: `60000` - TCP keep-alive interval for the socket in milliseconds + +### Example - Basic Client instantiation + +This will instantiate the undici H2CClient, but it will not connect to the origin until something is queued. Consider using `client.connect` to prematurely connect to the origin, or just call `client.request`. + +```js +"use strict"; +import { H2CClient } from "undici"; + +const client = new H2CClient("http://localhost:3000"); +``` + +## Instance Methods + +### `H2CClient.close([callback])` + +Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise). + +### `H2CClient.destroy([error, callback])` + +Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise). + +Waits until socket is closed before invoking the callback (or returning a promise if no callback is provided). + +### `H2CClient.connect(options[, callback])` + +See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback). + +### `H2CClient.dispatch(options, handlers)` + +Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler). + +### `H2CClient.pipeline(options, handler)` + +See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler). + +### `H2CClient.request(options[, callback])` + +See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback). + +### `H2CClient.stream(options, factory[, callback])` + +See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback). + +### `H2CClient.upgrade(options[, callback])` + +See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback). + +## Instance Properties + +### `H2CClient.closed` + +- `boolean` + +`true` after `H2CClient.close()` has been called. + +### `H2CClient.destroyed` + +- `boolean` + +`true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed. + +### `H2CClient.pipelining` + +- `number` + +Property to get and set the pipelining factor. + +## Instance Events + +### Event: `'connect'` + +See [Dispatcher Event: `'connect'`](/docs/docs/api/Dispatcher.md#event-connect). + +Parameters: + +- **origin** `URL` +- **targets** `Array` + +Emitted when a socket has been created and connected. The client will connect once `client.size > 0`. + +#### Example - Client connect event + +```js +import { createServer } from "node:http2"; +import { H2CClient } from "undici"; +import { once } from "events"; + +const server = createServer((request, response) => { + response.end("Hello, World!"); +}).listen(); + +await once(server, "listening"); + +const client = new H2CClient(`http://localhost:${server.address().port}`); + +client.on("connect", (origin) => { + console.log(`Connected to ${origin}`); // should print before the request body statement +}); + +try { + const { body } = await client.request({ + path: "/", + method: "GET", + }); + body.setEncoding("utf-8"); + body.on("data", console.log); + client.close(); + server.close(); +} catch (error) { + console.error(error); + client.close(); + server.close(); +} +``` + +### Event: `'disconnect'` + +See [Dispatcher Event: `'disconnect'`](/docs/docs/api/Dispatcher.md#event-disconnect). + +Parameters: + +- **origin** `URL` +- **targets** `Array` +- **error** `Error` + +Emitted when socket has disconnected. The error argument of the event is the error which caused the socket to disconnect. The client will reconnect if or once `client.size > 0`. + +#### Example - Client disconnect event + +```js +import { createServer } from "node:http2"; +import { H2CClient } from "undici"; +import { once } from "events"; + +const server = createServer((request, response) => { + response.destroy(); +}).listen(); + +await once(server, "listening"); + +const client = new H2CClient(`http://localhost:${server.address().port}`); + +client.on("disconnect", (origin) => { + console.log(`Disconnected from ${origin}`); +}); + +try { + await client.request({ + path: "/", + method: "GET", + }); +} catch (error) { + console.error(error.message); + client.close(); + server.close(); +} +``` + +### Event: `'drain'` + +Emitted when pipeline is no longer busy. + +See [Dispatcher Event: `'drain'`](/docs/docs/api/Dispatcher.md#event-drain). + +#### Example - Client drain event + +```js +import { createServer } from "node:http2"; +import { H2CClient } from "undici"; +import { once } from "events"; + +const server = createServer((request, response) => { + response.end("Hello, World!"); +}).listen(); + +await once(server, "listening"); + +const client = new H2CClient(`http://localhost:${server.address().port}`); + +client.on("drain", () => { + console.log("drain event"); + client.close(); + server.close(); +}); + +const requests = [ + client.request({ path: "/", method: "GET" }), + client.request({ path: "/", method: "GET" }), + client.request({ path: "/", method: "GET" }), +]; + +await Promise.all(requests); + +console.log("requests completed"); +``` + +### Event: `'error'` + +Invoked for users errors such as throwing in the `onError` handler. diff --git a/docs/docsify/sidebar.md b/docs/docsify/sidebar.md index adc27b32fe9..efbc217f33d 100644 --- a/docs/docsify/sidebar.md +++ b/docs/docsify/sidebar.md @@ -4,6 +4,7 @@ * API * [Dispatcher](/docs/api/Dispatcher.md "Undici API - Dispatcher") * [Client](/docs/api/Client.md "Undici API - Client") + * [H2CClient](/docs/api/H2CClient.md "Undici H2C API - Client") * [Pool](/docs/api/Pool.md "Undici API - Pool") * [BalancedPool](/docs/api/BalancedPool.md "Undici API - BalancedPool") * [Agent](/docs/api/Agent.md "Undici API - Agent") diff --git a/index.js b/index.js index 873f5643565..625ec98f0ef 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ const Agent = require('./lib/dispatcher/agent') const ProxyAgent = require('./lib/dispatcher/proxy-agent') const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent') const RetryAgent = require('./lib/dispatcher/retry-agent') +const H2CClient = require('./lib/dispatcher/h2c-client') const errors = require('./lib/core/errors') const util = require('./lib/core/util') const { InvalidArgumentError } = errors @@ -33,6 +34,7 @@ module.exports.Agent = Agent module.exports.ProxyAgent = ProxyAgent module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent module.exports.RetryAgent = RetryAgent +module.exports.H2CClient = H2CClient module.exports.RetryHandler = RetryHandler module.exports.DecoratorHandler = DecoratorHandler diff --git a/lib/core/connect.js b/lib/core/connect.js index 8cd8abccc54..9bb49010b03 100644 --- a/lib/core/connect.js +++ b/lib/core/connect.js @@ -3,10 +3,7 @@ const net = require('node:net') const assert = require('node:assert') const util = require('./util') -const { InvalidArgumentError, ConnectTimeoutError } = require('./errors') -const timers = require('../util/timers') - -function noop () {} +const { InvalidArgumentError } = require('./errors') let tls // include tls conditionally since it is not always available @@ -106,7 +103,6 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess servername, session, localAddress, - // TODO(HTTP/2): Add support for h2c ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'], socket: httpSocket, // upgrade socket connection port, @@ -138,7 +134,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess socket.setKeepAlive(true, keepAliveInitialDelay) } - const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port }) + const clearConnectTimeout = util.setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port }) socket .setNoDelay(true) @@ -165,76 +161,4 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess } } -/** - * @param {WeakRef} socketWeakRef - * @param {object} opts - * @param {number} opts.timeout - * @param {string} opts.hostname - * @param {number} opts.port - * @returns {() => void} - */ -const setupConnectTimeout = process.platform === 'win32' - ? (socketWeakRef, opts) => { - if (!opts.timeout) { - return noop - } - - let s1 = null - let s2 = null - const fastTimer = timers.setFastTimeout(() => { - // setImmediate is added to make sure that we prioritize socket error events over timeouts - s1 = setImmediate(() => { - // Windows needs an extra setImmediate probably due to implementation differences in the socket logic - s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts)) - }) - }, opts.timeout) - return () => { - timers.clearFastTimeout(fastTimer) - clearImmediate(s1) - clearImmediate(s2) - } - } - : (socketWeakRef, opts) => { - if (!opts.timeout) { - return noop - } - - let s1 = null - const fastTimer = timers.setFastTimeout(() => { - // setImmediate is added to make sure that we prioritize socket error events over timeouts - s1 = setImmediate(() => { - onConnectTimeout(socketWeakRef.deref(), opts) - }) - }, opts.timeout) - return () => { - timers.clearFastTimeout(fastTimer) - clearImmediate(s1) - } - } - -/** - * @param {net.Socket} socket - * @param {object} opts - * @param {number} opts.timeout - * @param {string} opts.hostname - * @param {number} opts.port - */ -function onConnectTimeout (socket, opts) { - // The socket could be already garbage collected - if (socket == null) { - return - } - - let message = 'Connect Timeout Error' - if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) { - message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},` - } else { - message += ` (attempted address: ${opts.hostname}:${opts.port},` - } - - message += ` timeout: ${opts.timeout}ms)` - - util.destroy(socket, new ConnectTimeoutError(message)) -} - module.exports = buildConnector diff --git a/lib/core/util.js b/lib/core/util.js index c7e1f16135a..df72fa8d272 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -9,7 +9,8 @@ const { Blob } = require('node:buffer') const nodeUtil = require('node:util') const { stringify } = require('node:querystring') const { EventEmitter: EE } = require('node:events') -const { InvalidArgumentError } = require('./errors') +const timers = require('../util/timers') +const { InvalidArgumentError, ConnectTimeoutError } = require('./errors') const { headerNameLowerCasedRecord } = require('./constants') const { tree } = require('./tree') @@ -28,6 +29,8 @@ class BodyAsyncIterable { } } +function noop () {} + /** * @param {*} body * @returns {*} @@ -837,6 +840,78 @@ function errorRequest (client, request, err) { } } +/** + * @param {WeakRef} socketWeakRef + * @param {object} opts + * @param {number} opts.timeout + * @param {string} opts.hostname + * @param {number} opts.port + * @returns {() => void} + */ +const setupConnectTimeout = process.platform === 'win32' + ? (socketWeakRef, opts) => { + if (!opts.timeout) { + return noop + } + + let s1 = null + let s2 = null + const fastTimer = timers.setFastTimeout(() => { + // setImmediate is added to make sure that we prioritize socket error events over timeouts + s1 = setImmediate(() => { + // Windows needs an extra setImmediate probably due to implementation differences in the socket logic + s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts)) + }) + }, opts.timeout) + return () => { + timers.clearFastTimeout(fastTimer) + clearImmediate(s1) + clearImmediate(s2) + } + } + : (socketWeakRef, opts) => { + if (!opts.timeout) { + return noop + } + + let s1 = null + const fastTimer = timers.setFastTimeout(() => { + // setImmediate is added to make sure that we prioritize socket error events over timeouts + s1 = setImmediate(() => { + onConnectTimeout(socketWeakRef.deref(), opts) + }) + }, opts.timeout) + return () => { + timers.clearFastTimeout(fastTimer) + clearImmediate(s1) + } + } + +/** + * @param {net.Socket} socket + * @param {object} opts + * @param {number} opts.timeout + * @param {string} opts.hostname + * @param {number} opts.port + */ +function onConnectTimeout (socket, opts) { + // The socket could be already garbage collected + if (socket == null) { + return + } + + let message = 'Connect Timeout Error' + if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) { + message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},` + } else { + message += ` (attempted address: ${opts.hostname}:${opts.port},` + } + + message += ` timeout: ${opts.timeout}ms)` + + destroy(socket, new ConnectTimeoutError(message)) +} + const kEnumerableProperty = Object.create(null) kEnumerableProperty.enumerable = true @@ -908,5 +983,6 @@ module.exports = { nodeMajor, nodeMinor, safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']), - wrapRequestBody + wrapRequestBody, + setupConnectTimeout } diff --git a/lib/dispatcher/h2c-client.js b/lib/dispatcher/h2c-client.js new file mode 100644 index 00000000000..3a876fd4553 --- /dev/null +++ b/lib/dispatcher/h2c-client.js @@ -0,0 +1,122 @@ +'use strict' +const { connect } = require('node:net') + +const { kClose, kDestroy } = require('../core/symbols') +const { InvalidArgumentError } = require('../core/errors') +const util = require('../core/util') + +const Client = require('./client') +const DispatcherBase = require('./dispatcher-base') + +class H2CClient extends DispatcherBase { + #client = null + + constructor (origin, clientOpts) { + super() + + if (typeof origin === 'string') { + origin = new URL(origin) + } + + if (origin.protocol !== 'http:') { + throw new InvalidArgumentError( + 'h2c-client: Only h2c protocol is supported' + ) + } + + const { connect, maxConcurrentStreams, pipelining, ...opts } = + clientOpts ?? {} + let defaultMaxConcurrentStreams = 100 + let defaultPipelining = 100 + + if ( + maxConcurrentStreams != null && + Number.isInteger(maxConcurrentStreams) && + maxConcurrentStreams > 0 + ) { + defaultMaxConcurrentStreams = maxConcurrentStreams + } + + if (pipelining != null && Number.isInteger(pipelining) && pipelining > 0) { + defaultPipelining = pipelining + } + + if (defaultPipelining > defaultMaxConcurrentStreams) { + throw new InvalidArgumentError( + 'h2c-client: pipelining cannot be greater than maxConcurrentStreams' + ) + } + + this.#client = new Client(origin, { + ...opts, + connect: this.#buildConnector(connect), + maxConcurrentStreams: defaultMaxConcurrentStreams, + pipelining: defaultPipelining, + allowH2: true + }) + } + + #buildConnector (connectOpts) { + return (opts, callback) => { + const timeout = connectOpts?.connectOpts ?? 10e3 + const { hostname, port, pathname } = opts + const socket = connect({ + ...opts, + host: hostname, + port, + pathname + }) + + // Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket + if (opts.keepAlive == null || opts.keepAlive) { + const keepAliveInitialDelay = + opts.keepAliveInitialDelay == null ? 60e3 : opts.keepAliveInitialDelay + socket.setKeepAlive(true, keepAliveInitialDelay) + } + + socket.alpnProtocol = 'h2' + + const clearConnectTimeout = util.setupConnectTimeout( + new WeakRef(socket), + { timeout, hostname, port } + ) + + socket + .setNoDelay(true) + .once('connect', function () { + queueMicrotask(clearConnectTimeout) + + if (callback) { + const cb = callback + callback = null + cb(null, this) + } + }) + .on('error', function (err) { + queueMicrotask(clearConnectTimeout) + + if (callback) { + const cb = callback + callback = null + cb(err) + } + }) + + return socket + } + } + + dispatch (opts, handler) { + return this.#client.dispatch(opts, handler) + } + + async [kClose] () { + await this.#client.close() + } + + async [kDestroy] () { + await this.#client.destroy() + } +} + +module.exports = H2CClient diff --git a/package.json b/package.json index 9c04f804d25..c77b97936c9 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "test:fuzzing": "node test/fuzzing/fuzzing.test.js", "test:fetch": "npm run build:node && borp --timeout 180000 --expose-gc --concurrency 1 -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy", "test:h2": "npm run test:h2:core && npm run test:h2:fetch", - "test:h2:core": "borp -p \"test/http2*.js\"", + "test:h2:core": "borp -p \"test/+(http2|h2)*.js\"", "test:h2:fetch": "npm run build:node && borp -p \"test/fetch/http2*.js\"", "test:interceptors": "borp -p \"test/interceptors/*.js\"", "test:jest": "cross-env NODE_V8_COVERAGE= jest", diff --git a/test/h2c-client.js b/test/h2c-client.js new file mode 100644 index 00000000000..a726082bd80 --- /dev/null +++ b/test/h2c-client.js @@ -0,0 +1,121 @@ +'use strict' + +const { createServer, createSecureServer } = require('node:http2') +const { once } = require('node:events') +const { test } = require('node:test') + +const { tspl } = require('@matteo.collina/tspl') +const pem = require('https-pem') + +const { H2CClient } = require('..') + +test('Should throw if no h2c origin', async t => { + const planner = tspl(t, { plan: 1 }) + + planner.throws(() => new H2CClient('https://localhost/')) + + await planner.completed +}) + +test('Should throw if pipelining greather than concurrent streams', async t => { + const planner = tspl(t, { plan: 1 }) + + planner.throws(() => new H2CClient('http://localhost/', { pipelining: 10, maxConcurrentStreams: 5 })) + + await planner.completed +}) + +test('Should support h2c connection', async t => { + const planner = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.writeHead(200) + res.end('Hello, world!') + }) + + server.listen() + await once(server, 'listening') + const client = new H2CClient(`http://localhost:${server.address().port}/`) + + t.after(() => client.close()) + t.after(() => server.close()) + + const response = await client + .request({ path: '/', method: 'GET' }) + .catch(console.log) + planner.equal(response.statusCode, 200) + planner.equal(await response.body.text(), 'Hello, world!') +}) + +test('Should support h2c connection with body', async t => { + const planner = tspl(t, { plan: 3 }) + const bodyChunks = [] + + const server = createServer((req, res) => { + req.on('data', chunk => bodyChunks.push(chunk)) + req.on('end', () => { + res.end('Hello, world!') + }) + res.writeHead(200, { + 'Content-Type': 'text/plain' + }) + }) + + server.listen() + await once(server, 'listening') + const client = new H2CClient(`http://localhost:${server.address().port}/`) + + t.after(() => client.close()) + t.after(() => server.close()) + + const response = await client.request({ + path: '/', + method: 'POST', + body: 'Hello, world!' + }) + planner.equal(response.statusCode, 200) + planner.equal(await response.body.text(), 'Hello, world!') + planner.equal(Buffer.concat(bodyChunks).toString(), 'Hello, world!') +}) + +test('Should support h2c connection', async t => { + const planner = tspl(t, { plan: 2 }) + + const server = createServer((req, res) => { + res.writeHead(200) + res.end('Hello, world!') + }) + + server.listen() + await once(server, 'listening') + const client = new H2CClient(`http://localhost:${server.address().port}/`) + + t.after(() => client.close()) + t.after(() => server.close()) + + const response = await client.request({ path: '/', method: 'GET' }) + planner.equal(response.statusCode, 200) + planner.equal(await response.body.text(), 'Hello, world!') +}) + +test('Should reject request if not h2c supported', async t => { + const planner = tspl(t, { plan: 1 }) + + const server = createSecureServer(pem, (req, res) => { + res.writeHead(200) + res.end('Hello, world!') + }) + + server.on('sessionError', console.error) + server.listen() + await once(server, 'listening') + const client = new H2CClient(`http://localhost:${server.address().port}/`) + + t.after(() => client.close()) + t.after(() => server.close()) + + planner.rejects( + client.request({ path: '/', method: 'GET' }), + 'SocketError: other side closed' + ) +}) diff --git a/types/h2c-client.d.ts b/types/h2c-client.d.ts new file mode 100644 index 00000000000..2e878694758 --- /dev/null +++ b/types/h2c-client.d.ts @@ -0,0 +1,75 @@ +import { URL } from 'url' +import Dispatcher from './dispatcher' +import buildConnector from './connector' + +type H2ClientOptions = Omit + +/** + * A basic H2C client, mapped on top a single TCP connection. Pipelining is disabled by default. + */ +export class H2CClient extends Dispatcher { + constructor (url: string | URL, options?: H2CClient.Options) + /** Property to get and set the pipelining factor. */ + pipelining: number + /** `true` after `client.close()` has been called. */ + closed: boolean + /** `true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed. */ + destroyed: boolean + + // Override dispatcher APIs. + override connect ( + options: H2ClientOptions + ): Promise + override connect ( + options: H2ClientOptions, + callback: (err: Error | null, data: Dispatcher.ConnectData) => void + ): void +} + +export declare namespace H2CClient { + export interface Options { + /** The maximum length of request headers in bytes. Default: Node.js' `--max-http-header-size` or `16384` (16KiB). */ + maxHeaderSize?: number; + /** The amount of time, in milliseconds, the parser will wait to receive the complete HTTP headers (Node 14 and above only). Default: `300e3` milliseconds (300s). */ + headersTimeout?: number; + /** TODO */ + connectTimeout?: number; + /** The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Default: `300e3` milliseconds (300s). */ + bodyTimeout?: number; + /** the timeout, in milliseconds, after which a socket without active requests will time out. Monitors time between activity on a connected socket. This value may be overridden by *keep-alive* hints from the server. Default: `4e3` milliseconds (4s). */ + keepAliveTimeout?: number; + /** the maximum allowed `idleTimeout`, in milliseconds, when overridden by *keep-alive* hints from the server. Default: `600e3` milliseconds (10min). */ + keepAliveMaxTimeout?: number; + /** A number of milliseconds subtracted from server *keep-alive* hints when overriding `idleTimeout` to account for timing inaccuracies caused by e.g. transport latency. Default: `1e3` milliseconds (1s). */ + keepAliveTimeoutThreshold?: number; + /** TODO */ + socketPath?: string; + /** The amount of concurrent requests to be sent over the single TCP/TLS connection according to [RFC7230](https://tools.ietf.org/html/rfc7230#section-6.3.2). Default: `1`. */ + pipelining?: number; + /** If `true`, an error is thrown when the request content-length header doesn't match the length of the request body. Default: `true`. */ + strictContentLength?: boolean; + /** TODO */ + maxCachedSessions?: number; + /** TODO */ + maxRedirections?: number; + /** TODO */ + connect?: Omit, 'allowH2'> | buildConnector.connector; + /** TODO */ + maxRequestsPerClient?: number; + /** TODO */ + localAddress?: string; + /** Max response body size in bytes, -1 is disabled */ + maxResponseSize?: number; + /** Enables a family autodetection algorithm that loosely implements section 5 of RFC 8305. */ + autoSelectFamily?: boolean; + /** The amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the `autoSelectFamily` option. */ + autoSelectFamilyAttemptTimeout?: 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 + } +} + +export default H2CClient diff --git a/types/index.d.ts b/types/index.d.ts index bfc724e831a..6540a929ca9 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -6,6 +6,7 @@ import { RedirectHandler, DecoratorHandler } from './handlers' import BalancedPool from './balanced-pool' import Client from './client' +import H2CClient from './h2c-client' import buildConnector from './connector' import errors from './errors' import Agent from './agent' @@ -32,7 +33,7 @@ export * from './content-type' export * from './cache' export { Interceptable } from './mock-interceptor' -export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent } +export { Dispatcher, BalancedPool, Pool, Client, buildConnector, errors, Agent, request, stream, pipeline, connect, upgrade, setGlobalDispatcher, getGlobalDispatcher, setGlobalOrigin, getGlobalOrigin, interceptors, MockClient, MockPool, MockAgent, MockCallHistory, MockCallHistoryLog, mockErrors, ProxyAgent, EnvHttpProxyAgent, RedirectHandler, DecoratorHandler, RetryHandler, RetryAgent, H2CClient } export default Undici declare namespace Undici { @@ -43,6 +44,7 @@ declare namespace Undici { const RetryHandler: typeof import ('./retry-handler').default const BalancedPool: typeof import('./balanced-pool').default const Client: typeof import('./client').default + const H2CClient: typeof import('./h2c-client').default const buildConnector: typeof import('./connector').default const errors: typeof import('./errors').default const Agent: typeof import('./agent').default