Skip to content

Commit e3fedb0

Browse files
authored
feat: support h2c over unix domain sockets (#4690)
* feat: support h2c over unix domain sockets * fix: disable unix socket test for windows * fix: add test case for invalid useH2c param
1 parent 5024d1b commit e3fedb0

File tree

5 files changed

+71
-79
lines changed

5 files changed

+71
-79
lines changed

docs/docs/api/Client.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Returns: `Client`
3030
* **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.
3131
* **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.
3232
* **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.
33+
* **useH2c**: `boolean` - Default: `false`. Enforces h2c for non-https connections.
3334
* **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.
3435

3536
> **Notes about HTTP/2**

lib/core/connect.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const SessionCache = class WeakSessionCache {
4343
}
4444
}
4545

46-
function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
46+
function buildConnector ({ allowH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
4747
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
4848
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
4949
}
@@ -96,6 +96,9 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
9696
port,
9797
host: hostname
9898
})
99+
if (useH2c === true) {
100+
socket.alpnProtocol = 'h2'
101+
}
99102
}
100103

101104
// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket

lib/dispatcher/client.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ class Client extends DispatcherBase {
107107
autoSelectFamilyAttemptTimeout,
108108
// h2
109109
maxConcurrentStreams,
110-
allowH2
110+
allowH2,
111+
useH2c
111112
} = {}) {
112113
if (keepAlive !== undefined) {
113114
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
@@ -199,13 +200,18 @@ class Client extends DispatcherBase {
199200
throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
200201
}
201202

203+
if (useH2c != null && typeof useH2c !== 'boolean') {
204+
throw new InvalidArgumentError('useH2c must be a valid boolean value')
205+
}
206+
202207
super()
203208

204209
if (typeof connect !== 'function') {
205210
connect = buildConnector({
206211
...tls,
207212
maxCachedSessions,
208213
allowH2,
214+
useH2c,
209215
socketPath,
210216
timeout: connectTimeout,
211217
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),

lib/dispatcher/h2c-client.js

Lines changed: 7 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
11
'use strict'
2-
const { connect } = require('node:net')
32

4-
const { kClose, kDestroy } = require('../core/symbols')
53
const { InvalidArgumentError } = require('../core/errors')
6-
const util = require('../core/util')
7-
84
const Client = require('./client')
9-
const DispatcherBase = require('./dispatcher-base')
10-
11-
class H2CClient extends DispatcherBase {
12-
#client = null
135

6+
class H2CClient extends Client {
147
constructor (origin, clientOpts) {
158
if (typeof origin === 'string') {
169
origin = new URL(origin)
@@ -23,14 +16,14 @@ class H2CClient extends DispatcherBase {
2316
}
2417

2518
const { connect, maxConcurrentStreams, pipelining, ...opts } =
26-
clientOpts ?? {}
19+
clientOpts ?? {}
2720
let defaultMaxConcurrentStreams = 100
2821
let defaultPipelining = 100
2922

3023
if (
3124
maxConcurrentStreams != null &&
32-
Number.isInteger(maxConcurrentStreams) &&
33-
maxConcurrentStreams > 0
25+
Number.isInteger(maxConcurrentStreams) &&
26+
maxConcurrentStreams > 0
3427
) {
3528
defaultMaxConcurrentStreams = maxConcurrentStreams
3629
}
@@ -45,76 +38,14 @@ class H2CClient extends DispatcherBase {
4538
)
4639
}
4740

48-
super()
49-
50-
this.#client = new Client(origin, {
41+
super(origin, {
5142
...opts,
52-
connect: this.#buildConnector(connect),
5343
maxConcurrentStreams: defaultMaxConcurrentStreams,
5444
pipelining: defaultPipelining,
55-
allowH2: true
45+
allowH2: true,
46+
useH2c: true
5647
})
5748
}
58-
59-
#buildConnector (connectOpts) {
60-
return (opts, callback) => {
61-
const timeout = connectOpts?.connectOpts ?? 10e3
62-
const { hostname, port, pathname } = opts
63-
const socket = connect({
64-
...opts,
65-
host: hostname,
66-
port,
67-
pathname
68-
})
69-
70-
// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
71-
if (opts.keepAlive == null || opts.keepAlive) {
72-
const keepAliveInitialDelay =
73-
opts.keepAliveInitialDelay == null ? 60e3 : opts.keepAliveInitialDelay
74-
socket.setKeepAlive(true, keepAliveInitialDelay)
75-
}
76-
77-
socket.alpnProtocol = 'h2'
78-
79-
const clearConnectTimeout = util.setupConnectTimeout(
80-
new WeakRef(socket),
81-
{ timeout, hostname, port }
82-
)
83-
84-
socket
85-
.setNoDelay(true)
86-
.once('connect', function () {
87-
queueMicrotask(clearConnectTimeout)
88-
if (callback) {
89-
const cb = callback
90-
callback = null
91-
cb(null, this)
92-
}
93-
})
94-
.on('error', function (err) {
95-
queueMicrotask(clearConnectTimeout)
96-
if (callback) {
97-
const cb = callback
98-
callback = null
99-
cb(err)
100-
}
101-
})
102-
103-
return socket
104-
}
105-
}
106-
107-
dispatch (opts, handler) {
108-
return this.#client.dispatch(opts, handler)
109-
}
110-
111-
[kClose] () {
112-
return this.#client.close()
113-
}
114-
115-
[kDestroy] () {
116-
return this.#client.destroy()
117-
}
11849
}
11950

12051
module.exports = H2CClient

test/h2c-client.js

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { test } = require('node:test')
77
const { tspl } = require('@matteo.collina/tspl')
88
const pem = require('@metcoder95/https-pem')
99

10-
const { H2CClient } = require('..')
10+
const { H2CClient, Client } = require('..')
1111

1212
test('Should throw if no h2c origin', async t => {
1313
const planner = tspl(t, { plan: 1 })
@@ -103,3 +103,54 @@ test('Should reject request if not h2c supported', async t => {
103103
'SocketError: other side closed'
104104
)
105105
})
106+
107+
test('Connect to h2c server over a unix domain socket', { skip: process.platform === 'win32' }, async t => {
108+
const planner = tspl(t, { plan: 6 })
109+
const { mkdtemp, rm } = require('node:fs/promises')
110+
const { join } = require('node:path')
111+
const { tmpdir } = require('node:os')
112+
113+
const tmpDir = await mkdtemp(join(tmpdir(), 'h2c-client-'))
114+
const socketPath = join(tmpDir, 'server.sock')
115+
const authority = 'localhost'
116+
117+
const server = createServer((req, res) => {
118+
planner.equal(req.headers[':authority'], authority)
119+
planner.equal(req.headers[':method'], 'GET')
120+
planner.equal(req.headers[':path'], '/')
121+
planner.equal(req.headers[':scheme'], 'http')
122+
res.writeHead(200)
123+
res.end('Hello, world!')
124+
})
125+
126+
server.listen(socketPath)
127+
await once(server, 'listening')
128+
const client = new H2CClient(`http://${authority}/`, {
129+
socketPath
130+
})
131+
132+
const response = await client.request({ path: '/', method: 'GET' })
133+
planner.equal(response.statusCode, 200)
134+
planner.equal(await response.body.text(), 'Hello, world!')
135+
136+
t.after(async () => {
137+
await rm(tmpDir, { recursive: true })
138+
client.close()
139+
server.close()
140+
})
141+
})
142+
143+
test('Should throw if bad useH2c has been passed', async t => {
144+
t = tspl(t, { plan: 1 })
145+
146+
t.throws(() => {
147+
// eslint-disable-next-line
148+
new Client('https://localhost:1000', {
149+
useH2c: 'true'
150+
})
151+
}, {
152+
message: 'useH2c must be a valid boolean value'
153+
})
154+
155+
await t.completed
156+
})

0 commit comments

Comments
 (0)