Skip to content

Commit c35427d

Browse files
authored
feat(connect): add preferH2 connector option to offer h2 first in ALPN (#5327)
1 parent 3a71e7d commit c35427d

5 files changed

Lines changed: 71 additions & 3 deletions

File tree

docs/docs/api/Connector.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Every Tls option, see [here](https://nodejs.org/api/tls.html#tls_tls_connect_opt
1313
Furthermore, the following options can be passed:
1414

1515
* **socketPath** `string | null` (optional) - Default: `null` - An IPC endpoint, either Unix domain socket or Windows named pipe.
16+
* **preferH2** `boolean` (optional) - Default: `false` - Only effective together with `allowH2`. When `true`, ALPN is offered as `['h2', 'http/1.1']` (HTTP/2 first) instead of the default `['http/1.1', 'h2']`. Use this when the server selects the ALPN protocol by *client* preference (e.g. some load balancers) so that HTTP/2 is negotiated whenever the server supports it. If the server does not support HTTP/2, ALPN transparently falls back to `http/1.1`.
1617
* **maxCachedSessions** `number | null` (optional) - Default: `100` - Maximum number of TLS cached sessions. Use 0 to disable TLS session caching. Default: `100`.
1718
* **timeout** `number | null` (optional) - In milliseconds. Default `10e3`.
1819
* **servername** `string | null` (optional)

lib/core/connect.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ const SessionCache = class WeakSessionCache {
5959
}
6060
}
6161

62-
function buildConnector ({ allowH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
62+
function buildConnector ({ allowH2, preferH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
6363
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
6464
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
6565
}
@@ -89,7 +89,7 @@ function buildConnector ({ allowH2, useH2c, maxCachedSessions, socketPath, timeo
8989
servername,
9090
session,
9191
localAddress,
92-
ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'],
92+
ALPNProtocols: allowH2 ? (preferH2 ? ['h2', 'http/1.1'] : ['http/1.1', 'h2']) : ['http/1.1'],
9393
socket: httpSocket, // upgrade socket connection
9494
port,
9595
host: hostname

test/connect-h2-alpn-order.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict'
2+
3+
const { tspl } = require('@matteo.collina/tspl')
4+
const { test, after, mock } = require('node:test')
5+
const { createSecureServer } = require('node:http2')
6+
const { once } = require('node:events')
7+
const { readFileSync } = require('node:fs')
8+
const { join } = require('node:path')
9+
const tls = require('node:tls')
10+
11+
const { Client } = require('..')
12+
13+
const key = readFileSync(join(__dirname, 'fixtures', 'key.pem'), 'utf8')
14+
const cert = readFileSync(join(__dirname, 'fixtures', 'cert.pem'), 'utf8')
15+
const ca = readFileSync(join(__dirname, 'fixtures', 'ca.pem'), 'utf8')
16+
17+
function createServer () {
18+
const server = createSecureServer({ key, cert, allowHTTP1: true }, (req, res) => {
19+
res.writeHead(200)
20+
res.end()
21+
})
22+
after(() => server.close())
23+
return server
24+
}
25+
26+
test('preferH2 offers ALPN as [h2, http/1.1] (h2 first)', async (t) => {
27+
t = tspl(t, { plan: 2 })
28+
29+
const mockConnect = mock.method(tls, 'connect')
30+
const server = createServer()
31+
await once(server.listen(0), 'listening')
32+
33+
const client = new Client(`https://localhost:${server.address().port}`, {
34+
allowH2: true,
35+
connect: { ca, servername: 'agent1', preferH2: true }
36+
})
37+
after(() => client.close())
38+
39+
const { statusCode } = await client.request({ path: '/', method: 'GET' })
40+
t.equal(statusCode, 200)
41+
t.deepStrictEqual(mockConnect.mock.calls[0].arguments[0].ALPNProtocols, ['h2', 'http/1.1'])
42+
43+
await t.completed
44+
})
45+
46+
test('without preferH2 the default ALPN order [http/1.1, h2] is preserved', async (t) => {
47+
t = tspl(t, { plan: 2 })
48+
49+
const mockConnect = mock.method(tls, 'connect')
50+
const server = createServer()
51+
await once(server.listen(0), 'listening')
52+
53+
const client = new Client(`https://localhost:${server.address().port}`, {
54+
allowH2: true,
55+
connect: { ca, servername: 'agent1' }
56+
})
57+
after(() => client.close())
58+
59+
const { statusCode } = await client.request({ path: '/', method: 'GET' })
60+
t.equal(statusCode, 200)
61+
t.deepStrictEqual(mockConnect.mock.calls[0].arguments[0].ALPNProtocols, ['http/1.1', 'h2'])
62+
63+
await t.completed
64+
})

test/types/connector.test-d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ expectAssignable<buildConnector.BuildOptions>({
2525
checkServerIdentity: () => undefined, // Test if ConnectionOptions is assignable
2626
localPort: 1234, // Test if TcpNetConnectOpts is assignable
2727
keepAlive: true,
28-
keepAliveInitialDelay: 12345
28+
keepAliveInitialDelay: 12345,
29+
allowH2: true,
30+
preferH2: true
2931
})
3032

3133
expectAssignable<buildConnector.Options>({

types/connector.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ declare function buildConnector (options?: buildConnector.BuildOptions): buildCo
77
declare namespace buildConnector {
88
export type BuildOptions = (ConnectionOptions | TcpNetConnectOpts | IpcNetConnectOpts) & {
99
allowH2?: boolean;
10+
preferH2?: boolean;
1011
maxCachedSessions?: number | null;
1112
socketPath?: string | null;
1213
timeout?: number | null;

0 commit comments

Comments
 (0)