Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/docs/api/Client.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Returns: `Client`
* **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.
* **allowH2**: `boolean` - Default: `false`. Enables support for H2 if the server has assigned bigger priority to it through ALPN negotiation.
* **useH2c**: `boolean` - Default: `false`. Enforces h2c for non-https connections.
* **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.

> **Notes about HTTP/2**
Expand Down
5 changes: 4 additions & 1 deletion lib/core/connect.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const SessionCache = class WeakSessionCache {
}
}

function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
function buildConnector ({ allowH2, useH2c, maxCachedSessions, socketPath, timeout, session: customSession, ...opts }) {
if (maxCachedSessions != null && (!Number.isInteger(maxCachedSessions) || maxCachedSessions < 0)) {
throw new InvalidArgumentError('maxCachedSessions must be a positive integer or zero')
}
Expand Down Expand Up @@ -96,6 +96,9 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
port,
host: hostname
})
if (useH2c === true) {
socket.alpnProtocol = 'h2'
}
}

// Set TCP keep alive options on the socket here instead of in connect() for the case of assigning the socket
Expand Down
8 changes: 7 additions & 1 deletion lib/dispatcher/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ class Client extends DispatcherBase {
autoSelectFamilyAttemptTimeout,
// h2
maxConcurrentStreams,
allowH2
allowH2,
useH2c
} = {}) {
if (keepAlive !== undefined) {
throw new InvalidArgumentError('unsupported keepAlive, use pipelining=0 instead')
Expand Down Expand Up @@ -199,13 +200,18 @@ class Client extends DispatcherBase {
throw new InvalidArgumentError('maxConcurrentStreams must be a positive integer, greater than 0')
}

if (useH2c != null && typeof useH2c !== 'boolean') {
throw new InvalidArgumentError('useH2c must be a valid boolean value')
}

super()

if (typeof connect !== 'function') {
connect = buildConnector({
...tls,
maxCachedSessions,
allowH2,
useH2c,
socketPath,
timeout: connectTimeout,
...(typeof autoSelectFamily === 'boolean' ? { autoSelectFamily, autoSelectFamilyAttemptTimeout } : undefined),
Expand Down
83 changes: 7 additions & 76 deletions lib/dispatcher/h2c-client.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
'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

class H2CClient extends Client {
constructor (origin, clientOpts) {
if (typeof origin === 'string') {
origin = new URL(origin)
Expand All @@ -23,14 +16,14 @@ class H2CClient extends DispatcherBase {
}

const { connect, maxConcurrentStreams, pipelining, ...opts } =
clientOpts ?? {}
clientOpts ?? {}
let defaultMaxConcurrentStreams = 100
let defaultPipelining = 100

if (
maxConcurrentStreams != null &&
Number.isInteger(maxConcurrentStreams) &&
maxConcurrentStreams > 0
Number.isInteger(maxConcurrentStreams) &&
maxConcurrentStreams > 0
) {
defaultMaxConcurrentStreams = maxConcurrentStreams
}
Expand All @@ -45,76 +38,14 @@ class H2CClient extends DispatcherBase {
)
}

super()

this.#client = new Client(origin, {
super(origin, {
...opts,
connect: this.#buildConnector(connect),
maxConcurrentStreams: defaultMaxConcurrentStreams,
pipelining: defaultPipelining,
allowH2: true
allowH2: true,
useH2c: 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)
}

[kClose] () {
return this.#client.close()
}

[kDestroy] () {
return this.#client.destroy()
}
}

module.exports = H2CClient
53 changes: 52 additions & 1 deletion test/h2c-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const { test } = require('node:test')
const { tspl } = require('@matteo.collina/tspl')
const pem = require('@metcoder95/https-pem')

const { H2CClient } = require('..')
const { H2CClient, Client } = require('..')

test('Should throw if no h2c origin', async t => {
const planner = tspl(t, { plan: 1 })
Expand Down Expand Up @@ -103,3 +103,54 @@ test('Should reject request if not h2c supported', async t => {
'SocketError: other side closed'
)
})

test('Connect to h2c server over a unix domain socket', { skip: process.platform === 'win32' }, async t => {
const planner = tspl(t, { plan: 6 })
const { mkdtemp, rm } = require('node:fs/promises')
const { join } = require('node:path')
const { tmpdir } = require('node:os')

const tmpDir = await mkdtemp(join(tmpdir(), 'h2c-client-'))
const socketPath = join(tmpDir, 'server.sock')
const authority = 'localhost'

const server = createServer((req, res) => {
planner.equal(req.headers[':authority'], authority)
planner.equal(req.headers[':method'], 'GET')
planner.equal(req.headers[':path'], '/')
planner.equal(req.headers[':scheme'], 'http')
res.writeHead(200)
res.end('Hello, world!')
})

server.listen(socketPath)
await once(server, 'listening')
const client = new H2CClient(`http://${authority}/`, {
socketPath
})

const response = await client.request({ path: '/', method: 'GET' })
planner.equal(response.statusCode, 200)
planner.equal(await response.body.text(), 'Hello, world!')

t.after(async () => {
await rm(tmpDir, { recursive: true })
client.close()
server.close()
})
})

test('Should throw if bad useH2c has been passed', async t => {
t = tspl(t, { plan: 1 })

t.throws(() => {
// eslint-disable-next-line
new Client('https://localhost:1000', {
useH2c: 'true'
})
}, {
message: 'useH2c must be a valid boolean value'
})

await t.completed
})
Loading