Skip to content

Commit 1749556

Browse files
metcoder95mcollina
andauthored
feat: implement h2c client (#4118)
* feat: implement h2c client * refactor: connect timeout utils * feat: implement socket handling intrinsics * test: enhance testing * types: export typing * refactor: cleanup * refactor: Update test/h2c-client.js Co-authored-by: Matteo Collina <[email protected]> * docs: adjust documentation * feat: limit pipelining to max concurrent streams * fix: bad statement --------- Co-authored-by: Matteo Collina <[email protected]>
1 parent 67adf42 commit 1749556

File tree

10 files changed

+667
-82
lines changed

10 files changed

+667
-82
lines changed

docs/docs/api/H2CClient.md

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# Class: H2CClient
2+
3+
Extends: `undici.Dispatcher`
4+
5+
A basic H2C client.
6+
7+
**Example**
8+
9+
```js
10+
const { createServer } = require('node:http2')
11+
const { once } = require('node:events')
12+
const { H2CClient } = require('undici')
13+
14+
const server = createServer((req, res) => {
15+
res.writeHead(200)
16+
res.end('Hello, world!')
17+
})
18+
19+
server.listen()
20+
once(server, 'listening').then(() => {
21+
const client = new H2CClient(`http://localhost:${server.address().port}/`)
22+
23+
const response = await client.request({ path: '/', method: 'GET' })
24+
console.log(response.statusCode) // 200
25+
response.body.text.then((text) => {
26+
console.log(text) // Hello, world!
27+
})
28+
})
29+
```
30+
31+
## `new H2CClient(url[, options])`
32+
33+
Arguments:
34+
35+
- **url** `URL | string` - Should only include the **protocol, hostname, and port**. It only supports `http` protocol.
36+
- **options** `H2CClientOptions` (optional)
37+
38+
Returns: `H2CClient`
39+
40+
### Parameter: `H2CClientOptions`
41+
42+
- **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.
43+
- **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.
44+
- **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.
45+
- **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.
46+
- **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.
47+
- **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.
48+
- **maxResponseSize** `number | null` (optional) - Default: `-1` - The maximum length of response body in bytes. Set to `-1` to disable.
49+
- **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.
50+
- **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.
51+
- **connect** `ConnectOptions | null` (optional) - Default: `null`.
52+
- **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.
53+
- **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.
54+
- **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.
55+
56+
#### Parameter: `H2CConnectOptions`
57+
58+
- **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe.
59+
- **timeout** `number | null` (optional) - In milliseconds, Default `10e3`.
60+
- **servername** `string | null` (optional)
61+
- **keepAlive** `boolean | null` (optional) - Default: `true` - TCP keep-alive enabled
62+
- **keepAliveInitialDelay** `number | null` (optional) - Default: `60000` - TCP keep-alive interval for the socket in milliseconds
63+
64+
### Example - Basic Client instantiation
65+
66+
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`.
67+
68+
```js
69+
"use strict";
70+
import { H2CClient } from "undici";
71+
72+
const client = new H2CClient("http://localhost:3000");
73+
```
74+
75+
## Instance Methods
76+
77+
### `H2CClient.close([callback])`
78+
79+
Implements [`Dispatcher.close([callback])`](/docs/docs/api/Dispatcher.md#dispatcherclosecallback-promise).
80+
81+
### `H2CClient.destroy([error, callback])`
82+
83+
Implements [`Dispatcher.destroy([error, callback])`](/docs/docs/api/Dispatcher.md#dispatcherdestroyerror-callback-promise).
84+
85+
Waits until socket is closed before invoking the callback (or returning a promise if no callback is provided).
86+
87+
### `H2CClient.connect(options[, callback])`
88+
89+
See [`Dispatcher.connect(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherconnectoptions-callback).
90+
91+
### `H2CClient.dispatch(options, handlers)`
92+
93+
Implements [`Dispatcher.dispatch(options, handlers)`](/docs/docs/api/Dispatcher.md#dispatcherdispatchoptions-handler).
94+
95+
### `H2CClient.pipeline(options, handler)`
96+
97+
See [`Dispatcher.pipeline(options, handler)`](/docs/docs/api/Dispatcher.md#dispatcherpipelineoptions-handler).
98+
99+
### `H2CClient.request(options[, callback])`
100+
101+
See [`Dispatcher.request(options [, callback])`](/docs/docs/api/Dispatcher.md#dispatcherrequestoptions-callback).
102+
103+
### `H2CClient.stream(options, factory[, callback])`
104+
105+
See [`Dispatcher.stream(options, factory[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherstreamoptions-factory-callback).
106+
107+
### `H2CClient.upgrade(options[, callback])`
108+
109+
See [`Dispatcher.upgrade(options[, callback])`](/docs/docs/api/Dispatcher.md#dispatcherupgradeoptions-callback).
110+
111+
## Instance Properties
112+
113+
### `H2CClient.closed`
114+
115+
- `boolean`
116+
117+
`true` after `H2CClient.close()` has been called.
118+
119+
### `H2CClient.destroyed`
120+
121+
- `boolean`
122+
123+
`true` after `client.destroyed()` has been called or `client.close()` has been called and the client shutdown has completed.
124+
125+
### `H2CClient.pipelining`
126+
127+
- `number`
128+
129+
Property to get and set the pipelining factor.
130+
131+
## Instance Events
132+
133+
### Event: `'connect'`
134+
135+
See [Dispatcher Event: `'connect'`](/docs/docs/api/Dispatcher.md#event-connect).
136+
137+
Parameters:
138+
139+
- **origin** `URL`
140+
- **targets** `Array<Dispatcher>`
141+
142+
Emitted when a socket has been created and connected. The client will connect once `client.size > 0`.
143+
144+
#### Example - Client connect event
145+
146+
```js
147+
import { createServer } from "node:http2";
148+
import { H2CClient } from "undici";
149+
import { once } from "events";
150+
151+
const server = createServer((request, response) => {
152+
response.end("Hello, World!");
153+
}).listen();
154+
155+
await once(server, "listening");
156+
157+
const client = new H2CClient(`http://localhost:${server.address().port}`);
158+
159+
client.on("connect", (origin) => {
160+
console.log(`Connected to ${origin}`); // should print before the request body statement
161+
});
162+
163+
try {
164+
const { body } = await client.request({
165+
path: "/",
166+
method: "GET",
167+
});
168+
body.setEncoding("utf-8");
169+
body.on("data", console.log);
170+
client.close();
171+
server.close();
172+
} catch (error) {
173+
console.error(error);
174+
client.close();
175+
server.close();
176+
}
177+
```
178+
179+
### Event: `'disconnect'`
180+
181+
See [Dispatcher Event: `'disconnect'`](/docs/docs/api/Dispatcher.md#event-disconnect).
182+
183+
Parameters:
184+
185+
- **origin** `URL`
186+
- **targets** `Array<Dispatcher>`
187+
- **error** `Error`
188+
189+
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`.
190+
191+
#### Example - Client disconnect event
192+
193+
```js
194+
import { createServer } from "node:http2";
195+
import { H2CClient } from "undici";
196+
import { once } from "events";
197+
198+
const server = createServer((request, response) => {
199+
response.destroy();
200+
}).listen();
201+
202+
await once(server, "listening");
203+
204+
const client = new H2CClient(`http://localhost:${server.address().port}`);
205+
206+
client.on("disconnect", (origin) => {
207+
console.log(`Disconnected from ${origin}`);
208+
});
209+
210+
try {
211+
await client.request({
212+
path: "/",
213+
method: "GET",
214+
});
215+
} catch (error) {
216+
console.error(error.message);
217+
client.close();
218+
server.close();
219+
}
220+
```
221+
222+
### Event: `'drain'`
223+
224+
Emitted when pipeline is no longer busy.
225+
226+
See [Dispatcher Event: `'drain'`](/docs/docs/api/Dispatcher.md#event-drain).
227+
228+
#### Example - Client drain event
229+
230+
```js
231+
import { createServer } from "node:http2";
232+
import { H2CClient } from "undici";
233+
import { once } from "events";
234+
235+
const server = createServer((request, response) => {
236+
response.end("Hello, World!");
237+
}).listen();
238+
239+
await once(server, "listening");
240+
241+
const client = new H2CClient(`http://localhost:${server.address().port}`);
242+
243+
client.on("drain", () => {
244+
console.log("drain event");
245+
client.close();
246+
server.close();
247+
});
248+
249+
const requests = [
250+
client.request({ path: "/", method: "GET" }),
251+
client.request({ path: "/", method: "GET" }),
252+
client.request({ path: "/", method: "GET" }),
253+
];
254+
255+
await Promise.all(requests);
256+
257+
console.log("requests completed");
258+
```
259+
260+
### Event: `'error'`
261+
262+
Invoked for users errors such as throwing in the `onError` handler.

docs/docsify/sidebar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* API
55
* [Dispatcher](/docs/api/Dispatcher.md "Undici API - Dispatcher")
66
* [Client](/docs/api/Client.md "Undici API - Client")
7+
* [H2CClient](/docs/api/H2CClient.md "Undici H2C API - Client")
78
* [Pool](/docs/api/Pool.md "Undici API - Pool")
89
* [BalancedPool](/docs/api/BalancedPool.md "Undici API - BalancedPool")
910
* [Agent](/docs/api/Agent.md "Undici API - Agent")

index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const Agent = require('./lib/dispatcher/agent')
88
const ProxyAgent = require('./lib/dispatcher/proxy-agent')
99
const EnvHttpProxyAgent = require('./lib/dispatcher/env-http-proxy-agent')
1010
const RetryAgent = require('./lib/dispatcher/retry-agent')
11+
const H2CClient = require('./lib/dispatcher/h2c-client')
1112
const errors = require('./lib/core/errors')
1213
const util = require('./lib/core/util')
1314
const { InvalidArgumentError } = errors
@@ -33,6 +34,7 @@ module.exports.Agent = Agent
3334
module.exports.ProxyAgent = ProxyAgent
3435
module.exports.EnvHttpProxyAgent = EnvHttpProxyAgent
3536
module.exports.RetryAgent = RetryAgent
37+
module.exports.H2CClient = H2CClient
3638
module.exports.RetryHandler = RetryHandler
3739

3840
module.exports.DecoratorHandler = DecoratorHandler

lib/core/connect.js

Lines changed: 2 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@
33
const net = require('node:net')
44
const assert = require('node:assert')
55
const util = require('./util')
6-
const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
7-
const timers = require('../util/timers')
8-
9-
function noop () {}
6+
const { InvalidArgumentError } = require('./errors')
107

118
let tls // include tls conditionally since it is not always available
129

@@ -106,7 +103,6 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
106103
servername,
107104
session,
108105
localAddress,
109-
// TODO(HTTP/2): Add support for h2c
110106
ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'],
111107
socket: httpSocket, // upgrade socket connection
112108
port,
@@ -138,7 +134,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
138134
socket.setKeepAlive(true, keepAliveInitialDelay)
139135
}
140136

141-
const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
137+
const clearConnectTimeout = util.setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
142138

143139
socket
144140
.setNoDelay(true)
@@ -165,76 +161,4 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
165161
}
166162
}
167163

168-
/**
169-
* @param {WeakRef<net.Socket>} socketWeakRef
170-
* @param {object} opts
171-
* @param {number} opts.timeout
172-
* @param {string} opts.hostname
173-
* @param {number} opts.port
174-
* @returns {() => void}
175-
*/
176-
const setupConnectTimeout = process.platform === 'win32'
177-
? (socketWeakRef, opts) => {
178-
if (!opts.timeout) {
179-
return noop
180-
}
181-
182-
let s1 = null
183-
let s2 = null
184-
const fastTimer = timers.setFastTimeout(() => {
185-
// setImmediate is added to make sure that we prioritize socket error events over timeouts
186-
s1 = setImmediate(() => {
187-
// Windows needs an extra setImmediate probably due to implementation differences in the socket logic
188-
s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts))
189-
})
190-
}, opts.timeout)
191-
return () => {
192-
timers.clearFastTimeout(fastTimer)
193-
clearImmediate(s1)
194-
clearImmediate(s2)
195-
}
196-
}
197-
: (socketWeakRef, opts) => {
198-
if (!opts.timeout) {
199-
return noop
200-
}
201-
202-
let s1 = null
203-
const fastTimer = timers.setFastTimeout(() => {
204-
// setImmediate is added to make sure that we prioritize socket error events over timeouts
205-
s1 = setImmediate(() => {
206-
onConnectTimeout(socketWeakRef.deref(), opts)
207-
})
208-
}, opts.timeout)
209-
return () => {
210-
timers.clearFastTimeout(fastTimer)
211-
clearImmediate(s1)
212-
}
213-
}
214-
215-
/**
216-
* @param {net.Socket} socket
217-
* @param {object} opts
218-
* @param {number} opts.timeout
219-
* @param {string} opts.hostname
220-
* @param {number} opts.port
221-
*/
222-
function onConnectTimeout (socket, opts) {
223-
// The socket could be already garbage collected
224-
if (socket == null) {
225-
return
226-
}
227-
228-
let message = 'Connect Timeout Error'
229-
if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
230-
message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
231-
} else {
232-
message += ` (attempted address: ${opts.hostname}:${opts.port},`
233-
}
234-
235-
message += ` timeout: ${opts.timeout}ms)`
236-
237-
util.destroy(socket, new ConnectTimeoutError(message))
238-
}
239-
240164
module.exports = buildConnector

0 commit comments

Comments
 (0)