Skip to content

Commit 7977f9a

Browse files
committed
quic: add autoWrap option to auto-attach HTTP/3
1 parent f036f05 commit 7977f9a

6 files changed

Lines changed: 157 additions & 36 deletions

File tree

‎doc/api/quic.md‎

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -476,7 +476,8 @@ added: v23.8.0
476476

477477
* `address` {string|net.SocketAddress}
478478
* `options` {quic.SessionOptions}
479-
* Returns: {Promise} a promise for a {quic.QuicSession}
479+
* Returns: {Promise} a promise for a {quic.QuicSession}, or {quic.Http3Session}
480+
if [`sessionOptions.autoWrap`][] is enabled and an HTTP/3 ALPN is negotiated.
480481

481482
Initiate a new client-side session.
482483

@@ -3061,6 +3062,23 @@ list that the client also supports.
30613062

30623063
This option is required; omitting it throws `ERR_MISSING_OPTION`.
30633064

3065+
#### `sessionOptions.autoWrap`
3066+
3067+
<!-- YAML
3068+
added: REPLACEME
3069+
-->
3070+
3071+
* Type: {boolean}
3072+
* **Default:** `true`
3073+
3074+
If this option is set for [`quic.connect()`][] or [`quic.listen()`][], then
3075+
sessions are automatically exposed as wrapped [`Http3Session`][] instances
3076+
instead of raw [`QuicSession`][], if an HTTP/3 ALPN (`h3` or an `h3-*` draft)
3077+
is negotiated.
3078+
3079+
Set this to `false` to always receive a raw [`QuicSession`][] and configure
3080+
HTTP/3 yourself with [`Http3Session.from()`][] instead.
3081+
30643082
#### `sessionOptions.ca`
30653083

30663084
<!-- YAML
@@ -3816,7 +3834,7 @@ added: v23.8.0
38163834
-->
38173835

38183836
* `this` {quic.QuicEndpoint}
3819-
* `session` {quic.QuicSession}
3837+
* `session` {quic.QuicSession|quic.Http3Session}
38203838

38213839
The callback function that is invoked when a new server session is initiated by
38223840
a remote peer. It is called once the peer's TLS `ClientHello` has been
@@ -4063,10 +4081,13 @@ added:
40634081
- v24.20.0
40644082
-->
40654083

4066-
HTTP/3, backed by `nghttp3`, can run on top of a QUIC session by attaching
4067-
an [`Http3Session`][]. Negotiating the `'h3'` ALPN tells the peer which
4068-
protocol to speak, but does not change how the connection works locally,
4069-
so both are needed. See [`new Http3Session()`][] for more details.
4084+
HTTP/3, backed by `nghttp3`, runs on top of a QUIC session as an
4085+
[`Http3Session`][]. By default, [`quic.listen()`][] and [`quic.connect()`][]
4086+
provide one whenever an HTTP/3 ALPN is negotiated (see
4087+
[`sessionOptions.autoWrap`][]).
4088+
4089+
HTTP/3 can also be configured manually, by setting `autoWrap: false` and using
4090+
the [`Http3Session.from()`][] API to attach HTTP/3 to an existing QUIC session.
40704091

40714092
Attaching the HTTP/3 application enables a number of stream- and
40724093
session-level capabilities that are not available to non-HTTP/3
@@ -4104,13 +4125,13 @@ applications:
41044125
### Minimal HTTP/3 client
41054126

41064127
```mjs
4107-
import { connect, Http3Session } from 'node:quic';
4128+
import { connect } from 'node:quic';
41084129
import process from 'node:process';
41094130

4110-
const session = Http3Session.from(await connect('example.com:443', {
4131+
const session = await connect('example.com:443', {
41114132
alpn: 'h3',
41124133
servername: 'example.com',
4113-
}));
4134+
});
41144135
await session.opened;
41154136

41164137
const stream = await session.createBidirectionalStream({
@@ -4155,15 +4176,11 @@ A few things to note:
41554176
### Minimal HTTP/3 server
41564177

41574178
```mjs
4158-
import { listen, Http3Session } from 'node:quic';
4179+
import { listen } from 'node:quic';
41594180

41604181
const encoder = new TextEncoder();
41614182

4162-
const endpoint = await listen((quicSession) => {
4163-
// Attaching HTTP/3 has to happen here, synchronously, before the
4164-
// callback returns.
4165-
const session = Http3Session.from(quicSession);
4166-
4183+
const endpoint = await listen((session) => {
41674184
// The session.onstream callback fires for each new client-initiated
41684185
// stream. It is optional here: with `onheaders` configured below,
41694186
// request streams are consumed through that callback.
@@ -4999,6 +5016,7 @@ throughput issues caused by flow control.
49995016
[`session.onstream`]: #sessiononstream
50005017
[`session.opened`]: #sessionopened
50015018
[`session.sendDatagram()`]: #sessionsenddatagramdatagram-encoding
5019+
[`sessionOptions.autoWrap`]: #sessionoptionsautowrap
50025020
[`sessionOptions.cc`]: #sessionoptionscc
50035021
[`sessionOptions.ciphers`]: #sessionoptionsciphers
50045022
[`sessionOptions.datagramDropPolicy`]: #sessionoptionsdatagramdroppolicy

‎lib/internal/quic/quic.js‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const {
1919
PromiseResolve,
2020
PromiseWithResolvers,
2121
SafeSet,
22+
StringPrototypeStartsWith,
2223
Symbol,
2324
SymbolAsyncDispose,
2425
SymbolAsyncIterator,
@@ -407,6 +408,8 @@ const endpointRegistry = new SafeSet();
407408
* @property {string|string[]} [alpn] The ALPN protocol identifier(s).
408409
* For client sessions, a single string. For server sessions, an array
409410
* of protocol names in preference order.
411+
* @property {boolean} [autoWrap] Whether to provide the session wrapped in the
412+
* application matching its ALPN (e.g. an Http3Session for 'h3').
410413
* @property {string} [ciphers] The TLS ciphers
411414
* @property {string} [groups] The TLS key-exchange groups
412415
* @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The
@@ -1320,6 +1323,21 @@ function updateHeaderInterest(handle, inner) {
13201323
);
13211324
}
13221325

1326+
/**
1327+
* Wraps a new session in the application matching its ALPN, if any. The
1328+
* http3 module is loaded lazily, as it depends on this one.
1329+
* @param {QuicSession} session
1330+
* @param {string} alpn
1331+
* @returns {QuicSession|Http3Session}
1332+
*/
1333+
function autoWrapSession(session, alpn) {
1334+
if (alpn === 'h3' || StringPrototypeStartsWith(alpn, 'h3-')) {
1335+
const { Http3Session } = require('internal/quic/http3');
1336+
return Http3Session.from(session);
1337+
}
1338+
return session;
1339+
}
1340+
13231341
/**
13241342
* Applies session and stream callbacks from an options object to a session.
13251343
* @param {QuicSession} session
@@ -4385,6 +4403,7 @@ class QuicEndpoint {
43854403
stats: undefined,
43864404
truncatedReads: undefined,
43874405
onsession: undefined,
4406+
autoWrap: undefined,
43884407
sessionCallbacks: undefined,
43894408
};
43904409

@@ -4690,10 +4709,12 @@ class QuicEndpoint {
46904709
onwanttrailers,
46914710
// Stored on the endpoint and applied to each incoming session.
46924711
truncatedReads,
4712+
autoWrap,
46934713
...rest
46944714
} = options;
46954715

46964716
inner.truncatedReads = truncatedReads;
4717+
inner.autoWrap = autoWrap;
46974718

46984719
// Store session and stream callbacks to apply to each new incoming session.
46994720
inner.sessionCallbacks = {
@@ -4725,15 +4746,17 @@ class QuicEndpoint {
47254746
* Initiates a session with a remote endpoint.
47264747
* @param {object} address
47274748
* @param {SessionOptions} [options]
4728-
* @returns {QuicSession}
4749+
* @param {string} alpn The client's offered ALPN
4750+
* @returns {QuicSession|Http3Session}
47294751
*/
4730-
[kConnect](address, options) {
4752+
[kConnect](address, options, alpn) {
47314753
assertEndpointNotClosedOrClosing(this);
47324754
assertEndpointIsNotBusy(this);
47334755
validateObject(options, 'options');
47344756
const {
47354757
sessionTicket,
47364758
truncatedReads,
4759+
autoWrap,
47374760
...rest
47384761
} = options;
47394762

@@ -4750,7 +4773,7 @@ class QuicEndpoint {
47504773
if (options.verifyPeer !== undefined) {
47514774
session[kVerifyPeer] = options.verifyPeer;
47524775
}
4753-
return session;
4776+
return autoWrap ? autoWrapSession(session, alpn) : session;
47544777
}
47554778

47564779
/**
@@ -4984,6 +5007,8 @@ class QuicEndpoint {
49845007
if (inner.sessionCallbacks) {
49855008
applyCallbacks(session, inner.sessionCallbacks);
49865009
}
5010+
const wrapped = inner.autoWrap ?
5011+
autoWrapSession(session, session.alpnProtocol) : session;
49875012
if (onEndpointServerSessionChannel.hasSubscribers) {
49885013
onEndpointServerSessionChannel.publish({
49895014
__proto__: null,
@@ -4997,7 +5022,7 @@ class QuicEndpoint {
49975022
// endpoint with the error rather than surfacing as an unhandled
49985023
// exception or unhandled rejection coming out of the C++ -> JS
49995024
// boundary.
5000-
safeCallbackInvoke(inner.onsession, this, session);
5025+
safeCallbackInvoke(inner.onsession, this, wrapped);
50015026
}
50025027

50035028
// Called by the QuicSession when it closes to remove itself from
@@ -5472,6 +5497,7 @@ function processSessionOptions(options, config = kEmptyObject) {
54725497
streamIdleTimeout,
54735498
verifyPeer = 'auto',
54745499
truncatedReads = 'error',
5500+
autoWrap,
54755501
// Session callbacks that can be set at construction time to avoid
54765502
// race conditions with events that fire during or immediately
54775503
// after the handshake.
@@ -5554,6 +5580,8 @@ function processSessionOptions(options, config = kEmptyObject) {
55545580

55555581
const tls = processTlsOptions(options, forServer);
55565582

5583+
if (autoWrap !== undefined) validateBoolean(autoWrap, 'options.autoWrap');
5584+
55575585
const actualEndpoint = processEndpointOption(endpoint,
55585586
reuseEndpoint,
55595587
forServer,
@@ -5584,6 +5612,7 @@ function processSessionOptions(options, config = kEmptyObject) {
55845612
},
55855613
verifyPeer,
55865614
truncatedReads,
5615+
autoWrap: autoWrap ?? true,
55875616
qlog,
55885617
maxPayloadSize,
55895618
unacknowledgedPacketThreshold,
@@ -5675,7 +5704,8 @@ async function connect(address, options = kEmptyObject) {
56755704
});
56765705
}
56775706

5678-
const session = endpoint[kConnect](address[kSocketAddressHandle], rest);
5707+
const session = endpoint[kConnect](address[kSocketAddressHandle], rest,
5708+
options.alpn);
56795709

56805710
if (onEndpointClientSessionChannel.hasSubscribers) {
56815711
onEndpointClientSessionChannel.publish({

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ const { createPrivateKey } = await import('node:crypto');
1414
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
1515
const cert = fixtures.readKey('agent1-cert.pem');
1616

17-
// Negotiating the h3 ALPN does not itself activate HTTP/3. The ALPN is
18-
// reported as usual, but the session keeps the default application unless it
19-
// has an Http3Session attached.
17+
// With autoWrap off, negotiating the h3 ALPN does not activate HTTP/3. The
18+
// ALPN is reported as usual, but the session keeps the default application
19+
// unless an Http3Session is attached.
2020

2121
const serverOpened = Promise.withResolvers();
2222

@@ -27,13 +27,15 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
2727
serverOpened.resolve();
2828
}), {
2929
alpn: ['h3'],
30+
autoWrap: false,
3031
sni: { '*': { keys: [key], certs: [cert] } },
3132
});
3233

3334
assert.notStrictEqual(serverEndpoint.address, undefined);
3435

3536
const clientSession = await connect(serverEndpoint.address, {
3637
alpn: 'h3',
38+
autoWrap: false,
3739
servername: 'localhost',
3840
verifyPeer: 'manual',
3941
});

‎test/parallel/test-quic-h3-attach.mjs‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,6 @@ assert.throws(() => new Http3Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' });
4646
assert.throws(() => Http3Session.from(quicSession),
4747
{ code: 'ERR_INVALID_STATE' });
4848

49-
// HTTP/3 frames every stream, so raw streams can no longer be opened:
50-
const rawRefused = {
51-
code: 'ERR_INVALID_STATE',
52-
message: /Raw QUIC streams cannot be created/,
53-
};
54-
assert.rejects(quicSession.createUnidirectionalStream(), rawRefused)
55-
.then(mustCall());
56-
assert.rejects(quicSession.createBidirectionalStream(), rawRefused)
57-
.then(mustCall());
58-
5949
// Incoming streams are now reported through the Http3Session only:
6050
assert.throws(() => { quicSession.onstream = () => {}; }, {
6151
code: 'ERR_INVALID_STATE',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Flags: --experimental-quic --no-warnings
2+
3+
// Test: sessions arrive wrapped in the application matching their ALPN,
4+
// unless autoWrap is false. Servers know the negotiated protocol before
5+
// surfacing a session, and clients offer exactly one.
6+
7+
import { hasQuic, skip, mustCall } from '../common/index.mjs';
8+
import assert from 'node:assert';
9+
import * as fixtures from '../common/fixtures.mjs';
10+
11+
if (!hasQuic) {
12+
skip('QUIC is not enabled');
13+
}
14+
15+
const { listen, connect, Http3Session } = await import('node:quic');
16+
const { createPrivateKey } = await import('node:crypto');
17+
18+
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
19+
const cert = fixtures.readKey('agent1-cert.pem');
20+
const clientOpts = { servername: 'localhost', verifyPeer: 'manual' };
21+
22+
const isHttp3 = (session) => session instanceof Http3Session;
23+
24+
// Both sides wrap by ALPN.
25+
{
26+
const seen = [];
27+
const endpoint = await listen(mustCall((session) => {
28+
seen.push(isHttp3(session));
29+
session.onerror = () => {};
30+
}, 3), {
31+
alpn: ['h3', 'h3-29', 'other'],
32+
sni: { '*': { keys: [key], certs: [cert] } },
33+
});
34+
35+
// One protocol: wrapped up front, before the handshake.
36+
const single = await connect(endpoint.address, { ...clientOpts, alpn: 'h3' });
37+
assert.ok(isHttp3(single));
38+
assert.throws(() => Http3Session.from(single.quicSession),
39+
{ code: 'ERR_INVALID_STATE' });
40+
await single.opened;
41+
await single.close();
42+
43+
// Draft ALPNs count as HTTP/3 too.
44+
const draft = await connect(endpoint.address, { ...clientOpts, alpn: 'h3-29' });
45+
assert.ok(isHttp3(draft));
46+
await draft.opened;
47+
await draft.close();
48+
49+
// A non-HTTP/3 protocol stays a plain QuicSession on both sides.
50+
const other = await connect(endpoint.address, { ...clientOpts, alpn: 'other' });
51+
assert.ok(!isHttp3(other));
52+
await other.opened;
53+
await other.close();
54+
55+
await endpoint.close();
56+
assert.deepStrictEqual(seen, [true, true, false]);
57+
}
58+
59+
// Opting out gives the raw session on either side, to attach yourself.
60+
{
61+
const endpoint = await listen(mustCall((quicSession) => {
62+
assert.ok(!isHttp3(quicSession));
63+
Http3Session.from(quicSession);
64+
}), {
65+
alpn: ['h3'],
66+
autoWrap: false,
67+
sni: { '*': { keys: [key], certs: [cert] } },
68+
});
69+
const quicSession = await connect(endpoint.address,
70+
{ ...clientOpts, alpn: 'h3', autoWrap: false });
71+
assert.ok(!isHttp3(quicSession));
72+
const session = Http3Session.from(quicSession);
73+
await session.opened;
74+
await session.close();
75+
await endpoint.close();
76+
}
77+
78+
for (const autoWrap of [1, 'yes', null]) {
79+
await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, alpn: 'h3', autoWrap }),
80+
{ code: 'ERR_INVALID_ARG_TYPE', message: /options\.autoWrap/ });
81+
}

‎test/parallel/test-quic-h3-uni-stream-teardown.mjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ if (!hasQuic) {
1515
}
1616

1717
const { createPrivateKey } = await import('node:crypto');
18-
const { listen, connect, Http3Session } = await import('node:quic');
18+
const { listen, connect } = await import('node:quic');
1919

2020
const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
2121
const cert = fixtures.readKey('agent1-cert.pem');
@@ -25,11 +25,11 @@ const endpoint = await listen(mustNotCall(), {
2525
sni: { '*': { keys: [key], certs: [cert] } },
2626
});
2727

28-
const session = new Http3Session(await connect(endpoint.address, {
28+
const session = await connect(endpoint.address, {
2929
alpn: 'h3',
3030
servername: 'localhost',
3131
verifyPeer: 'manual',
32-
}));
32+
});
3333

3434
const refused = {
3535
code: 'ERR_INVALID_STATE',

0 commit comments

Comments
 (0)