diff --git a/benchmark/quic/h3-request.js b/benchmark/quic/h3-request.js index f96a18407ae1..cbb4f8491fbf 100644 --- a/benchmark/quic/h3-request.js +++ b/benchmark/quic/h3-request.js @@ -42,6 +42,7 @@ async function main({ mode, n }) { session.closed.catch(() => {}); session.onstream = (stream) => { stream.closed.catch(() => {}); }; }, { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders() { this.sendHeaders({ ':status': '200' }); diff --git a/doc/api/quic.md b/doc/api/quic.md index 05de774dbd9f..d8aeffa95bd8 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -67,18 +67,21 @@ is strongly recommended for users of this module. ## Architecture -The `quic` module is built around three core abstractions: +The `quic` module is built around four core abstractions: * `QuicEndpoint`: represents the local UDP socket binding for QUIC. It is used to send and receive QUIC packets and can be shared across multiple sessions. A single endpoint can be used as both a client and a server simultaneously. -* `QuicSession`: represents a QUIC connection between the local endpoint and - a remote peer. A session is created either by initiating a connection to a +* `QuicConnection`: represents a QUIC connection between the local endpoint + and a remote peer. A connection is created either by initiating it to a remote peer using `quic.connect()` or by accepting an incoming connection from a remote peer via `quic.listen()`. +* `QuicSession` and `Http3Session`: the application protocol session started + on a connection, which carries its data - raw QUIC, or HTTP/3. + * `QuicStream`: represents a QUIC stream within a session. Streams are created by either local or remote peers and can be bidirectional or unidirectional. @@ -239,20 +242,25 @@ counter tracks how many packets have been dropped by the filter. ### Applications -Every `QuicSession` is associated with a single application protocol, negotiated -via ALPN during the TLS handshake. The `quic` module is designed to be -application-agnostic in general but includes built-in support for HTTP/3 as a -specific application protocol. When using HTTP/3, the `quic` module provides -additional APIs for handling HTTP/3-specific features such as headers, trailers, -and prioritization. For other application protocols, users can implement their -own message framing and multiplexing on top of the core QUIC transport features. +Every `QuicConnection` is associated with a single application protocol. The +application protocol is selected by starting an application session on the +connection, either automatically by using `autoStart` with ALPN negotiation, or +by using `autoStart: false` and calling `.start(connection)` from a session +class. + +The `quic` module is designed to be application-agnostic in general, but also +includes built-in support for HTTP/3 as a specific application protocol. When +using HTTP/3, the `quic` module provides additional APIs for handling +HTTP/3-specific features such as headers, trailers, and prioritization. For +other application protocols, users can implement their own message framing and +multiplexing on top of the core QUIC transport features. When initiating a TLS handshake, the client will include a list of supported ALPN protocols in the `ClientHello`. The server selects one of these protocols -(if any) and includes it in the `ServerHello`. The negotiated protocol determines -how the `QuicSession` and `QuicStream` APIs behave. For example, when the `h3` -protocol is negotiated for HTTP/3, the `QuicSession` and `QuicStream` will support -HTTP/3-specific features. +(if any) and includes it in the `ServerHello`. For example, when the `h3` +protocol is negotiated for HTTP/3 and [`sessionOptions.autoStart`][] is enabled +(the default), connections will be exposed as instances of [`Http3Session`][] +which exposes APIs for various HTTP/3-specific features. Currently, the `quic` module only supports HTTP/3 as a built-in application protocol. All other protocols must be implemented by the user on top of the provided JavaScript @@ -263,9 +271,9 @@ API. The QUIC API is designed to be flexible and highly configurable to support a wide range of use cases. Users can configure various aspects of the QUIC transport, TLS handshake, and application behavior via options passed to the `quic.connect()` -and `quic.listen()` functions, as well as dynamically on `QuicEndpoint` and -`QuicSession` instances. The API also provides access to detailed statistics and -events for monitoring and debugging. +and `quic.listen()` functions, as well as dynamically on `QuicEndpoint`, +`QuicConnection`, and session instances. The API also provides access to +detailed statistics and events for monitoring and debugging. QUIC transport parameters are exchanged during the TLS handshake to negotiate various transport-level settings such as maximum stream counts, idle timeouts, @@ -285,7 +293,7 @@ operations. For example, initiating a connection with `quic.connect()` returns a promise for the established session, while incoming sessions on the server side are handled via a callback passed to `quic.listen()`. Within a session, events such as incoming streams, datagrams, and session state changes are handled -via callbacks on the `QuicSession` instance. Promises are used for operations +via callbacks on the session and its connection. Promises are used for operations that have a clear completion point, such as completion of the TLS handshake or graceful closure of a session. @@ -382,11 +390,11 @@ reconnection. Two pieces of state from a prior connection make this possible: -* A **session ticket**, received via the [`session.onsessionticket`][] callback, +* A **session ticket**, received via the [`connection.onsessionticket`][] callback, enables TLS session resumption and 0-RTT encryption. Pass it as the [`sessionOptions.sessionTicket`][] option on a subsequent connection to the same server. -* An **address validation token**, received via the [`session.onnewtoken`][] +* An **address validation token**, received via the [`connection.onnewtoken`][] callback, allows the client to skip the server's address validation step (avoiding a Retry round-trip). Pass it as the [`sessionOptions.token`][] option. @@ -396,7 +404,7 @@ completes is 0-RTT early data. On the server side, `stream.early` is `true` for streams carrying early data. The server can reject the 0-RTT attempt (for example, if its configuration has changed since the ticket was issued). When this happens, all streams opened during the 0-RTT phase are destroyed and -the client's [`session.onearlyrejected`][] callback fires. The connection +the client's [`connection.onearlyrejected`][] callback fires. The connection falls back to a normal 1-RTT handshake and the application can reopen streams. Early data is less secure than data sent after the handshake completes — it @@ -409,7 +417,7 @@ during the early data phase. A typical client session progresses through these stages: 1. Call [`quic.connect()`][] with a server address and options. This returns a - `QuicSession`. + session: `Http3Session` for HTTP/3 or `QuicSession` for other protocols. 2. The TLS handshake runs automatically. `session.opened` resolves when the handshake completes, providing the negotiated ALPN, cipher, and certificate validation results. @@ -424,18 +432,19 @@ streams arrive via the [`session.onstream`][] callback, or, for HTTP/3 sessions with an `onheaders` callback configured, directly through that callback (see the [minimal HTTP/3 server][] example). -[`session.destroy()`][] is available for immediate teardown — all open streams +[`connection.destroy()`][] is available for immediate teardown — all open streams are destroyed and the session is closed without waiting for them to finish. -`QuicEndpoint` and `QuicSession` support `Symbol.asyncDispose`, so they can -be used with `await using` for automatic cleanup. +`QuicEndpoint`, `QuicConnection`, `QuicSession`, and `Http3Session` support +`Symbol.asyncDispose`, so they can be used with `await using` for automatic +cleanup. ### Error handling Errors in the `quic` module are communicated through two complementary mechanisms: the `onerror` callback and the `closed` promise. -Both `QuicSession` and `QuicStream` expose an optional `onerror` callback. +Sessions and `QuicStream` expose an optional `onerror` callback. When a session or stream is destroyed with an error — including errors thrown by other user callbacks — the `onerror` callback is invoked with the error before the object is torn down. Setting `onerror` also marks the `closed` @@ -476,7 +485,9 @@ added: v23.8.0 * `address` {string|net.SocketAddress} * `options` {quic.SessionOptions} -* Returns: {Promise} a promise for a {quic.QuicSession} +* Returns: {Promise} a promise for a {quic.QuicSession} or {quic.Http3Session}, + depending on ALPN negotiation, if [`sessionOptions.autoStart`][] is true + (the default) or for a {quic.QuicConnection} otherwise. Initiate a new client-side session. @@ -505,7 +516,10 @@ const endpoint = new QuicEndpoint({ address: '127.0.0.1:1234', }); -const client = await connect('123.123.123.123:8888', { endpoint }); +const client = await connect('123.123.123.123:8888', { + alpn: 'h3', + endpoint, +}); ``` ## `quic.listen(onsession[, options])` @@ -527,7 +541,7 @@ import { listen } from 'node:quic'; const endpoint = await listen((session) => { // ... handle the session -}); +}, { alpn: ['h3'] }); // Closing the endpoint allows any sessions open when close is called // to complete naturally while preventing new sessions from being @@ -942,15 +956,44 @@ added: v23.8.0 * Type: {bigint} The total number of incoming packets dropped by the block list filter. Read only. -## Class: `QuicSession` +## Class: `QuicConnection` + +A `QuicConnection` represents the local side of a QUIC connection: its TLS +state, network path, transport parameters, statistics, and lifecycle. +Application data is carried by a session started on the connection: a +[`QuicSession`][] for raw QUIC, or an [`Http3Session`][] for HTTP/3. Each +session exposes its connection as `session.connection`. + +### Starting a session + + -A `QuicSession` represents the local side of a QUIC connection. +By default [`sessionOptions.autoStart`][] is `true`, and sessions are started +and provided automatically by both [`quic.connect()`][] and [`quic.listen()`][] +according to the ALPN protocol negotiated on the connection. + +If this is set to `false`, both APIs will instead provide a [`QuicConnection`][] +and the session on top must be started manually. When doing so, a server must +start the session synchronously inside the [`quic.listen()`][] callback, and +a client must start one within the tick when its [`connection.opened`][] +promise resolves. A connection with no session started by then is closed with +an error. -### `session.applicationOptions` +Starting a session throws `ERR_INVALID_STATE` if the connection already has +one, has been destroyed, or if the local session initialization fails. + +### `connection.applicationOptions` - -* `options` {Object} - * `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE` - frame sent to the peer. Must be a non-negative 62-bit unsigned varint - (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0` (no error). - * `type` {string} Either `'transport'` or `'application'`. Determines the - error code namespace used in the `CONNECTION_CLOSE` frame. When `'transport'` - (the default), the frame type is `0x1c` and the code is interpreted as a QUIC - transport error. When `'application'`, the frame type is `0x1d` and the code - is application-specific. **Default:** `'transport'`. - * `reason` {string} An optional human-readable reason string included in - the `CONNECTION_CLOSE` frame. Per RFC 9000, this is for diagnostic purposes - only and should not be used for machine-readable error descriptions. -* Returns: {Promise} - -Initiate a graceful close of the session. Existing streams will be allowed -to complete but no new streams will be opened. Once all streams have closed, -the session will be destroyed. The returned promise will be fulfilled once -the session has been destroyed. If a non-zero `code` is specified, the -promise will reject with an `ERR_QUIC_TRANSPORT_ERROR` or -`ERR_QUIC_APPLICATION_ERROR` depending on the `type`. - -### `session.opened` +### `connection.opened` -* Type: {quic.OnApplicationCallback} +* Type: {Function|undefined} -The callback to invoke when new application options, e.g. HTTP/3 settings arrived. +The callback to invoke when the server rejects 0-RTT early data. When +this fires, all streams that were opened during the 0-RTT phase have +been destroyed. The application should re-open streams if needed. +Read/write. -### `session.onerror` +This callback only fires on the client side when the server rejects +the client's 0-RTT attempt. The connection falls back to 1-RTT and +continues normally. + +### `connection.onpathvalidation` -* Type: {Function|undefined} - -An optional callback invoked when the session is destroyed with an error. -This includes errors caused by user callbacks that throw or reject (see -[Callback error handling][]). The callback receives a single argument: the -error that triggered the destruction. If the `onerror` callback itself throws -or returns a promise that rejects, the error is surfaced as an uncaught -exception. Read/write. +* Type: {quic.OnPathValidationCallback} -Can also be set via the `onerror` option in [`quic.connect()`][] or -[`quic.listen()`][]. +The callback to invoke when the path validation is updated. Read/write. -### `session.onstream` +### `connection.onsessionticket` -* Type: {quic.OnStreamCallback} - -The callback to invoke when a new stream is initiated by a remote peer. Read/write. +* Type: {quic.OnSessionTicketCallback} -If no `onstream` callback is set and the stream has no other consumer, an -incoming stream is destroyed on arrival and a warning is emitted. An -`onheaders` callback counts as a consumer when the negotiated application -protocol supports it (e.g. HTTP/3), because it is invoked for every incoming -request stream. Other stream-level callbacks (`ontrailers`, `oninfo`, -`onwanttrailers`) do not, since they are conditional or outbound-only and -would leave the stream unobservable. An HTTP/3 server that handles requests -entirely through `onheaders` does not need to set `onstream`. +The callback to invoke when a new session ticket is received. Read/write. -### `session.ondatagram` +### `connection.onversionnegotiation` -* Type: {quic.OnDatagramCallback} +* Type: {quic.OnVersionNegotiationCallback} -The callback to invoke when a new datagram is received from a remote peer. Read/write. +The callback to invoke when a version negotiation is initiated. Read/write. -### `session.ondatagramstatus` +### `connection.onhandshake` -* Type: {quic.OnDatagramStatusCallback} +* Type: {quic.OnHandshakeCallback} -The callback to invoke when the status of a datagram is updated. Read/write. +The callback to invoke when the TLS handshake is completed. Read/write. -### `session.onearlyrejected` +### `connection.onnewtoken` -* Type: {Function|undefined} +* Type: {quic.OnNewTokenCallback} -The callback to invoke when the server rejects 0-RTT early data. When -this fires, all streams that were opened during the 0-RTT phase have -been destroyed. The application should re-open streams if needed. -Read/write. +The callback to invoke when a NEW\_TOKEN token is received from the server. +The token can be passed as the `token` option on a future connection to +the same server to skip address validation. Read/write. -This callback only fires on the client side when the server rejects -the client's 0-RTT attempt. The connection falls back to 1-RTT and -continues normally. +### `connection.onkeylog` -### `session.onpathvalidation` + + +* Type: {quic.OnKeylogCallback} + +The callback to invoke when TLS key material is available. Requires +[`sessionOptions.keylog`][] to be `true`. Each invocation receives a single +line of [NSS Key Log Format][] text (including a trailing newline). This is +useful for decrypting packet captures with tools like Wireshark. Read/write. + +Can also be set via the `onkeylog` option in [`quic.connect()`][] or +[`quic.listen()`][]. + +### `connection.onqlog` -* Type: {quic.OnPathValidationCallback} +* Type: {quic.OnQlogCallback} -The callback to invoke when the path validation is updated. Read/write. +The callback to invoke when qlog data is available. Requires +[`sessionOptions.qlog`][] to be `true`. The callback receives a string +chunk of [JSON-SEQ][] formatted qlog data and a boolean `fin` flag. When +`fin` is `true`, the chunk is the final qlog output for this connection and +the concatenated chunks form a complete qlog trace. Read/write. -### `session.onsessionticket` +Qlog data arrives during the connection lifecycle. The first chunk contains +the qlog header with format metadata. Subsequent chunks contain trace +events. The final chunk (with `fin` set to `true`) is emitted during +connection destruction and completes the JSON-SEQ output. + +Can also be set via the `onqlog` option in [`quic.connect()`][] or +[`quic.listen()`][]. + +### `connection.path` -* Type: {quic.OnSessionTicketCallback} +* Type: {Object|undefined} + * `local` {net.SocketAddress} + * `remote` {net.SocketAddress} -The callback to invoke when a new session ticket is received. Read/write. +The local and remote socket addresses associated with the connection. Read only. -### `session.onversionnegotiation` +### `connection.remoteTransportParams` -* Type: {quic.OnVersionNegotiationCallback} +* Type: {quic.TransportParams|null|undefined} -The callback to invoke when a version negotiation is initiated. Read/write. +The transport parameters advertised by the remote peer during the handshake. +Returns `null` if the connection has been destroyed, `undefined` if the +handshake has not yet completed and the remote parameters are not yet +available. Read only. -### `session.onhandshake` +### `connection.servername` -* Type: {quic.OnHandshakeCallback} +* Type: {string|boolean|null} -The callback to invoke when the TLS handshake is completed. Read/write. +The SNI (Server Name Indication) host name associated with the connection. This is +`null` before the client hello is processed. Once the hello has been +processed, this is either the host name string or `false` if the handshake +had no SNI. + +### `connection.alpnProtocol` + + + +* Type: {string|null} + +The negotiated ALPN protocol. This is `null` before the client hello is +processed. Once ALPN has been negotiated, this is the protocol string. ALPN +is mandatory in QUIC so this is never `false` on successful connections, +unlike `node:tls` where this is optional. -### `session.onnewtoken` +### `connection.certificate` -* Type: {quic.OnNewTokenCallback} +* Type: {crypto.X509Certificate|undefined} -The callback to invoke when a NEW\_TOKEN token is received from the server. -The token can be passed as the `token` option on a future connection to -the same server to skip address validation. Read/write. +The local certificate as a [`crypto.X509Certificate`][] instance. Server +connections return the certificate configured for the negotiated SNI host. +Client connections return `undefined` unless a client certificate was sent. +Returns `undefined` if the connection is destroyed. -### `session.onorigin` +### `connection.peerCertificate` -* Type: {quic.OnOriginCallback} +* Type: {crypto.X509Certificate|undefined} -The callback to invoke when an ORIGIN frame (RFC 9412) is received from -the server, indicating which origins the server is authoritative for. -Read/write. +The peer's certificate as a [`crypto.X509Certificate`][] instance. Returns +`undefined` if the peer did not present a certificate or the connection is +destroyed. -### `session.ongoaway` +### `connection.ephemeralKeyInfo` -* Type: {Function} +* Type: {Object|undefined} -The callback to invoke when the peer sends an HTTP/3 GOAWAY frame, -indicating it is initiating a graceful shutdown. The callback receives -`(lastStreamId)` where `lastStreamId` is a `{bigint}`: +The ephemeral key information for the connection, with properties such as +`type`, `name`, and `size`. Only available on client connections. Returns +`undefined` for server connections or if the connection is destroyed. -* When `lastStreamId` is `-1n`, the peer sent a shutdown notice (intent - to close) without specifying a stream boundary. All existing streams - may still be processed. -* When `lastStreamId` is `>= 0n`, it is the highest stream ID the peer - may have processed. Streams with IDs above this value were NOT - processed and can be safely retried on a new connection. +### `connection.stats` + + + +* Type: {quic.QuicConnection.Stats} + +Return the current statistics for the connection. Read only. + +### `connection.updateKey()` + + + +Initiate a key update for the connection. + +### `connection[Symbol.asyncDispose]()` + + + +Calls `connection.destroy()`. To close gracefully, dispose of the session +started on the connection instead. + +## Class: `QuicSession` + + + +A `QuicSession` is a raw QUIC session, which exchanges application data directly +over the streams and datagrams of its [`QuicConnection`][]. This provides raw +QUIC APIs so that custom application protocols can be implemented on top. + +### `QuicSession.start(connection)` + + + +* `connection` {quic.QuicConnection} The connection to start the session on. +* Returns: {quic.QuicSession} + +Starts a raw QUIC session on a connection that has no session yet. See +[Starting a session][]. + +### Members forwarded to the QUIC connection + + + +Each of the following behaves exactly as the member of the same name on the +underlying [`QuicConnection`][]: `closed`, `closing`, `destroy()`, +`destroyed`, `opened`, and `stats`. + +Any callback set through the `QuicSession` is invoked with the `QuicSession` +as `this`. + +### `session.connection` + + + +* Type: {quic.QuicConnection} + +The QUIC connection on which this session is running. + +### `session.close([options])` + + + +* `options` {Object} + * `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE` + frame sent to the peer. Must be a non-negative 62-bit unsigned varint + (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0` (no error). + * `type` {string} Either `'transport'` or `'application'`. Determines the + error code namespace used in the `CONNECTION_CLOSE` frame. When `'transport'` + (the default), the frame type is `0x1c` and the code is interpreted as a QUIC + transport error. When `'application'`, the frame type is `0x1d` and the code + is application-specific. **Default:** `'transport'`. + * `reason` {string} An optional human-readable reason string included in + the `CONNECTION_CLOSE` frame. Per RFC 9000, this is for diagnostic purposes + only and should not be used for machine-readable error descriptions. +* Returns: {Promise} + +Initiate a graceful close of the session. Existing streams will be allowed +to complete but no new streams will be opened. Once all streams have closed, +the session will be destroyed. The returned promise will be fulfilled once +the session has been destroyed. If a non-zero `code` is specified, the +promise will reject with an `ERR_QUIC_TRANSPORT_ERROR` or +`ERR_QUIC_APPLICATION_ERROR` depending on the `type`. + +### `session.onerror` + + + +* Type: {Function|undefined} -After GOAWAY is received, `session.createBidirectionalStream()` will -throw `ERR_INVALID_STATE`. Existing streams continue until they -complete or the session closes. +An optional callback invoked when the session is destroyed with an error. +This includes errors caused by user callbacks that throw or reject (see +[Callback error handling][]). The callback receives a single argument: the +error that triggered the destruction. If the `onerror` callback itself throws +or returns a promise that rejects, the error is surfaced as an uncaught +exception. Read/write. -This callback is only relevant for HTTP/3 sessions. Read/write. +Can also be set via the `onerror` option in [`quic.connect()`][] or +[`quic.listen()`][]. -### `session.onkeylog` +### `session.onstream` -* Type: {quic.OnKeylogCallback} +* Type: {quic.OnStreamCallback} -The callback to invoke when TLS key material is available. Requires -[`sessionOptions.keylog`][] to be `true`. Each invocation receives a single -line of [NSS Key Log Format][] text (including a trailing newline). This is -useful for decrypting packet captures with tools like Wireshark. Read/write. +The callback to invoke when a new stream is initiated by a remote peer. Read/write. -Can also be set via the `onkeylog` option in [`quic.connect()`][] or -[`quic.listen()`][]. +If no `onstream` callback is set and the stream has no other consumer, an +incoming stream is destroyed on arrival and a warning is emitted. An +`onheaders` callback counts as a consumer when the negotiated application +protocol supports it (e.g. HTTP/3), because it is invoked for every incoming +request stream. Other stream-level callbacks (`ontrailers`, `oninfo`, +`onwanttrailers`) do not, since they are conditional or outbound-only and +would leave the stream unobservable. An HTTP/3 server that handles requests +entirely through `onheaders` does not need to set `onstream`. -### `session.onqlog` +### `session.ondatagram` -* Type: {quic.OnQlogCallback} +* Type: {quic.OnDatagramCallback} -The callback to invoke when qlog data is available. Requires -[`sessionOptions.qlog`][] to be `true`. The callback receives a string -chunk of [JSON-SEQ][] formatted qlog data and a boolean `fin` flag. When -`fin` is `true`, the chunk is the final qlog output for this session and -the concatenated chunks form a complete qlog trace. Read/write. +The callback to invoke when a new datagram is received from a remote peer. Read/write. -Qlog data arrives during the connection lifecycle. The first chunk contains -the qlog header with format metadata. Subsequent chunks contain trace -events. The final chunk (with `fin` set to `true`) is emitted during -session destruction and completes the JSON-SEQ output. +### `session.ondatagramstatus` -Can also be set via the `onqlog` option in [`quic.connect()`][] or -[`quic.listen()`][]. + + +* Type: {quic.OnDatagramStatusCallback} + +The callback to invoke when the status of a datagram is updated. Read/write. ### `session.createBidirectionalStream([options])` @@ -1413,33 +1575,6 @@ the stream's outgoing side remains writable and no FIN is sent immediately. The `priority` and `incremental` options are only used when the session supports priority (e.g. HTTP/3). -### `session.path` - - - -* Type: {Object|undefined} - * `local` {net.SocketAddress} - * `remote` {net.SocketAddress} - -The local and remote socket addresses associated with the session. Read only. - -### `session.remoteTransportParams` - - - -* Type: {quic.TransportParams|null|undefined} - -The transport parameters advertised by the remote peer during the handshake. -Returns `null` if the session has been destroyed, `undefined` if the handshake -has not yet completed and the remote parameters are not yet available. Read -only. - ### `session.sendDatagram(datagram[, encoding])` - -* Type: {string|boolean|null} - -The SNI (Server Name Indication) host name associated with the session. This is -`null` before the client hello is processed. Once the hello has been -processed, this is either the host name string or `false` if the handshake -had no SNI. - -### `session.alpnProtocol` - - - -* Type: {string|null} - -The negotiated ALPN protocol. This is `null` before the client hello is -processed. Once ALPN has been negotiated, this is the protocol string. ALPN -is mandatory in QUIC so this is never `false` on successful connections, -unlike `node:tls` where this is optional. - -### `session.certificate` - - - -* Type: {crypto.X509Certificate|undefined} - -The local certificate as a [`crypto.X509Certificate`][] instance. Server -sessions return the certificate configured for the negotiated SNI host. -Client sessions return `undefined` unless a client certificate was sent. -Returns `undefined` if the session is destroyed. - -### `session.peerCertificate` - - - -* Type: {crypto.X509Certificate|undefined} - -The peer's certificate as a [`crypto.X509Certificate`][] instance. Returns -`undefined` if the peer did not present a certificate or the session is -destroyed. - -### `session.ephemeralKeyInfo` - - - -* Type: {Object|undefined} - -The ephemeral key information for the session, with properties such as -`type`, `name`, and `size`. Only available on client sessions. Returns -`undefined` for server sessions or if the session is destroyed. - ### `session.maxDatagramSize` - -* Type: {quic.QuicSession.Stats} - -Return the current statistics for the session. Read only. - -### `session.updateKey()` - - - -Initiate a key update for the session. - ### `session[Symbol.asyncDispose]()` -* Type: {quic.QuicSession|null} +* Type: {quic.QuicSession|quic.Http3Session|null} The session that created this stream, or `null` if the stream has been destroyed. Read only. @@ -2704,7 +2748,8 @@ added: * Type: {Object} -The application specific options. +The application specific options, configured for HTTP/3 with +[`Http3Session.start()`][]. #### `applicationOptions.maxHeaderPairs` @@ -3104,11 +3149,7 @@ preference order that the server supports (e.g. `['h3', 'h3-29']`). During the TLS handshake, the server selects the first protocol from its list that the client also supports. -The negotiated ALPN determines which Application implementation is used -for the session. `'h3'` and `'h3-*'` variants select the HTTP/3 -application; all other values select the default application. - -Default: `'h3'` +This option is required; omitting it throws `ERR_MISSING_OPTION`. #### `sessionOptions.application` @@ -3120,12 +3161,16 @@ added: * Type: {quic.ApplicationOptions} -Application-specific options. +Application-specific options, such as the HTTP/3 settings of an +[`Http3Session`][], for the session started by [`sessionOptions.autoStart`][]. +When `autoStart` is `false`, pass the settings to [`Http3Session.start()`][] +instead. ```mjs const { listen } = await import('node:quic'); await listen((session) => { /* ... */ }, { + alpn: ['h3'], application: { maxHeaderPairs: 64, qpackMaxDTableCapacity: 8192, @@ -3135,6 +3180,28 @@ await listen((session) => { /* ... */ }, { }); ``` +#### `sessionOptions.autoStart` + + + +* Type: {boolean} +* **Default:** `true` + +When `true`, calls to [`quic.connect()`][] or [`quic.listen()`][] will expose +connections as session instances automatically - either an [`Http3Session`][] +or a raw [`QuicSession`][], depending on the negotiated ALPN value. + +Set this to `false` to receive a [`QuicConnection`][] from both APIs instead. +In this case, no session will be started automatically, and the application +protocol session must be selected & started manually instead, see +[Starting a session][] for details. + +The `onerror`, `onstream`, `ondatagram`, `ondatagramstatus`, and `application` +options only configure the automatically started sessions, and so these will +throw an error if provided with `autoStart: false`. + #### `sessionOptions.ca` * `this` {quic.QuicEndpoint} -* `session` {quic.QuicSession} +* `session` {quic.QuicSession|quic.Http3Session|quic.QuicConnection} The + session started on the new connection, or the connection itself if + [`sessionOptions.autoStart`][] is `false`. The callback function that is invoked when a new server session is initiated by a remote peer. It is called once the peer's TLS `ClientHello` has been @@ -3903,7 +3973,7 @@ never surfaced. added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicSession|quic.Http3Session} * `stream` {quic.QuicStream} ### Callback: `OnDatagramCallback` @@ -3912,7 +3982,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicSession|quic.Http3Session} * `datagram` {Uint8Array} * `early` {boolean} @@ -3922,7 +3992,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicSession|quic.Http3Session} * `id` {bigint} * `status` {string} One of `'acknowledged'`, `'lost'`, or `'abandoned'`. `'acknowledged'` means the peer confirmed receipt. `'lost'` means the @@ -3936,7 +4006,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.Http3Session} * `applicationoption` {quic.QuicSession} The callback function that is invoked when application options change. @@ -3949,7 +4019,7 @@ may arrive after the connection is established. added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `result` {string} One of either `'success'`, `'failure'`, or `'aborted'`. * `newLocalAddress` {net.SocketAddress} The local address of the validated path. * `newRemoteAddress` {net.SocketAddress} The remote address of the validated path. @@ -3967,7 +4037,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `ticket` {Object} ### Callback: `OnVersionNegotiationCallback` @@ -3976,7 +4046,7 @@ added: v23.8.0 added: v23.8.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `version` {number} The QUIC version that was configured for this session (the version that the server did not support). * `requestedVersions` {number\[]} The versions advertised by the server in @@ -3995,8 +4065,8 @@ callback returns. added: v23.8.0 --> -* `this` {quic.QuicSession} -* `info` {Object} The same object that `session.opened` resolves with. +* `this` {quic.QuicConnection} +* `info` {Object} The same object that `connection.opened` resolves with. * `local` {net.SocketAddress} The local socket address. * `remote` {net.SocketAddress} The remote socket address. * `servername` {string} The SNI server name negotiated during the handshake. @@ -4018,7 +4088,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `token` {Buffer} The NEW\_TOKEN token data. * `address` {SocketAddress} The remote address the token is associated with. @@ -4030,7 +4100,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.Http3Session} * `origins` {string\[]} The list of origins the server is authoritative for. ### Callback: `OnKeylogCallback` @@ -4041,7 +4111,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `line` {string} A single line of [NSS Key Log Format][] text, including a trailing newline character. @@ -4058,7 +4128,7 @@ added: - v24.20.0 --> -* `this` {quic.QuicSession} +* `this` {quic.QuicConnection} * `data` {string} A chunk of [JSON-SEQ][] formatted [qlog][] data. * `fin` {boolean} `true` if this is the final qlog chunk for the session. @@ -4136,11 +4206,13 @@ added: - v24.20.0 --> -When the negotiated ALPN identifier is `'h3'` (or one of the `'h3-*'` -draft variants), the QUIC session runs the HTTP/3 application backed -by `nghttp3`. `'h3'` is the default ALPN for `quic.connect()` and -`quic.listen()`, so HTTP/3 is what you get unless you select a -different ALPN explicitly. +HTTP/3, backed by `nghttp3`, runs on top of a QUIC session as an +[`Http3Session`][]. By default, [`quic.listen()`][] and [`quic.connect()`][] +provide one whenever an HTTP/3 ALPN is negotiated (see +[`sessionOptions.autoStart`][]). + +HTTP/3 can also be configured manually, by setting `autoStart: false` and using +the [`Http3Session.start()`][] API to start HTTP/3 on a QUIC connection. Selecting the HTTP/3 application enables a number of stream- and session-level capabilities that are not available to non-HTTP/3 @@ -4163,17 +4235,19 @@ applications: * **ORIGIN frame (RFC 9412)** — servers automatically advertise the hostnames in their [`sessionOptions.sni`][] map (entries with `authoritative: true`); clients receive the list via - [`session.onorigin`][]. + [`http3session.onorigin`][]. * **GOAWAY** — graceful shutdown. The server emits `GOAWAY` as part of [`session.close()`][]; the client observes it via - [`session.ongoaway`][] and stops opening new bidirectional streams. + [`http3session.ongoaway`][] and stops opening new bidirectional streams. * **Extended CONNECT settings (RFC 9220)** — the `SETTINGS_ENABLE_CONNECT_PROTOCOL` setting can be enabled via - [`application.enableConnectProtocol`][]. The setting is exchanged + [`application.enableConnectProtocol`][] (see + [`sessionOptions.application`][]). The setting is exchanged but the application is responsible for handling the `:protocol` pseudo-header and any payload framing on top. * **QPACK tuning** — dynamic-table size and blocked-streams limits - via [`application.qpackMaxDTableCapacity`][] and friends. + via [`application.qpackMaxDTableCapacity`][] and friends (see + [`sessionOptions.application`][]). ### Minimal HTTP/3 client @@ -4182,7 +4256,7 @@ import { connect } from 'node:quic'; import process from 'node:process'; const session = await connect('example.com:443', { - // ALPN defaults to 'h3'. + alpn: 'h3', servername: 'example.com', }); await session.opened; @@ -4238,8 +4312,8 @@ const endpoint = await listen((session) => { // stream. It is optional here: with `onheaders` configured below, // request streams are consumed through that callback. }, { + alpn: ['h3'], sni: { '*': { keys: [defaultKey], certs: [defaultCert] } }, - // ALPN defaults to 'h3'. onheaders(headers) { // `this` is the QuicStream. Pseudo-headers are available on the // request header block (`:method`, `:path`, `:scheme`, @@ -4288,6 +4362,176 @@ Server-side notes: cookie handling. These are deliberately left to higher-level libraries built on top of `node:quic`. +## Class: `Http3Session` + + + +An HTTP/3 session, started on a [`QuicConnection`][]. HTTP/3 interprets the +data on the connection's streams and exposes APIs to allow you to use HTTP/3 +over QUIC. The streams that this session exposes are still `QuicStream` +instances, but they gain HTTP/3 APIs and functionality from the application. + +The HTTP/3 session API exposes the HTTP/3 session details: the settings, +statistics, and HTTP/3-level events. The connection details underneath (e.g. +the TLS identity and negotiated ALPN, paths, transport parameters, and key +updates) are on the QUIC connection, accessible as +[`http3session.connection`][]. + +### `Http3Session.start(connection[, options])` + + + +* `connection` {quic.QuicConnection} The connection to start HTTP/3 on. +* `options` {Object} + * `settings` {quic.ApplicationOptions} The HTTP/3 settings to use. + Defaults apply to anything left out. + * `ongoaway` {Function} See [`http3session.ongoaway`][]. + * `onorigin` {Function} See [`http3session.onorigin`][]. + * `onsettings` {Function} See [`http3session.onsettings`][]. +* Returns: {quic.Http3Session} + +Starts HTTP/3 on a connection that has no session yet. See +[Starting a session][]. + +### Members forwarded to the QUIC connection + + + +Each of the following behaves exactly as the member of the same name on the +underlying [`QuicConnection`][]: `closed`, `closing`, `destroy()`, +`destroyed`, `opened`, and `stats`. + +The datagram members `sendDatagram()`, `ondatagram`, `ondatagramstatus`, +`maxDatagramSize`, and `maxPendingDatagrams` behave as on a [`QuicSession`][]. + +Any callback set through the `Http3Session` - `onerror` and the +HTTP/3-specific ones below - is invoked with the `Http3Session` as `this`. + +### `http3session.onerror` + + + +* Type: {Function|undefined} + +The HTTP/3 session's error handler, invoked with the error the session is +destroyed with. It behaves as [`session.onerror`][]. Read/write. + +### `http3session.close([options])` + + + +* `options` {Object} The same options as [`session.close()`][]. +* Returns: {Promise} + +Initiates a graceful shutdown of the HTTP/3 session, sending a `GOAWAY` frame to +the peer. Requests already in progress are allowed to complete, but no new ones +can be started. Once they have all finished, the connection is closed. The +returned promise behaves as for [`session.close()`][]. + +### `http3session.createBidirectionalStream([options])` + + + +* Returns: {Promise} fulfilled with a {quic.QuicStream} + +Opens an HTTP/3 request stream. Takes the same options as +[`session.createBidirectionalStream()`][]. + +HTTP/3 has no server-initiated request streams, so on a server session the +returned promise is rejected with `ERR_INVALID_STATE`. + +### `http3session.ongoaway` + + + +* Type: {Function} + +The callback to invoke when the peer sends an HTTP/3 GOAWAY frame, +indicating it is initiating a graceful shutdown. The callback receives +`(lastStreamId)` where `lastStreamId` is a `{bigint}`: + +* When `lastStreamId` is `-1n`, the peer sent a shutdown notice (intent + to close) without specifying a stream boundary. All existing streams + may still be processed. +* When `lastStreamId` is `>= 0n`, it is the highest stream ID the peer + may have processed. Streams with IDs above this value were NOT + processed and can be safely retried on a new connection. + +After GOAWAY is received, `http3session.createBidirectionalStream()` will +reject with `ERR_INVALID_STATE`. Existing streams continue until they +complete or the session closes. Read/write. + +### `http3session.onorigin` + + + +* Type: {quic.OnOriginCallback} + +The callback to invoke when an ORIGIN frame (RFC 9412) is received from +the server, indicating which origins the server is authoritative for. +Read/write. + +### `http3session.onstream` + + + +* Type: {Function} + +Called as `onstream(stream)` with each request stream the client opens. +HTTP/3 has no server-initiated requests, so this is never called on a client +session. See [`session.onstream`][]. Read/write. + +### `http3session.onsettings` + + + +* Type: {quic.OnApplicationCallback} + +The callback to invoke when the peer's HTTP/3 SETTINGS arrive, which may be +after the session opens. See [`http3session.settings`][]. Read/write. + +### `http3session.connection` + + + +* Type: {quic.QuicConnection} + +The QUIC connection on which this HTTP/3 session is running. + +### `http3session.settings` + + + +* Type: {quic.ApplicationOptions|null} + +The HTTP/3 settings in effect, including any update received from the peer's +SETTINGS frame, which may arrive after the session opens. `null` once the +session is destroyed. Read only. + ## Performance measurement -QUIC sessions, streams, and endpoints emit [`PerformanceEntry`][] objects +QUIC connections, streams, and endpoints emit [`PerformanceEntry`][] objects with `entryType` set to `'quic'`. These entries are only created when a [`PerformanceObserver`][] is observing the `'quic'` entry type, ensuring zero overhead when not in use. Each entry provides: -* `name` {string} One of `'QuicEndpoint'`, `'QuicSession'`, or `'QuicStream'`. +* `name` {string} One of `'QuicEndpoint'`, `'QuicConnection'`, or `'QuicStream'`. * `entryType` {string} Always `'quic'`. * `startTime` {number} High-resolution timestamp (ms) when the object was created. * `duration` {number} Lifetime in milliseconds from creation to destruction. @@ -4314,9 +4558,9 @@ Each entry provides: * `detail.stats` {QuicEndpointStats} The endpoint's statistics object (frozen at destruction time). -### `QuicSession` entries +### `QuicConnection` entries -* `detail.stats` {QuicSessionStats} The session's statistics object +* `detail.stats` {quic.QuicConnection.Stats} The connection's statistics object (frozen at destruction time). Includes bytes sent/received, RTT measurements, congestion window, packet counts, and more. * `detail.handshake` {Object|undefined} Timing-relevant handshake metadata, @@ -4325,7 +4569,7 @@ Each entry provides: * `protocol` {string} The negotiated ALPN protocol. * `earlyDataAttempted` {boolean} Whether 0-RTT early data was attempted. * `earlyDataAccepted` {boolean} Whether 0-RTT early data was accepted. -* `detail.path` {Object|undefined} The session's network path, or +* `detail.path` {Object|undefined} The connection's network path, or `undefined` if not yet established. * `local` {net.SocketAddress} * `remote` {net.SocketAddress} @@ -4345,7 +4589,7 @@ import { PerformanceObserver } from 'node:perf_hooks'; const obs = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(`${entry.name}: ${entry.duration.toFixed(1)}ms`); - if (entry.name === 'QuicSession') { + if (entry.name === 'QuicConnection') { const { stats, handshake } = entry.detail; console.log(` protocol: ${handshake?.protocol}`); console.log(` bytes sent: ${stats.bytesSent}`); @@ -4447,7 +4691,7 @@ added: v23.8.0 --> * `applicationoptions` {quic.ApplicationOptions} Current application options. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a locally-initiated stream is opened. @@ -4458,7 +4702,7 @@ added: v23.8.0 --> * `endpoint` {quic.QuicEndpoint} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `address` {net.SocketAddress} The remote server address. * `options` {quic.SessionOptions} @@ -4471,7 +4715,7 @@ added: v23.8.0 --> * `endpoint` {quic.QuicEndpoint} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `address` {net.SocketAddress|undefined} The remote peer address. Published when a server-side session is created for an incoming connection. @@ -4483,7 +4727,7 @@ added: v23.8.0 --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `direction` {string} Either `'bidi'` or `'uni'`. Published when a locally-initiated stream is opened. @@ -4495,7 +4739,7 @@ added: v23.8.0 --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `direction` {string} Either `'bidi'` or `'uni'`. Published when a remotely-initiated stream is received. @@ -4508,7 +4752,7 @@ added: v23.8.0 * `id` {bigint} The datagram ID. * `length` {number} The datagram payload size in bytes. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a datagram is queued for sending. @@ -4518,7 +4762,7 @@ Published when a datagram is queued for sending. added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a TLS key update is initiated. @@ -4528,7 +4772,7 @@ Published when a TLS key update is initiated. added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a session begins gracefully closing (including when a GOAWAY frame is received from the peer). @@ -4539,9 +4783,9 @@ GOAWAY frame is received from the peer). added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The error that caused the close, or `undefined` if clean. -* `stats` {quic.QuicSession.Stats} Final session statistics. +* `stats` {quic.QuicConnection.Stats} Final connection statistics. Published when a session is destroyed. The `stats` object is a snapshot of the final statistics at the time of destruction. @@ -4554,7 +4798,7 @@ added: - v24.20.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The error that caused the session to be destroyed. Published when a session is destroyed due to an error. Fires before the @@ -4571,7 +4815,7 @@ added: v23.8.0 * `length` {number} The datagram payload size in bytes. * `early` {boolean} Whether the datagram was received as 0-RTT early data. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a datagram is received from the remote peer. @@ -4583,7 +4827,7 @@ added: v23.8.0 * `id` {bigint} The datagram ID. * `status` {string} One of `'acknowledged'`, `'lost'`, or `'abandoned'`. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the delivery status of a sent datagram is updated. @@ -4599,7 +4843,7 @@ added: v23.8.0 * `oldLocalAddress` {net.SocketAddress|null} * `oldRemoteAddress` {net.SocketAddress|null} * `preferredAddress` {boolean} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a path validation attempt completes. @@ -4613,7 +4857,7 @@ added: * `token` {Buffer} The NEW\_TOKEN token data. * `address` {net.SocketAddress} The remote server address. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a client session receives a NEW\_TOKEN frame from the server. @@ -4625,7 +4869,7 @@ added: v23.8.0 --> * `ticket` {Object} The opaque session ticket. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a new TLS session ticket is received. @@ -4638,7 +4882,7 @@ added: v23.8.0 * `version` {number} The QUIC version that was configured for this session. * `requestedVersions` {number\[]} The versions advertised by the server. * `supportedVersions` {number\[]} The versions supported locally. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the client receives a Version Negotiation packet from the server. The session is always destroyed immediately after. @@ -4652,7 +4896,7 @@ added: --> * `origins` {string\[]} The list of origins the server is authoritative for. -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the session receives an ORIGIN frame (RFC 9412) from the peer. @@ -4663,7 +4907,7 @@ the peer. added: v23.8.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `servername` {string} * `protocol` {string} * `cipher` {string} @@ -4683,7 +4927,7 @@ added: - v24.20.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `lastStreamId` {bigint} The highest stream ID the peer may have processed. Published when the peer sends an HTTP/3 GOAWAY frame. Streams with IDs @@ -4699,7 +4943,7 @@ added: - v24.20.0 --> -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when the server rejects 0-RTT early data. All streams that were opened during the 0-RTT phase have been destroyed. Useful for diagnosing @@ -4714,7 +4958,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The error that caused the close, or `undefined` if clean. * `stats` {quic.QuicStream.Stats} Final stream statistics. @@ -4730,7 +4974,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `headers` {Object} The initial request or response headers. Published when initial headers are received on a stream. For HTTP/3 @@ -4747,7 +4991,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `trailers` {Object} The trailing headers. Published when trailing headers are received on a stream. @@ -4761,7 +5005,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `headers` {Object} The informational headers. Published when informational (1xx) headers are received on a stream @@ -4776,7 +5020,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} * `error` {any} The QUIC error associated with the reset. Published when a stream receives a RESET\_STREAM frame from the peer, @@ -4792,7 +5036,7 @@ added: --> * `stream` {quic.QuicStream} -* `session` {quic.QuicSession} +* `session` {quic.QuicConnection} Published when a stream is flow-control blocked and cannot send data until the peer increases the flow control window. Useful for diagnosing @@ -4823,14 +5067,26 @@ throughput issues caused by flow control. [RFC 9369]: https://www.rfc-editor.org/rfc/rfc9369 [RFC 9412]: https://www.rfc-editor.org/rfc/rfc9412 [RFC 9443]: https://www.rfc-editor.org/rfc/rfc9443 +[Starting a session]: #starting-a-session +[`Http3Session.start()`]: #http3sessionstartconnection-options +[`Http3Session`]: #class-http3session [`PerformanceEntry`]: perf_hooks.md#class-performanceentry [`PerformanceObserver`]: perf_hooks.md#class-performanceobserver +[`QuicConnection`]: #class-quicconnection [`QuicEndpoint`]: #class-quicendpoint [`QuicError`]: #class-quicerror -[`application.enableConnectProtocol`]: #sessionoptionsapplication +[`QuicSession`]: #class-quicsession +[`application.enableConnectProtocol`]: #applicationoptionsenableconnectprotocol [`application.enableDatagrams`]: #sessionoptionsapplication -[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication +[`application.qpackMaxDTableCapacity`]: #applicationoptionsqpackmaxdtablecapacity [`certificateCompression`]: #sessionoptionscertificatecompression +[`connection.destroy()`]: #connectiondestroyerror-options +[`connection.onearlyrejected`]: #connectiononearlyrejected +[`connection.onkeylog`]: #connectiononkeylog +[`connection.onnewtoken`]: #connectiononnewtoken +[`connection.onqlog`]: #connectiononqlog +[`connection.onsessionticket`]: #connectiononsessionticket +[`connection.opened`]: #connectionopened [`crypto.X509Certificate`]: crypto.md#class-x509certificate [`endpoint.busy`]: #endpointbusy [`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost @@ -4849,6 +5105,11 @@ throughput issues caused by flow control. [`endpointOptions.versionNegotiationRate`]: #endpointoptionsversionnegotiationrate [`error.errorCode`]: #errorerrorcode [`fs.promises.open(path, 'r')`]: fs.md#fspromisesopenpath-flags-mode +[`http3session.connection`]: #http3sessionconnection +[`http3session.ongoaway`]: #http3sessionongoaway +[`http3session.onorigin`]: #http3sessiononorigin +[`http3session.onsettings`]: #http3sessiononsettings +[`http3session.settings`]: #http3sessionsettings [`maxDatagramFrameSize`]: #transportparamsmaxdatagramframesize [`net.BlockList`]: net.md#class-netblocklist [`quic.connect()`]: #quicconnectaddress-options @@ -4856,21 +5117,14 @@ throughput issues caused by flow control. [`session.close()`]: #sessioncloseoptions [`session.createBidirectionalStream()`]: #sessioncreatebidirectionalstreamoptions [`session.createUnidirectionalStream()`]: #sessioncreateunidirectionalstreamoptions -[`session.destroy()`]: #sessiondestroyerror-options [`session.maxPendingDatagrams`]: #sessionmaxpendingdatagrams -[`session.onapplication`]: #sessiononapplication [`session.ondatagram`]: #sessionondatagram [`session.ondatagramstatus`]: #sessionondatagramstatus -[`session.onearlyrejected`]: #sessiononearlyrejected [`session.onerror`]: #sessiononerror -[`session.ongoaway`]: #sessionongoaway -[`session.onkeylog`]: #sessiononkeylog -[`session.onnewtoken`]: #sessiononnewtoken -[`session.onorigin`]: #sessiononorigin -[`session.onqlog`]: #sessiononqlog -[`session.onsessionticket`]: #sessiononsessionticket [`session.onstream`]: #sessiononstream [`session.sendDatagram()`]: #sessionsenddatagramdatagram-encoding +[`sessionOptions.application`]: #sessionoptionsapplication +[`sessionOptions.autoStart`]: #sessionoptionsautostart [`sessionOptions.cc`]: #sessionoptionscc [`sessionOptions.ciphers`]: #sessionoptionsciphers [`sessionOptions.datagramDropPolicy`]: #sessionoptionsdatagramdroppolicy diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js new file mode 100644 index 000000000000..053a62c0784e --- /dev/null +++ b/lib/internal/quic/http3.js @@ -0,0 +1,188 @@ +'use strict'; + +const { + BigInt, + NumberIsInteger, + PromiseReject, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +if (!process.features.quic || !getOptionValue('--experimental-quic')) { + return; +} + +// Internal, experimental HTTP/3 layer over node:quic. +// +// An Http3Session is started on a QuicConnection that has no session yet, +// and from then on the connection's streams are HTTP/3 request streams. + +const { + QuicSessionBase, + createApplicationStream, + getApplicationCallback, + isServerConnection, + setApplicationCallback, +} = require('internal/quic/quic'); + +const { + kPrivateConstructor, +} = require('internal/quic/symbols'); +const { kEmptyObject } = require('internal/util'); + +const { + QUIC_APPLICATION_HTTP3, + STREAM_DIRECTION_BIDIRECTIONAL: kStreamDirectionBidirectional, +} = internalBinding('quic'); + +const { + validateBoolean, + validateFunction, + validateObject, +} = require('internal/validators'); + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_OUT_OF_RANGE, + }, +} = require('internal/errors'); + +const kMaxUint64 = (1n << 64n) - 1n; + +function validateUint64Setting(value, name) { + if (value === undefined) return undefined; + let big; + if (typeof value === 'bigint') { + big = value; + } else if (typeof value === 'number') { + if (!NumberIsInteger(value)) { + throw new ERR_OUT_OF_RANGE(`options.settings.${name}`, 'an integer', + value); + } + big = BigInt(value); + } else { + throw new ERR_INVALID_ARG_TYPE(`options.settings.${name}`, + ['number', 'bigint'], value); + } + if (big < 0n || big > kMaxUint64) { + throw new ERR_OUT_OF_RANGE(`options.settings.${name}`, + `>= 0 && <= ${kMaxUint64}`, value); + } + return big; +} + +function validateBooleanSetting(value, name) { + if (value !== undefined) validateBoolean(value, `options.settings.${name}`); + return value; +} + +// Read each setting just once (they may be getters), then validate and return +// them as a plain null-proto object: +function prepareH3Settings(settings) { + const { + maxHeaderPairs, + maxHeaderLength, + maxFieldSectionSize, + qpackMaxDTableCapacity, + qpackEncoderMaxDTableCapacity, + qpackBlockedStreams, + enableConnectProtocol, + enableDatagrams, + } = settings; + return { + __proto__: null, + maxHeaderPairs: validateUint64Setting(maxHeaderPairs, 'maxHeaderPairs'), + maxHeaderLength: validateUint64Setting(maxHeaderLength, 'maxHeaderLength'), + maxFieldSectionSize: + validateUint64Setting(maxFieldSectionSize, 'maxFieldSectionSize'), + qpackMaxDTableCapacity: + validateUint64Setting(qpackMaxDTableCapacity, 'qpackMaxDTableCapacity'), + qpackEncoderMaxDTableCapacity: validateUint64Setting( + qpackEncoderMaxDTableCapacity, 'qpackEncoderMaxDTableCapacity'), + qpackBlockedStreams: + validateUint64Setting(qpackBlockedStreams, 'qpackBlockedStreams'), + enableConnectProtocol: + validateBooleanSetting(enableConnectProtocol, 'enableConnectProtocol'), + enableDatagrams: validateBooleanSetting(enableDatagrams, 'enableDatagrams'), + }; +} + +class Http3Session extends QuicSessionBase { + /** + * Starts HTTP/3 on a QuicConnection that has no session yet. + * @param {QuicConnection} connection + * @param {object} [options] + * @param {ApplicationOptions} [options.settings] + * @param {Function} [options.ongoaway] + * @param {Function} [options.onorigin] + * @param {Function} [options.onsettings] + * @returns {Http3Session} + */ + static start(connection, options) { + return new Http3Session(kPrivateConstructor, connection, options); + } + + constructor(privateSymbol, connection, options = kEmptyObject) { + validateObject(options, 'options'); + const { ongoaway, onorigin, onsettings, settings } = options; + if (ongoaway !== undefined) validateFunction(ongoaway, 'options.ongoaway'); + if (onorigin !== undefined) validateFunction(onorigin, 'options.onorigin'); + if (onsettings !== undefined) { + validateFunction(onsettings, 'options.onsettings'); + } + let preparedSettings; + if (settings !== undefined) { + validateObject(settings, 'options.settings'); + preparedSettings = prepareH3Settings(settings); + } + + // Reading settings could call getters and go into JS, so start after: + super(privateSymbol, connection, QUIC_APPLICATION_HTTP3, preparedSettings); + + if (ongoaway !== undefined) this.ongoaway = ongoaway; + if (onorigin !== undefined) this.onorigin = onorigin; + if (onsettings !== undefined) this.onsettings = onsettings; + } + + /** + * The settings in effect, including any update from the peer's SETTINGS + * frame, which may arrive after the session opens. Null once destroyed. + * @type {ApplicationOptions|null} + */ + get settings() { return this.connection.applicationOptions; } + + /** @type {Function|undefined} */ + get ongoaway() { return getApplicationCallback(this.connection, 'ongoaway'); } + set ongoaway(fn) { setApplicationCallback(this.connection, 'ongoaway', fn, this); } + + /** @type {Function|undefined} */ + get onorigin() { return getApplicationCallback(this.connection, 'onorigin'); } + set onorigin(fn) { setApplicationCallback(this.connection, 'onorigin', fn, this); } + + /** @type {Function|undefined} */ + get onsettings() { return getApplicationCallback(this.connection, 'onapplication'); } + set onsettings(fn) { + setApplicationCallback(this.connection, 'onapplication', fn, this, 'onsettings'); + } + + /** + * Opens a request stream. + * @returns {Promise} + */ + createBidirectionalStream(options) { + if (isServerConnection(this.connection)) { + return PromiseReject(new ERR_INVALID_STATE( + 'Server sessions cannot open HTTP/3 request streams')); + } + return createApplicationStream( + this.connection, kStreamDirectionBidirectional, options); + } +} + +module.exports = { + Http3Session, +}; diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 633e1f4e4dec..f63dd66e21c3 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -68,6 +68,8 @@ const { DEFAULT_PREFERRED_ADDRESS_POLICY: kPreferredAddressDefault, STREAM_DIRECTION_BIDIRECTIONAL: kStreamDirectionBidirectional, STREAM_DIRECTION_UNIDIRECTIONAL: kStreamDirectionUnidirectional, + QUIC_APPLICATION_DEFAULT: kApplicationTypeDefault, + QUIC_APPLICATION_HTTP3: kApplicationTypeHttp3, CLOSECONTEXT_CLOSE: kCloseContextClose, CLOSECONTEXT_BIND_FAILURE: kCloseContextBindFailure, CLOSECONTEXT_LISTEN_FAILURE: kCloseContextListenFailure, @@ -112,6 +114,7 @@ const { ERR_INVALID_STATE, ERR_INVALID_THIS, ERR_MISSING_ARGS, + ERR_MISSING_OPTION, ERR_OUT_OF_RANGE, ERR_QUIC_CONNECTION_FAILED, ERR_QUIC_ENDPOINT_CLOSED, @@ -185,6 +188,7 @@ const { kAttachFileHandle, kAvailable, kBlocked, + kClose, kConnect, kDatagram, kDatagramStatus, @@ -210,6 +214,7 @@ const { kPathValidation, kPrivateConstructor, kReset, + kSendDatagram, kSendHeaders, kSessionApplication, kSessionTicket, @@ -222,13 +227,13 @@ const { const { QuicEndpointStats, QuicStreamStats, - QuicSessionStats, + QuicConnectionStats, kCreateDisconnected, } = require('internal/quic/stats'); const { QuicEndpointState, - QuicSessionState, + QuicConnectionState, QuicStreamState, } = require('internal/quic/state'); @@ -407,6 +412,8 @@ const endpointRegistry = new SafeSet(); * @property {string|string[]} [alpn] The ALPN protocol identifier(s). * For client sessions, a single string. For server sessions, an array * of protocol names in preference order. + * @property {boolean} [autoStart] Whether to start the session matching the + * ALPN automatically (e.g. an Http3Session for 'h3'). * @property {string} [ciphers] The TLS ciphers * @property {string} [groups] The TLS key-exchange groups * @property {Array<'zlib'|'brotli'|'zstd'>} [certificateCompression] The @@ -463,11 +470,8 @@ const endpointRegistry = new SafeSet(); * @property {OnVersionNegotiationCallback} [onversionnegotiation] Version negotiation callback. * @property {OnHandshakeCallback} [onhandshake] Handshake-completed callback. * @property {OnNewTokenCallback} [onnewtoken] NEW_TOKEN frame callback (client only). - * @property {OnOriginCallback} [onorigin] ORIGIN frame callback (client only). - * @property {OnGoawayCallback} [ongoaway] GOAWAY frame callback. * @property {OnKeylogCallback} [onkeylog] TLS key-log callback. * @property {OnQlogCallback} [onqlog] qlog data callback. - * @property {OnApplicationCallback} [onapplication] application options callback. * @property {OnHeadersCallback} [onheaders] Default per-stream initial-headers callback. * @property {OnTrailersCallback} [ontrailers] Default per-stream trailing-headers callback. * @property {OnInfoCallback} [oninfo] Default per-stream informational-headers callback. @@ -938,14 +942,14 @@ setCallbacks({ * @param {number} direction The stream direction (0 == bidi, 1 == uni) */ onStreamCreated(stream, direction) { - const session = this[kOwner]; + const connection = this[kOwner]; // The event is ignored and the stream destroyed if the session has been destroyed. - debug('stream created callback', session, direction); - if (session.destroyed) { + debug('stream created callback', connection, direction); + if (connection.destroyed) { stream.destroy(); return; }; - session[kNewStream](stream, direction); + connection[kNewStream](stream, direction); }, // QuicStream callbacks @@ -1041,7 +1045,7 @@ const kMaxQuicErrorCode = (1n << 62n) - 1n; * `errorCode` as the wire code for the resulting RESET_STREAM / * STOP_SENDING / CONNECTION_CLOSE frame; otherwise the negotiated * application's "internal error" code is used (see - * `QuicSessionState.internalErrorCode`). + * `QuicConnectionState.internalErrorCode`). * * The Node.js error code (`error.code`) defaults to * `'ERR_QUIC_STREAM_ABORTED'` but can be overridden via @@ -1324,28 +1328,27 @@ function updateHeaderInterest(handle, inner) { } /** - * Applies session and stream callbacks from an options object to a session. - * @param {QuicSession} session + * Applies session and stream callbacks from an options object to a + * connection, and the session started on it by autoStart. + * @param {QuicConnection} connection * @param {object} cbs + * @param {QuicSession|Http3Session|QuicConnection} connOrSession */ -function applyCallbacks(session, cbs) { - if (cbs.onerror) session.onerror = cbs.onerror; - if (cbs.onstream) session.onstream = cbs.onstream; - if (cbs.ondatagram) session.ondatagram = cbs.ondatagram; - if (cbs.ondatagramstatus) session.ondatagramstatus = cbs.ondatagramstatus; - if (cbs.onpathvalidation) session.onpathvalidation = cbs.onpathvalidation; - if (cbs.onsessionticket) session.onsessionticket = cbs.onsessionticket; - if (cbs.onversionnegotiation) session.onversionnegotiation = cbs.onversionnegotiation; - if (cbs.onhandshake) session.onhandshake = cbs.onhandshake; - if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken; - if (cbs.onearlyrejected) session.onearlyrejected = cbs.onearlyrejected; - if (cbs.onorigin) session.onorigin = cbs.onorigin; - if (cbs.ongoaway) session.ongoaway = cbs.ongoaway; - if (cbs.onkeylog) session.onkeylog = cbs.onkeylog; - if (cbs.onqlog) session.onqlog = cbs.onqlog; - if (cbs.onapplication) session.onapplication = cbs.onapplication; +function applyCallbacks(connection, cbs, connOrSession) { + if (cbs.onerror) connOrSession.onerror = cbs.onerror; + if (cbs.onstream) connOrSession.onstream = cbs.onstream; + if (cbs.ondatagram) connOrSession.ondatagram = cbs.ondatagram; + if (cbs.ondatagramstatus) connOrSession.ondatagramstatus = cbs.ondatagramstatus; + if (cbs.onpathvalidation) connection.onpathvalidation = cbs.onpathvalidation; + if (cbs.onsessionticket) connection.onsessionticket = cbs.onsessionticket; + if (cbs.onversionnegotiation) connection.onversionnegotiation = cbs.onversionnegotiation; + if (cbs.onhandshake) connection.onhandshake = cbs.onhandshake; + if (cbs.onnewtoken) connection.onnewtoken = cbs.onnewtoken; + if (cbs.onearlyrejected) connection.onearlyrejected = cbs.onearlyrejected; + if (cbs.onkeylog) connection.onkeylog = cbs.onkeylog; + if (cbs.onqlog) connection.onqlog = cbs.onqlog; if (cbs.onheaders || cbs.ontrailers || cbs.oninfo || cbs.onwanttrailers) { - session[kStreamCallbacks] = { + connection[kStreamCallbacks] = { __proto__: null, onheaders: cbs.onheaders, ontrailers: cbs.ontrailers, @@ -1561,16 +1564,22 @@ function isSyncIterable(obj) { // Functions used specifically for internal or assertion purposes only. let getQuicStreamState; -let getQuicSessionState; +let getQuicConnectionState; let getQuicEndpointState; let assertIsQuicEndpoint; let assertIsQuicStream; -let assertIsQuicSession; +let assertIsQuicConnection; let assertHeadersSupported; let assertEndpointNotClosedOrClosing; let assertEndpointIsNotBusy; let isQuicStream; -let isQuicSession; +let isQuicConnection; +let isServerConnection; +let createApplicationStream; +let getApplicationCallback; +let setApplicationCallback; +let startApplication; +let getApplicationSession; let isQuicEndpoint; function maybeGetCloseError(context, status, pendingError) { @@ -1640,8 +1649,8 @@ class QuicStream { } }; - assertHeadersSupported = function(session) { - if (getQuicSessionState(session).headersSupported === 2) { + assertHeadersSupported = function(connection) { + if (getQuicConnectionState(connection).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -1656,18 +1665,18 @@ class QuicStream { /** * @param {symbol} privateSymbol * @param {object} handle - * @param {QuicSession} session + * @param {QuicConnection} connection * @param {number} direction * @param {boolean} isLocal * @param {'error'|'ignore'} truncatedReads */ - constructor(privateSymbol, handle, session, direction, isLocal, truncatedReads) { + constructor(privateSymbol, handle, connection, direction, isLocal, truncatedReads) { assertPrivateSymbol(privateSymbol); this.#handle = handle; handle[kOwner] = this; const inner = this.#inner; - inner.session = session; + inner.session = connection; inner.direction = direction; inner.isLocal = isLocal; inner.truncatedReads = truncatedReads; @@ -2007,7 +2016,8 @@ class QuicStream { */ get session() { assertIsQuicStream(this); - return this.#inner.session; + const connection = this.#inner.session; + return connection === undefined ? connection : getApplicationSession(connection); } /** @@ -2058,7 +2068,7 @@ class QuicStream { * side of the stream. The wire code is resolved as: * `options.code` -> `error.errorCode` (when `error` is a * `QuicError`) -> the negotiated application's "internal error" - * code from `QuicSessionState.internalErrorCode`. + * code from `QuicConnectionState.internalErrorCode`. * @param {any} error * @param {QuicStreamDestroyOptions} [options] */ @@ -2099,7 +2109,7 @@ class QuicStream { } else if (error !== undefined) { abortCode = QuicError.isQuicError(error) ? error.errorCode : - getQuicSessionState(inner.session).internalErrorCode; + getQuicConnectionState(inner.session).internalErrorCode; } // When destroying with an error, ensure the peer stops sending // data we are about to discard by emitting STOP_SENDING. The @@ -2160,7 +2170,7 @@ class QuicStream { sendHeaders(headers, options = kEmptyObject) { assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2182,7 +2192,7 @@ class QuicStream { sendInformationalHeaders(headers) { assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2203,7 +2213,7 @@ class QuicStream { sendTrailers(headers) { assertIsQuicStream(this); if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2493,13 +2503,13 @@ class QuicStream { // `errorCode`. // 2. Otherwise fall back to the negotiated application's // "internal error" code, surfaced via - // `QuicSessionState.internalErrorCode`. For HTTP/3 this is + // `QuicConnectionState.internalErrorCode`. For HTTP/3 this is // `H3_INTERNAL_ERROR` (0x102); for raw QUIC applications // it falls back to the QUIC transport-layer // `INTERNAL_ERROR` (0x1). const code = QuicError.isQuicError(error) ? error.errorCode : - getQuicSessionState(stream.#inner.session).internalErrorCode; + getQuicConnectionState(stream.#inner.session).internalErrorCode; handle.resetStream(code); if (drainWakeup != null) { markPromiseAsHandled(drainWakeup.promise); @@ -2633,7 +2643,7 @@ class QuicStream { get priority() { assertIsQuicStream(this); if (this.destroyed || - !getQuicSessionState(this.#inner.session).isPrioritySupported) return null; + !getQuicConnectionState(this.#inner.session).isPrioritySupported) return null; const packed = this.#handle.getPriority(); const urgency = packed >> 1; const incremental = !!(packed & 1); @@ -2648,7 +2658,7 @@ class QuicStream { setPriority(options = kEmptyObject) { assertIsQuicStream(this); if (this.destroyed) return; - if (!getQuicSessionState(this.#inner.session).isPrioritySupported) { + if (!getQuicConnectionState(this.#inner.session).isPrioritySupported) { throw new ERR_INVALID_STATE( 'The session does not support stream priority'); } @@ -2678,7 +2688,7 @@ class QuicStream { [kSendHeaders](headers, kind = kHeadersKindInitial, flags = kHeadersFlagsTerminal) { validateObject(headers, 'headers'); - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { + if (getQuicConnectionState(this.#inner.session).headersSupported === 2) { throw new ERR_INVALID_STATE( 'The negotiated QUIC application protocol does not support headers'); } @@ -2908,7 +2918,7 @@ class QuicStream { } } -class QuicSession { +class QuicConnection { /** @type {object|undefined} */ #handle; @@ -2922,11 +2932,15 @@ class QuicSession { handshakeCompleted: false, pendingClose: PromiseWithResolvers(), pendingOpen: PromiseWithResolvers(), - /** @type {QuicSessionState} */ + /** @type {QuicConnectionState} */ state: undefined, - /** @type {QuicSessionStats} */ + /** @type {QuicConnectionStats} */ stats: undefined, streams: new SafeSet(), + // The session started on this connection (a QuicSession or an + // Http3Session), which owns onerror, onstream and the datagram callbacks. + app: undefined, + isServer: false, onerror: undefined, onstream: undefined, ondatagram: undefined, @@ -2961,19 +2975,87 @@ class QuicSession { }; static { - isQuicSession = function(val) { + isQuicConnection = function(val) { return val != null && typeof val === 'object' && #handle in val; }; - assertIsQuicSession = function(val) { - if (!isQuicSession(val)) { - throw new ERR_INVALID_THIS('QuicSession'); + assertIsQuicConnection = function(val) { + if (!isQuicConnection(val)) { + throw new ERR_INVALID_THIS('QuicConnection'); + } + }; + + isServerConnection = (connection) => connection.#inner.isServer; + + createApplicationStream = (connection, direction, options) => { + assertIsQuicConnection(connection); + return connection.#createStream(direction, options); + }; + + getApplicationCallback = (connection, name) => connection.#inner[name]; + + // For callbacks owned by the session started on this connection, which + // are invoked with that session as `this`. The option name is the + // session's name for the callback, if it differs. + setApplicationCallback = (connection, name, fn, session, option = name) => { + assertIsQuicConnection(connection); + if (fn !== undefined) { + validateFunction(fn, option); + fn = FunctionPrototypeBind(fn, session); + } + const inner = connection.#inner; + inner[name] = fn; + if (name === 'onorigin') { + inner.state.hasOriginListener = fn !== undefined; + } else if (name === 'onapplication') { + inner.state.hasApplicationListener = fn !== undefined; + } else if (name === 'ondatagram') { + inner.state.hasDatagramListener = fn !== undefined; + } else if (name === 'ondatagramstatus') { + inner.state.hasDatagramStatusListener = fn !== undefined; + } else if (name === 'onerror' && fn !== undefined) { + // When an onerror handler is provided, mark the pending promises as + // handled so that rejections from destroy(error) don't surface as + // unhandled rejections. The onerror callback is the application's + // error handler for this connection. + markPromiseAsHandled(inner.pendingClose.promise); + markPromiseAsHandled(inner.pendingOpen.promise); + // Also mark existing streams' closed promises. Stream rejections + // during session destruction are expected collateral when the + // session has an error handler. + for (const stream of inner.streams) { + markPromiseAsHandled(stream.closed); + } + } + }; + + startApplication = (connection, type, app, settings) => { + assertIsQuicConnection(connection); + if (connection.destroyed) { + throw new ERR_INVALID_STATE( + 'A session cannot be started on a destroyed QUIC connection'); + } + const inner = connection.#inner; + if (inner.app !== undefined) { + throw new ERR_INVALID_STATE( + 'The QUIC connection already has a session started'); + } + // Recorded first, so a failed start can't be retried: the connection + // is closed regardless. + inner.app = app; + // With autoStart, the application was installed by ALPN already: + if (inner.state.applicationType === 0 && + !connection.#handle.startApplication(type, settings)) { + throw new ERR_INVALID_STATE( + 'The session could not be started, so the QUIC connection is closing'); } }; - getQuicSessionState = function(session) { - assertIsQuicSession(session); - return session.#inner.state; + getApplicationSession = (connection) => connection.#inner.app; + + getQuicConnectionState = function(connection) { + assertIsQuicConnection(connection); + return connection.#inner.state; }; } @@ -2984,7 +3066,7 @@ class QuicSession { * @param {{ truncatedReads?: 'error'|'ignore' }} [options] */ constructor(privateSymbol, handle, endpoint, options = kEmptyObject) { - // Instances of QuicSession can only be created internally. + // Instances of QuicConnection can only be created internally. assertPrivateSymbol(privateSymbol); this.#handle = handle; @@ -2992,20 +3074,21 @@ class QuicSession { const inner = this.#inner; inner.endpoint = endpoint; - const { truncatedReads } = options; + const { truncatedReads, isServer } = options; if (truncatedReads !== undefined) inner.truncatedReads = truncatedReads; + inner.isServer = isServer === true; // Move any qlog entries that arrived before the wrapper existed. if (handle._pendingQlog !== undefined) { inner.pendingQlog = handle._pendingQlog; handle._pendingQlog = undefined; } - inner.stats = new QuicSessionStats( + inner.stats = new QuicConnectionStats( kPrivateConstructor, handle.stats, handle.statsByteOffset); - inner.state = new QuicSessionState( + inner.state = new QuicConnectionState( kPrivateConstructor, handle.state, handle.stateByteOffset); if (hasObserver('quic')) { - startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicSession' }); + startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicConnection' }); } debug('session created'); @@ -3063,59 +3146,13 @@ class QuicSession { return this.#handle === undefined || this.#inner.isPendingClose; } - /** @type {Function|undefined} */ - get onerror() { - assertIsQuicSession(this); - return this.#inner.onerror; - } - - set onerror(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onerror = undefined; - } else { - validateFunction(fn, 'onerror'); - inner.onerror = FunctionPrototypeBind(fn, this); - // When an onerror handler is provided, mark the pending promises - // as handled so that rejections from destroy(error) don't surface - // as unhandled rejections. The onerror callback is the - // application's error handler for this session. - markPromiseAsHandled(inner.pendingClose.promise); - markPromiseAsHandled(inner.pendingOpen.promise); - // Also mark existing streams' closed promises. Stream rejections - // during session destruction are expected collateral when the - // session has an error handler. - for (const stream of inner.streams) { - markPromiseAsHandled(stream.closed); - } - } - } - - /** @type {OnStreamCallback} */ - get onstream() { - assertIsQuicSession(this); - return this.#inner.onstream; - } - - set onstream(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onstream = undefined; - } else { - validateFunction(fn, 'onstream'); - inner.onstream = FunctionPrototypeBind(fn, this); - } - } - /** * The SNI servername: `null` until known, then the host name string, or * `false` if the handshake produced no SNI. * @type {string|boolean|null} */ get servername() { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (inner.servername !== undefined) return inner.servername; if (this.destroyed) return null; @@ -3132,7 +3169,7 @@ class QuicSession { * @type {string|null} */ get alpnProtocol() { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (inner.alpnProtocol !== undefined) return inner.alpnProtocol; if (this.destroyed) return null; @@ -3141,56 +3178,14 @@ class QuicSession { return value; } - /** @type {OnDatagramCallback} */ - get ondatagram() { - assertIsQuicSession(this); - return this.#inner.ondatagram; - } - - set ondatagram(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ondatagram = undefined; - inner.state.hasDatagramListener = false; - } else { - validateFunction(fn, 'ondatagram'); - inner.ondatagram = FunctionPrototypeBind(fn, this); - inner.state.hasDatagramListener = true; - } - } - - /** - * The ondatagramstatus callback is called when the status of a sent datagram - * is received. This is best-effort only. - * @type {OnDatagramStatusCallback} - */ - get ondatagramstatus() { - assertIsQuicSession(this); - return this.#inner.ondatagramstatus; - } - - set ondatagramstatus(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ondatagramstatus = undefined; - inner.state.hasDatagramStatusListener = false; - } else { - validateFunction(fn, 'ondatagramstatus'); - inner.ondatagramstatus = FunctionPrototypeBind(fn, this); - inner.state.hasDatagramStatusListener = true; - } - } - /** @type {Function|undefined} */ get onpathvalidation() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onpathvalidation; } set onpathvalidation(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onpathvalidation = undefined; @@ -3203,12 +3198,12 @@ class QuicSession { } get onkeylog() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onkeylog; } set onkeylog(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onkeylog = undefined; @@ -3219,12 +3214,12 @@ class QuicSession { } get onqlog() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onqlog; } set onqlog(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onqlog = undefined; @@ -3244,12 +3239,12 @@ class QuicSession { /** @type {Function|undefined} */ get onsessionticket() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onsessionticket; } set onsessionticket(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onsessionticket = undefined; @@ -3261,33 +3256,14 @@ class QuicSession { } } - /** @type {Function|undefined} */ - get onapplication() { - assertIsQuicSession(this); - return this.#inner.onapplication; - } - - set onapplication(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onapplication = undefined; - inner.state.hasApplicationListener = false; - } else { - validateFunction(fn, 'onapplication'); - inner.onapplication = FunctionPrototypeBind(fn, this); - inner.state.hasApplicationListener = true; - } - } - /** @type {Function|undefined} */ get onversionnegotiation() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onversionnegotiation; } set onversionnegotiation(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onversionnegotiation = undefined; @@ -3299,12 +3275,12 @@ class QuicSession { /** @type {Function|undefined} */ get onhandshake() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onhandshake; } set onhandshake(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onhandshake = undefined; @@ -3316,12 +3292,12 @@ class QuicSession { /** @type {Function|undefined} */ get onnewtoken() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onnewtoken; } set onnewtoken(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onnewtoken = undefined; @@ -3335,12 +3311,12 @@ class QuicSession { /** @type {Function|undefined} */ get onearlyrejected() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.onearlyrejected; } set onearlyrejected(fn) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; if (fn === undefined) { inner.onearlyrejected = undefined; @@ -3350,75 +3326,12 @@ class QuicSession { } } - /** @type {Function|undefined} */ - get onorigin() { - assertIsQuicSession(this); - return this.#inner.onorigin; - } - - set onorigin(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onorigin = undefined; - inner.state.hasOriginListener = false; - } else { - validateFunction(fn, 'onorigin'); - inner.onorigin = FunctionPrototypeBind(fn, this); - inner.state.hasOriginListener = true; - } - } - - /** @type {Function|undefined} */ - get ongoaway() { - assertIsQuicSession(this); - return this.#inner.ongoaway; - } - - set ongoaway(fn) { - assertIsQuicSession(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ongoaway = undefined; - } else { - validateFunction(fn, 'ongoaway'); - inner.ongoaway = FunctionPrototypeBind(fn, this); - } - } - - /** - * The maximum datagram size the peer will accept, or 0 if datagrams - * are not supported or the handshake has not yet completed. - * @type {bigint} - */ - get maxDatagramSize() { - assertIsQuicSession(this); - return this.#inner.state.maxDatagramSize; - } - - /** - * Maximum number of datagrams that can be queued while inside a - * ngtcp2 callback scope. When the queue is full, the oldest - * datagram is dropped and reported as lost. Default is 128. - * @type {number} - */ - get maxPendingDatagrams() { - assertIsQuicSession(this); - return this.#inner.state.maxPendingDatagrams; - } - - set maxPendingDatagrams(val) { - assertIsQuicSession(this); - validateInteger(val, 'maxPendingDatagrams', 0, 0xFFFF); - this.#inner.state.maxPendingDatagrams = val; - } - /** * The statistics collected for this session. - * @type {QuicSessionStats} + * @type {QuicConnectionStats} */ get stats() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.stats; } @@ -3428,7 +3341,7 @@ class QuicSession { * @type {QuicEndpoint|null} */ get endpoint() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return null; return this.#inner.endpoint; } @@ -3438,7 +3351,7 @@ class QuicSession { * @type {QuicSessionPath | undefined} */ get path() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; return this.#inner.path ??= { __proto__: null, @@ -3455,7 +3368,7 @@ class QuicSession { * @type {crypto.X509Certificate|undefined} */ get certificate() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; if (this.#inner.certificate === undefined) { const handle = this.#handle.getCertificate(); @@ -3470,7 +3383,7 @@ class QuicSession { * @type {crypto.X509Certificate|undefined} */ get peerCertificate() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; if (this.#inner.peerCertificate === undefined) { const handle = this.#handle.getPeerCertificate(); @@ -3488,7 +3401,7 @@ class QuicSession { * @type {object|undefined} */ get ephemeralKeyInfo() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.destroyed) return undefined; return this.#inner.ephemeralKeyInfo ??= this.#handle.getEphemeralKey(); } @@ -3576,28 +3489,6 @@ class QuicSession { return stream; } - /** - * Creates a new bidirectional stream on this session. If the session - * does not allow new streams to be opened, an error will be thrown. - * @param {OpenStreamOptions} [options] - * @returns {Promise} - */ - async createBidirectionalStream(options = kEmptyObject) { - assertIsQuicSession(this); - return await this.#createStream(kStreamDirectionBidirectional, options); - } - - /** - * Creates a new unidirectional stream on this session. If the session - * does not allow new streams to be opened, an error will be thrown. - * @param {OpenStreamOptions} [options] - * @returns {Promise} - */ - async createUnidirectionalStream(options = kEmptyObject) { - assertIsQuicSession(this); - return await this.#createStream(kStreamDirectionUnidirectional, options); - } - /** * Send a datagram. The id of the sent datagram will be returned. The status * of the sent datagram will be reported via the datagram-status event if @@ -3618,8 +3509,8 @@ class QuicSession { * @param {string} [encoding] The encoding to use if datagram is a string * @returns {Promise} The datagram ID */ - async sendDatagram(datagram, encoding = 'utf8') { - assertIsQuicSession(this); + async [kSendDatagram](datagram, encoding = 'utf8') { + assertIsQuicConnection(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -3677,7 +3568,7 @@ class QuicSession { * Initiate a key update. */ updateKey() { - assertIsQuicSession(this); + assertIsQuicConnection(this); if (this.#isClosedOrClosing) { throw new ERR_INVALID_STATE('Session is closed'); } @@ -3710,8 +3601,8 @@ class QuicSession { * string included in the CONNECTION_CLOSE frame (diagnostic only). * @returns {Promise} */ - close(options = kEmptyObject) { - assertIsQuicSession(this); + [kClose](options = kEmptyObject) { + assertIsQuicConnection(this); options = validateCloseOptions(options); const inner = this.#inner; if (!this.#isClosedOrClosing) { @@ -3740,7 +3631,7 @@ class QuicSession { /** @type {Promise} */ get opened() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.pendingOpen.promise; } @@ -3750,13 +3641,13 @@ class QuicSession { * @type {Promise} */ get closed() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#inner.pendingClose.promise; } /** @type {boolean} */ get destroyed() { - assertIsQuicSession(this); + assertIsQuicConnection(this); return this.#handle === undefined; } @@ -3776,7 +3667,7 @@ class QuicSession { * string included in the CONNECTION_CLOSE frame (diagnostic only). */ destroy(error, options) { - assertIsQuicSession(this); + assertIsQuicConnection(this); const inner = this.#inner; // Two distinct guards (see also `QuicStream.destroy`): // * `#destroying` flips synchronously here so any re-entrant call @@ -3934,7 +3825,7 @@ class QuicSession { }); } if (typeof inner.ongoaway === 'function') { - safeCallbackInvoke(inner.ongoaway, this, lastStreamId); + safeCallbackInvoke(inner.ongoaway, inner.app, lastStreamId); } } @@ -4034,7 +3925,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(inner.ondatagram, this, u8, early); + safeCallbackInvoke(inner.ondatagram, inner.app, u8, early); } /** @@ -4055,7 +3946,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(inner.ondatagramstatus, this, id, status); + safeCallbackInvoke(inner.ondatagramstatus, inner.app, id, status); } /** @@ -4126,7 +4017,7 @@ class QuicSession { } const inner = this.#inner; if (typeof inner.onapplication === 'function') - safeCallbackInvoke(inner.onapplication, this, applicationoptions); + safeCallbackInvoke(inner.onapplication, inner.app, applicationoptions); } /** @@ -4205,7 +4096,7 @@ class QuicSession { session: this, }); } - safeCallbackInvoke(inner.onorigin, this, origins); + safeCallbackInvoke(inner.onorigin, inner.app, origins); } /** @@ -4315,7 +4206,7 @@ class QuicSession { // outbound-only (onwanttrailers) and do not expose the stream, so they // do not count as a consumer. if (typeof this[kStreamCallbacks]?.onheaders !== 'function') return false; - return getQuicSessionState(this).streamCallbacksSupported === 1; + return getQuicConnectionState(this).streamCallbacksSupported === 1; } /** @@ -4341,8 +4232,8 @@ class QuicSession { // (HTTP/3: H3_REQUEST_REJECTED), reset the stream with it so the peer // learns the request was not processed (RFC 9114 section 4.1.1). // Other applications have no such semantic and are torn down as before. - const rejectedCode = getQuicSessionState(this).requestRejectedCode; - if (getQuicSessionState(this).streamCallbacksSupported === 1) { + const rejectedCode = getQuicConnectionState(this).requestRejectedCode; + if (getQuicConnectionState(this).streamCallbacksSupported === 1) { stream.destroy(undefined, { code: rejectedCode }); } else { stream.destroy(); @@ -4382,7 +4273,7 @@ class QuicSession { // stream callbacks were applied above and the application (e.g. // HTTP/3) drives the stream, so there is nothing to invoke here. if (typeof inner.onstream === 'function') { - safeCallbackInvoke(inner.onstream, this, stream); + safeCallbackInvoke(inner.onstream, inner.app, stream); } } @@ -4392,7 +4283,7 @@ class QuicSession { [kInspect](depth, options) { if (depth < 0) { - return 'QuicSession { }'; + return 'QuicConnection { }'; } const opts = { @@ -4410,7 +4301,7 @@ class QuicSession { streams, } = this.#inner; - return `QuicSession ${inspect({ + return `QuicConnection ${inspect({ closed: this.closed, closing, destroyed: this.destroyed, @@ -4422,7 +4313,186 @@ class QuicSession { }, opts)}`; } + async [SymbolAsyncDispose]() { this.destroy(); } +} + +/** + * The members shared by every session started on a QuicConnection, e.g. a + * QuicSession or an Http3Session. + */ +class QuicSessionBase { + #connection; + + constructor(privateSymbol, connection, type, settings) { + assertPrivateSymbol(privateSymbol); + if (!isQuicConnection(connection)) { + throw new ERR_INVALID_ARG_TYPE('connection', 'QuicConnection', connection); + } + this.#connection = connection; + startApplication(connection, type, this, settings); + } + + /** @type {QuicConnection} */ + get connection() { return this.#connection; } + + /** @type {QuicConnectionStats} */ + get stats() { return this.#connection.stats; } + + /** @type {Function|undefined} */ + get onerror() { return getApplicationCallback(this.#connection, 'onerror'); } + set onerror(fn) { setApplicationCallback(this.#connection, 'onerror', fn, this); } + + /** @type {OnStreamCallback} */ + get onstream() { return getApplicationCallback(this.#connection, 'onstream'); } + set onstream(fn) { setApplicationCallback(this.#connection, 'onstream', fn, this); } + + /** @type {OnDatagramCallback} */ + get ondatagram() { return getApplicationCallback(this.#connection, 'ondatagram'); } + set ondatagram(fn) { setApplicationCallback(this.#connection, 'ondatagram', fn, this); } + + /** + * The ondatagramstatus callback is called when the status of a sent datagram + * is received. This is best-effort only. + * @type {OnDatagramStatusCallback} + */ + get ondatagramstatus() { return getApplicationCallback(this.#connection, 'ondatagramstatus'); } + set ondatagramstatus(fn) { setApplicationCallback(this.#connection, 'ondatagramstatus', fn, this); } + + /** + * The maximum datagram size the peer will accept, or 0 if datagrams + * are not supported or the handshake has not yet completed. + * @type {bigint} + */ + get maxDatagramSize() { + return getQuicConnectionState(this.#connection).maxDatagramSize; + } + + /** + * Maximum number of datagrams that can be queued while inside a + * ngtcp2 callback scope. When the queue is full, the oldest + * datagram is dropped and reported as lost. Default is 128. + * @type {number} + */ + get maxPendingDatagrams() { + return getQuicConnectionState(this.#connection).maxPendingDatagrams; + } + + set maxPendingDatagrams(val) { + validateInteger(val, 'maxPendingDatagrams', 0, 0xFFFF); + getQuicConnectionState(this.#connection).maxPendingDatagrams = val; + } + + /** + * Send a datagram. The id of the sent datagram will be returned, and its + * status reported via ondatagramstatus if possible. + * @param {ArrayBufferView|string|Promise} datagram The datagram payload + * @param {string} [encoding] The encoding to use if datagram is a string + * @returns {Promise} The datagram ID + */ + sendDatagram(datagram, encoding) { + return this.#connection[kSendDatagram](datagram, encoding); + } + + /** @type {Promise} */ + get opened() { return this.#connection.opened; } + + /** @type {Promise} */ + get closed() { return this.#connection.closed; } + + /** @type {boolean} */ + get closing() { return this.#connection.closing; } + + /** @type {boolean} */ + get destroyed() { return this.#connection.destroyed; } + + /** + * Gracefully closes the session and its connection. + * @param {object} [options] + * @returns {Promise} + */ + close(options) { return this.#connection[kClose](options); } + + destroy(error, options) { return this.#connection.destroy(error, options); } + async [SymbolAsyncDispose]() { await this.close(); } + + [kInspect](depth, options) { + const name = this.constructor.name; + if (depth < 0) { + return `${name} { }`; + } + const opts = { + __proto__: null, + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + return `${name} ${inspect({ + connection: this.#connection, + destroyed: this.destroyed, + }, opts)}`; + } +} + +/** + * A raw QUIC session, which carries application data directly over the + * streams and datagrams of its connection. + */ +class QuicSession extends QuicSessionBase { + /** + * Starts a raw QUIC session on a connection that has none yet. + * @param {QuicConnection} connection + * @returns {QuicSession} + */ + static start(connection) { + return new QuicSession(kPrivateConstructor, connection); + } + + constructor(privateSymbol, connection) { + super(privateSymbol, connection, kApplicationTypeDefault); + } + + /** + * Creates a new bidirectional stream on this session. If the session + * does not allow new streams to be opened, an error will be thrown. + * @param {OpenStreamOptions} [options] + * @returns {Promise} + */ + createBidirectionalStream(options) { + return createApplicationStream( + this.connection, kStreamDirectionBidirectional, options); + } + + /** + * Creates a new unidirectional stream on this session. If the session + * does not allow new streams to be opened, an error will be thrown. + * @param {OpenStreamOptions} [options] + * @returns {Promise} + */ + createUnidirectionalStream(options) { + return createApplicationStream( + this.connection, kStreamDirectionUnidirectional, options); + } + +} + +/** + * Provides a new connection as the session for the application autoStart + * installed by ALPN, if any. The http3 module is loaded lazily, as it depends + * on this one. + * @param {QuicConnection} connection + * @returns {QuicSession|Http3Session|QuicConnection} + */ +function autoStartSession(connection) { + switch (getQuicConnectionState(connection).applicationType) { + case kApplicationTypeHttp3: { + const { Http3Session } = require('internal/quic/http3'); + return Http3Session.start(connection); + } + case kApplicationTypeDefault: + return QuicSession.start(connection); + default: + return connection; + } } // The QuicEndpoint represents a local UDP port binding. It can act as both a @@ -4445,6 +4515,8 @@ class QuicEndpoint { truncatedReads: undefined, onsession: undefined, sessionCallbacks: undefined, + // The encoded ALPN this endpoint listens with. + alpn: undefined, }; static { @@ -4575,12 +4647,12 @@ class QuicEndpoint { }; } - #newSession(handle, options) { - const session = new QuicSession(kPrivateConstructor, handle, this, options); - this.#inner.sessions.add(session); + #newConnection(handle, options) { + const connection = new QuicConnection(kPrivateConstructor, handle, this, options); + this.#inner.sessions.add(connection); // Set default pending datagram queue size. - session.maxPendingDatagrams = kDefaultMaxPendingDatagrams; - return session; + getQuicConnectionState(connection).maxPendingDatagrams = kDefaultMaxPendingDatagrams; + return connection; } /** @@ -4740,11 +4812,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, // Stream-level callbacks applied to each incoming stream. onheaders, ontrailers, @@ -4756,6 +4825,7 @@ class QuicEndpoint { } = options; inner.truncatedReads = truncatedReads; + inner.alpn = rest.tls.alpn; // Store session and stream callbacks to apply to each new incoming session. inner.sessionCallbacks = { @@ -4770,11 +4840,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, onheaders, ontrailers, oninfo, @@ -4790,7 +4857,7 @@ class QuicEndpoint { * Initiates a session with a remote endpoint. * @param {object} address * @param {SessionOptions} [options] - * @returns {QuicSession} + * @returns {QuicSession|Http3Session|QuicConnection} */ [kConnect](address, options) { assertEndpointNotClosedOrClosing(this); @@ -4807,15 +4874,16 @@ class QuicEndpoint { if (handle === undefined) { throw new ERR_QUIC_CONNECTION_FAILED(); } - const session = this.#newSession(handle, { __proto__: null, truncatedReads }); + const connection = this.#newConnection(handle, { __proto__: null, truncatedReads }); + const connOrSession = autoStartSession(connection); // Set callbacks before any async work to avoid missing events // that fire during or immediately after the handshake. - applyCallbacks(session, options); + applyCallbacks(connection, options, connOrSession); // Store the verifyPeer policy for use in the handshake handler. if (options.verifyPeer !== undefined) { - session[kVerifyPeer] = options.verifyPeer; + connection[kVerifyPeer] = options.verifyPeer; } - return session; + return connOrSession; } /** @@ -4913,17 +4981,17 @@ class QuicEndpoint { // `destroy()`, which trips the `#destroying` guard and leaves the // C++ side asserting an inconsistent destroyed state. const closeOptions = errorToCloseOptions(error); - for (const session of inner.sessions) { + for (const connection of inner.sessions) { // Mark each cascaded session's `closed` as handled before // destroying it. This prevents unhandled-rejection warnings when // the session is collateral damage from an endpoint-level destroy // (e.g. a synchronous throw out of a user `onsession` callback // routed through safeCallbackInvoke). The rejection is still // observable to any caller that explicitly awaits `session.closed`. - markPromiseAsHandled(session.closed); - session.destroy( + markPromiseAsHandled(connection.closed); + connection.destroy( error, - session[kHandshakeCompleted] ? closeOptions : undefined); + connection[kHandshakeCompleted] ? closeOptions : undefined); } if (!this.#isClosedOrClosing) { // Trigger a graceful close of the endpoint that'll ensure that the @@ -4965,7 +5033,8 @@ class QuicEndpoint { if (identity.certs === undefined) { throw new ERR_MISSING_ARGS(`entries['${hostname}'].certs`); } - processed[hostname] = identity; + // These identities offer the same protocols as the ones given to listen(): + processed[hostname] = { __proto__: null, ...identity, alpn: this.#inner.alpn }; } this.#handle.setSNIContexts(processed, replace); @@ -5041,20 +5110,21 @@ class QuicEndpoint { const inner = this.#inner; assert(typeof inner.onsession === 'function', 'onsession callback not specified'); - const session = this.#newSession(handle, - { __proto__: null, truncatedReads: inner.truncatedReads }); + const connection = this.#newConnection( + handle, { __proto__: null, truncatedReads: inner.truncatedReads, isServer: true }); + const connOrSession = autoStartSession(connection); // Apply session callbacks stored at listen time before notifying // the onsession callback, to avoid missing events that fire // during or immediately after the handshake. if (inner.sessionCallbacks) { - applyCallbacks(session, inner.sessionCallbacks); + applyCallbacks(connection, inner.sessionCallbacks, connOrSession); } if (onEndpointServerSessionChannel.hasSubscribers) { onEndpointServerSessionChannel.publish({ __proto__: null, endpoint: this, - session, - address: session.path?.remote, + session: connection, + address: connection.path?.remote, }); } // Route through safeCallbackInvoke so that a synchronous throw or a @@ -5062,13 +5132,13 @@ class QuicEndpoint { // endpoint with the error rather than surfacing as an unhandled // exception or unhandled rejection coming out of the C++ -> JS // boundary. - safeCallbackInvoke(inner.onsession, this, session); + safeCallbackInvoke(inner.onsession, this, connOrSession); } // Called by the QuicSession when it closes to remove itself from // the active sessions tracked by the QuicEndpoint. - [kRemoveSession](session) { - this.#inner.sessions.delete(session); + [kRemoveSession](connection) { + this.#inner.sessions.delete(connection); } [kInspect](depth, options) { @@ -5311,7 +5381,6 @@ function processTlsOptions(tls, forServer) { // Encode the ALPN option to wire format (length-prefixed protocol names). // Server: array of protocol names. Client: single protocol name. - // If not specified, the C++ default (h3) is used. let encodedAlpn; if (alpn !== undefined) { const protocols = forServer ? @@ -5344,6 +5413,9 @@ function processTlsOptions(tls, forServer) { offset += protocols[i].length; } encodedAlpn = buf.toString('latin1'); + } else { + // Transport-only: there is no application protocol to default to. + throw new ERR_MISSING_OPTION('options.alpn'); } if (ca !== undefined) { @@ -5511,6 +5583,13 @@ function getPreferredAddressPolicy(policy = 'default') { throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy); } +function assertAutoStartOption(value, name) { + if (value !== undefined) { + throw new ERR_INVALID_ARG_VALUE(`options.${name}`, value, + 'is only supported with autoStart'); + } +} + /** * @param {SessionOptions} options * @param {ProcessSessionOptions} [config] @@ -5542,9 +5621,10 @@ function processSessionOptions(options, config = kEmptyObject) { streamIdleTimeout, verifyPeer = 'auto', truncatedReads = 'error', + autoStart, // HTTP/3 application-specific options. Nested under `application` // to separate protocol-specific settings from transport-level ones. - application = kEmptyObject, + application, // Session callbacks that can be set at construction time to avoid // race conditions with events that fire during or immediately // after the handshake. @@ -5558,12 +5638,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - // Application level options changed, e.g. HTTP/3 settings related // Stream-level callbacks. onheaders, ontrailers, @@ -5629,6 +5705,19 @@ function processSessionOptions(options, config = kEmptyObject) { } } + if (autoStart !== undefined) { + validateBoolean(autoStart, 'options.autoStart'); + // These configure the session that autoStart starts, so without it they'd + // have nothing to apply to: + if (!autoStart) { + assertAutoStartOption(onerror, 'onerror'); + assertAutoStartOption(onstream, 'onstream'); + assertAutoStartOption(ondatagram, 'ondatagram'); + assertAutoStartOption(ondatagramstatus, 'ondatagramstatus'); + assertAutoStartOption(application, 'application'); + } + } + const actualEndpoint = processEndpointOption(endpoint, reuseEndpoint, forServer, @@ -5659,6 +5748,7 @@ function processSessionOptions(options, config = kEmptyObject) { }, verifyPeer, truncatedReads, + autoStart, qlog, maxPayloadSize, unacknowledgedPacketThreshold, @@ -5685,11 +5775,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, onheaders, ontrailers, oninfo, @@ -5754,19 +5841,20 @@ async function connect(address, options = kEmptyObject) { }); } - const session = endpoint[kConnect](address[kSocketAddressHandle], rest); + const connOrSession = endpoint[kConnect](address[kSocketAddressHandle], rest); if (onEndpointClientSessionChannel.hasSubscribers) { onEndpointClientSessionChannel.publish({ __proto__: null, endpoint, - session, + // As on every other quic channel, the session reported is the connection + session: isQuicConnection(connOrSession) ? connOrSession : connOrSession.connection, address, options, }); } - return session; + return connOrSession; } ObjectDefineProperties(QuicEndpoint, { @@ -5778,13 +5866,13 @@ ObjectDefineProperties(QuicEndpoint, { value: QuicEndpointStats, }, }); -ObjectDefineProperties(QuicSession, { +ObjectDefineProperties(QuicConnection, { Stats: { __proto__: null, writable: false, configurable: false, enumerable: true, - value: QuicSessionStats, + value: QuicConnectionStats, }, }); ObjectDefineProperties(QuicStream, { @@ -5803,6 +5891,7 @@ module.exports = { listen, connect, QuicEndpoint, + QuicConnection, QuicError, QuicSession, QuicStream, @@ -5811,9 +5900,15 @@ module.exports = { CC_ALGO_BBR, DEFAULT_CIPHERS, DEFAULT_GROUPS, + // Internal only, for the HTTP/3 layer's integration with node:quic. + QuicSessionBase, + createApplicationStream, + getApplicationCallback, + isServerConnection, + setApplicationCallback, // These are exported only for internal testing purposes. getQuicStreamState, - getQuicSessionState, + getQuicConnectionState, getQuicEndpointState, listEndpoints, }; diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 0a47e2e7adc8..38c438731304 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -326,7 +326,7 @@ class QuicEndpointState { } } -class QuicSessionState { +class QuicConnectionState { /** @type {DataView} */ #handle; /** @type {number} */ @@ -378,58 +378,58 @@ class QuicSessionState { /** @type {boolean} */ get hasApplicationListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_APPLICATION); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_APPLICATION); } set hasApplicationListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_APPLICATION, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_APPLICATION, val); } /** @type {boolean} */ get hasPathValidationListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_PATH_VALIDATION); } set hasPathValidationListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_PATH_VALIDATION, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_PATH_VALIDATION, val); } /** @type {boolean} */ get hasDatagramListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM); } set hasDatagramListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM, val); } /** @type {boolean} */ get hasDatagramStatusListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM_STATUS); } set hasDatagramStatusListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_DATAGRAM_STATUS, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_DATAGRAM_STATUS, val); } /** @type {boolean} */ get hasSessionTicketListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_SESSION_TICKET); } set hasSessionTicketListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_SESSION_TICKET, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_SESSION_TICKET, val); } /** @type {boolean} */ get hasNewTokenListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_NEW_TOKEN); } set hasNewTokenListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_NEW_TOKEN, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_NEW_TOKEN, val); } /** @type {boolean} */ get hasOriginListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_ORIGIN); + return this.#getListenerFlag(QuicConnectionState.#LISTENER_ORIGIN); } set hasOriginListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_ORIGIN, val); + this.#setListenerFlag(QuicConnectionState.#LISTENER_ORIGIN, val); } /** @type {boolean} */ @@ -655,11 +655,11 @@ class QuicSessionState { [kInspect](depth, options) { if (this.#handle === undefined) { - return 'QuicSessionState { }'; + return 'QuicConnectionState { }'; } if (depth < 0) { - return 'QuicSessionState { }'; + return 'QuicConnectionState { }'; } const opts = { @@ -693,7 +693,7 @@ class QuicSessionState { maxPendingDatagrams, } = this; - return `QuicSessionState ${inspect({ + return `QuicConnectionState ${inspect({ hasPathValidationListener, hasDatagramListener, hasDatagramStatusListener, @@ -1027,7 +1027,7 @@ class QuicStreamState { module.exports = { QuicEndpointState, - QuicSessionState, + QuicConnectionState, QuicStreamState, }; diff --git a/lib/internal/quic/stats.js b/lib/internal/quic/stats.js index f2fefbcb74cd..7304e2a45119 100644 --- a/lib/internal/quic/stats.js +++ b/lib/internal/quic/stats.js @@ -190,10 +190,10 @@ assert(IDX_STATS_SESSION_COUNT !== undefined); const kCreateDisconnected = Symbol('kCreateDisconnected'); let assertIsQuicEndpointStats; -let assertIsQuicSessionStats; +let assertIsQuicConnectionStats; let assertIsQuicStreamStats; let isQuicEndpointStats; -let isQuicSessionStats; +let isQuicConnectionStats; let isQuicStreamStats; function assertIsPrivateConstructor(privateSymbol) { @@ -477,20 +477,20 @@ class QuicEndpointStats { } } -class QuicSessionStats { +class QuicConnectionStats { /** @type {BigUint64Array} */ #handle; #disconnected = false; #offset = 0; static { - isQuicSessionStats = function(val) { + isQuicConnectionStats = function(val) { return val != null && typeof val === 'object' && #handle in val; }; - assertIsQuicSessionStats = function(val) { - if (!isQuicSessionStats(val)) { - throw new ERR_INVALID_THIS('QuicSessionStats'); + assertIsQuicConnectionStats = function(val) { + if (!isQuicConnectionStats(val)) { + throw new ERR_INVALID_THIS('QuicConnectionStats'); } }; } @@ -503,7 +503,7 @@ class QuicSessionStats { */ constructor(privateSymbol, view, byteOffset = 0) { // We use the kPrivateConstructor symbol to restrict the ability to - // create new instances of QuicSessionStats to internal code. + // create new instances of QuicConnectionStats to internal code. assertIsPrivateConstructor(privateSymbol); if (isArrayBuffer(view)) { this.#handle = new BigUint64Array(view); @@ -515,185 +515,185 @@ class QuicSessionStats { /** @type {bigint} */ get createdAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_CREATED_AT]; } /** @type {bigint} */ get destroyedAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DESTROYED_AT]; } /** @type {bigint} */ get closingAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_CLOSING_AT]; } /** @type {bigint} */ get handshakeCompletedAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT]; } /** @type {bigint} */ get handshakeConfirmedAt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_HANDSHAKE_CONFIRMED_AT]; } /** @type {bigint} */ get bytesReceived() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_RECEIVED]; } /** @type {bigint} */ get bidiInStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BIDI_IN_STREAM_COUNT]; } /** @type {bigint} */ get bidiOutStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BIDI_OUT_STREAM_COUNT]; } /** @type {bigint} */ get uniInStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_UNI_IN_STREAM_COUNT]; } /** @type {bigint} */ get uniOutStreamCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_UNI_OUT_STREAM_COUNT]; } /** @type {bigint} */ get maxBytesInFlight() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_MAX_BYTES_IN_FLIGHT]; } /** @type {bigint} */ get bytesInFlight() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_IN_FLIGHT]; } /** @type {bigint} */ get blockCount() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BLOCK_COUNT]; } /** @type {bigint} */ get cwnd() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_CWND]; } /** @type {bigint} */ get latestRtt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_LATEST_RTT]; } /** @type {bigint} */ get minRtt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_MIN_RTT]; } /** @type {bigint} */ get rttVar() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_RTTVAR]; } /** @type {bigint} */ get smoothedRtt() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_SMOOTHED_RTT]; } /** @type {bigint} */ get ssthresh() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_SSTHRESH]; } get pktSent() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_SENT]; } get bytesSent() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_SENT]; } get pktRecv() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_RECV]; } get bytesRecv() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_RECV]; } get pktLost() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_LOST]; } get bytesLost() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_BYTES_LOST]; } get pingRecv() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PING_RECV]; } get pktDiscarded() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_PKT_DISCARDED]; } /** @type {bigint} */ get datagramsReceived() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_RECEIVED]; } /** @type {bigint} */ get datagramsSent() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_SENT]; } /** @type {bigint} */ get datagramsAcknowledged() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_ACKNOWLEDGED]; } /** @type {bigint} */ get datagramsLost() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_DATAGRAMS_LOST]; } /** @type {bigint} */ get streamsIdleTimedOut() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); return this.#handle[this.#offset + IDX_STATS_SESSION_STREAMS_IDLE_TIMED_OUT]; } @@ -703,7 +703,7 @@ class QuicSessionStats { } toJSON() { - assertIsQuicSessionStats(this); + assertIsQuicConnectionStats(this); const { createdAt, closingAt, @@ -778,7 +778,7 @@ class QuicSessionStats { [kInspect](depth, options) { if (depth < 0) { - return 'QuicSessionStats { }'; + return 'QuicConnectionStats { }'; } const opts = { @@ -821,7 +821,7 @@ class QuicSessionStats { streamsIdleTimedOut, } = this; - return `QuicSessionStats ${inspect({ + return `QuicConnectionStats ${inspect({ connected: this.isConnected, createdAt, closingAt, @@ -858,7 +858,7 @@ class QuicSessionStats { } /** - * True if this QuicSessionStats object is still connected to the underlying + * True if this QuicConnectionStats object is still connected to the underlying * Session stats source. If this returns false, then the stats object is * no longer being updated and should be considered stale. * @type {boolean} @@ -1108,7 +1108,7 @@ class QuicStreamStats { module.exports = { QuicEndpointStats, - QuicSessionStats, + QuicConnectionStats, QuicStreamStats, kCreateDisconnected, }; diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 1ed44ebe2b13..1e6f9443cb26 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -30,6 +30,7 @@ const { const kAttachFileHandle = Symbol('kAttachFileHandle'); const kAvailable = Symbol('kAvailable'); const kBlocked = Symbol('kBlocked'); +const kClose = Symbol('kClose'); const kConnect = Symbol('kConnect'); const kDrain = Symbol('kDrain'); const kDatagram = Symbol('kDatagram'); @@ -55,6 +56,7 @@ const kPrivateConstructor = Symbol('kPrivateConstructor'); const kRemoveSession = Symbol('kRemoveSession'); const kRemoveStream = Symbol('kRemoveStream'); const kReset = Symbol('kReset'); +const kSendDatagram = Symbol('kSendDatagram'); const kSendHeaders = Symbol('kSendHeaders'); const kSessionApplication = Symbol('kSessionApplication'); const kSessionTicket = Symbol('kSessionTicket'); @@ -66,6 +68,7 @@ module.exports = { kAttachFileHandle, kAvailable, kBlocked, + kClose, kConnect, kDatagram, kDatagramStatus, @@ -93,6 +96,7 @@ module.exports = { kRemoveSession, kRemoveStream, kReset, + kSendDatagram, kSendHeaders, kSessionApplication, kSessionTicket, diff --git a/lib/quic.js b/lib/quic.js index 90f3a6addf0d..bca4df67bbcc 100644 --- a/lib/quic.js +++ b/lib/quic.js @@ -9,6 +9,7 @@ const { connect, listen, listEndpoints, + QuicConnection, QuicEndpoint, QuicError, QuicSession, @@ -20,6 +21,10 @@ const { DEFAULT_GROUPS, } = require('internal/quic/quic'); +const { + Http3Session, +} = require('internal/quic/http3'); + const cc = { get RENO() { return CC_ALGO_RENO; }, get CUBIC() { return CC_ALGO_CUBIC; }, @@ -36,6 +41,8 @@ module.exports = { connect, listen, listEndpoints, + Http3Session, + QuicConnection, QuicEndpoint, QuicError, QuicSession, diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 21af988c95d8..4ff8dd8ebd22 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -148,8 +148,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { "internal/streams/lazy_transform", #endif // !HAVE_OPENSSL #ifndef OPENSSL_NO_QUIC - "internal/quic/quic", "internal/quic/symbols", "internal/quic/stats", - "internal/quic/state", + "internal/quic/quic", "internal/quic/http3", "internal/quic/symbols", + "internal/quic/stats", "internal/quic/state", #endif // !OPENSSL_NO_QUIC #if HAVE_DTLS "internal/dtls/dtls", "internal/dtls/symbols", "internal/dtls/stats", diff --git a/src/quic/README.md b/src/quic/README.md index acea22840a7e..986f2279add2 100644 --- a/src/quic/README.md +++ b/src/quic/README.md @@ -15,7 +15,7 @@ The stack is layered as: ├─────────────────────────────────────────────┤ │ Endpoint — UDP socket, packet I/O │ │ Session — QUIC connection (ngtcp2) │ -│ Application — ALPN protocol logic │ +│ Application — Protocol logic (e.g. h3) │ │ Stream — Bidirectional data flow │ ├─────────────────────────────────────────────┤ │ ngtcp2 / nghttp3 / OpenSSL │ @@ -26,9 +26,9 @@ The stack is layered as: An **Endpoint** binds a UDP socket and dispatches incoming packets to **Sessions**. Each Session wraps an `ngtcp2_conn` and delegates -protocol-specific behavior to an **Application** (selected by ALPN -negotiation). Sessions contain **Streams** — bidirectional or unidirectional -data channels that carry application data. +protocol-specific behavior to an **Application**. Sessions contain +**Streams** — bidirectional or unidirectional data channels that carry +application data. ## File Map @@ -135,7 +135,7 @@ re-reading from the source. ### Application Abstraction `Session::Application` is a virtual interface that the Session delegates -ALPN-specific behavior to. Two implementations exist: +protocol-specific behavior to. Two implementations exist: * **`DefaultApplication`** (`application.cc`): Used for non-HTTP/3 ALPN protocols. Maintains its own stream scheduling queue. Streams are scheduled @@ -146,9 +146,16 @@ ALPN-specific behavior to. Two implementations exist: server push, and stream prioritization. Manages unidirectional control streams internally. -The Application is selected as soon as the ALPN protocol is known: -immediately for clients, and for servers from the `OnClientHello` TLS -callback (see [Server handshake ordering](#server-handshake-ordering)). +When the `autoStart` option is true (the default) the Application is +selected as soon as the ALPN protocol is known: immediately for clients, +and for servers from the `OnClientHello` TLS callback (see +[Server handshake ordering](#server-handshake-ordering)). + +When `autoStart` is false, the Application is selected and started via +the session start APIs ( (`QuicSession.start()` or `Http3Session.start()`)). +A session must be started by the end of the tick when the connection is +opened (the server session callback or the client `opened` promise) - if +not, the connection is closed automatically. ### Allocator @@ -181,12 +188,11 @@ succeed but memory tracking is silently skipped. The state is deleted once **Client**: `Endpoint::Connect()` builds a `Session::Config` with `Side::CLIENT`, creates a `TLSContext`, and calls `Session::Create()` → -`ngtcp2_conn_client_new()`. The Application is selected immediately. +`ngtcp2_conn_client_new()`. **Server**: `Endpoint::Receive()` processes an Initial packet through address validation (retry tokens, LRU cache), then calls `Session::Create()` -→ `ngtcp2_conn_server_new()`. The Application is selected later, once the -ClientHello names an ALPN protocol. +→ `ngtcp2_conn_server_new()`. ### Server handshake ordering diff --git a/src/quic/application.cc b/src/quic/application.cc index 212a9984d1ac..11753aeb90f8 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -156,7 +156,7 @@ MaybeLocal Session::Application_Options::ToObject( static_assert(std::size(values) == std::size(names)); auto obj = tmpl->NewInstance(env->context(), values); - if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { + if (obj->SetPrototype(env->context(), Null(env->isolate())).IsNothing()) { return {}; } return obj; diff --git a/src/quic/application.h b/src/quic/application.h index 4fd748763742..fe0e0a7dd700 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -22,7 +22,7 @@ enum class HeadersFlags : uint8_t { TERMINAL, }; -// An Application implements the ALPN-protocol specific semantics on behalf +// An Application implements the protocol-specific semantics on behalf // of a QUIC Session. class Session::Application : public MemoryRetainer { public: diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 7d424b9bf6f6..3215ae3dd4fe 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -77,6 +77,7 @@ struct QuicAllocState; V(allow, "allow") \ V(application, "application") \ V(authoritative, "authoritative") \ + V(auto_start, "autoStart") \ V(bbr, "bbr") \ V(ca, "ca") \ V(cc_algorithm, "cc") \ diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 13ca955fa2a4..5c968ca6d6aa 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -2013,6 +2013,8 @@ void Endpoint::EmitNewSession(const BaseObjectPtr& session) { // ClientHello, which is the only output that can predate this callback. if (!session->is_destroyed()) { session->FlushPendingQlog(); + // JS has had its chance to start a session; without one, this closes it. + session->RequireApplication(); } } diff --git a/src/quic/session.cc b/src/quic/session.cc index d83c1cbb89db..0218d8b0450f 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -199,7 +199,8 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(SendDatagram, sendDatagram, SIDE_EFFECT) \ V(LocalTransportParams, localTransportParams, NO_SIDE_EFFECT) \ V(RemoteTransportParams, remoteTransportParams, NO_SIDE_EFFECT) \ - V(ApplicationOptions, applicationOptions, NO_SIDE_EFFECT) + V(ApplicationOptions, applicationOptions, NO_SIDE_EFFECT) \ + V(StartApplication, startApplication, SIDE_EFFECT) struct Session::State final { #define V(_, name, type) type name; @@ -623,7 +624,8 @@ Maybe Session::Options::From(Environment* env, !SET(keep_alive_timeout) || !SET(max_stream_window) || !SET(max_window) || !SET(max_payload_size) || !SET(unacknowledged_packet_threshold) || !SET(cc_algorithm) || !SET(draining_period_multiplier) || - !SET(max_datagram_send_attempts) || !SET(stream_idle_timeout)) { + !SET(max_datagram_send_attempts) || !SET(stream_idle_timeout) || + !SET(auto_start)) { return Nothing(); } @@ -1238,6 +1240,25 @@ struct Session::Impl final : public MemoryRetainer { } } + JS_METHOD(StartApplication) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + if (session->is_destroyed()) return args.GetReturnValue().Set(false); + CHECK(!session->has_application()); + CHECK(args[0]->IsUint32()); + auto type = static_cast(args[0].As()->Value()); + Application_Options options = Application_Options::kDefault; + if (!args[1]->IsUndefined() && + !Application_Options::From(env, args[1]).To(&options)) { + return; + } + session->SetApplication(type == Application::Type::HTTP3 + ? CreateHttp3Application(session, options) + : CreateDefaultApplication(session, options)); + args.GetReturnValue().Set(!session->flags_.application_start_failed); + } + JS_METHOD(ApplicationOptions) { auto env = Environment::GetCurrent(args); Session* session; @@ -1447,6 +1468,11 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS; + // A client that hasn't started its session yet can still start one + // until the handshake completes, see SetApplication(). + session->keys_ready_ = true; + if (!session->impl_->application_) return NGTCP2_SUCCESS; + // If the application was already started via on_receive_tx_key // (0-RTT path), this is a no-op. if (session->application().is_started()) return NGTCP2_SUCCESS; @@ -1784,7 +1810,7 @@ Session::SendPendingDataScope::~SendPendingDataScope() { DCHECK_GE(session->impl_->send_scope_depth_, 1); Debug(session, "Send Scope Depth %zu", session->impl_->send_scope_depth_); if (--session->impl_->send_scope_depth_ == 0 && - session->impl_->application_ && !session->impl_->handshake_deferred_) { + !session->impl_->handshake_deferred_) { session->SendPendingData(); } } @@ -2026,7 +2052,7 @@ void Session::SendPendingData() { stream_data.fin = false; stream_data.stream.reset(); - if (application().GetStreamData(&stream_data) < 0) { + if (impl_->application_ && application().GetStreamData(&stream_data) < 0) { Debug(this, "Application failed to get stream data"); SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); closed = true; @@ -2274,7 +2300,7 @@ Session::Session(Endpoint* endpoint, // For clients, select the Application immediately - the ALPN is // known upfront from the options. For servers, application_ stays // null until the ClientHello names a protocol. - if (config.side == Side::CLIENT) { + if (config.side == Side::CLIENT && config.options.auto_start) { InstallApplicationForAlpn(DecodeAlpn(config.options.tls_options.alpn)); } @@ -2440,15 +2466,6 @@ void Session::Close(CloseMethod method) { } impl_->state()->graceful_close = 1; - // application_ may be null for server sessions if close() is called - // before the TLS handshake selects the ALPN. Without an application - // we cannot do a graceful shutdown (GOAWAY, CONNECTION_CLOSE etc.), - // so fall through to a silent close. - if (!impl_->application_) { - impl_->state()->silent_close = 1; - return FinishClose(); - } - // The SendPendingDataScope ensures that the GOAWAY packet queued // by BeginShutdown is actually sent. Without it, the GOAWAY sits // in nghttp3's outq until the next Receive() triggers a send. @@ -2650,6 +2667,19 @@ void Session::SetEarlyRemoteTransportParams(std::span params) { *this, params.data(), params.size())); } +bool Session::RequireApplication() { + if (is_destroyed()) return false; + if (has_application()) return !flags_.application_start_failed; + // Nothing can use a connection that no session was started on. Inside an + // ngtcp2 callback, returning false fails the handshake instead. + Debug(this, "No application started"); + if (!flags_.in_ngtcp2_callback_scope) { + SetLastError(QuicError::ForTransport(NGTCP2_CONNECTION_REFUSED)); + Close(); + } + return false; +} + void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); impl_->state()->application_type = static_cast(app->type()); @@ -2662,12 +2692,21 @@ void Session::SetApplication(std::unique_ptr app) { : StreamCallbacksSupportState::UNSUPPORTED); // Surface the application's "no error" and "internal error" codes via // session state so that JS-side code (e.g. the stream writer's fail() - // path) can resolve the right wire code for the negotiated ALPN + // path) can resolve the right wire code for the installed application // without duplicating the per-application table. impl_->state()->no_error_code = app->GetNoErrorCode(); impl_->state()->internal_error_code = app->GetInternalErrorCode(); impl_->state()->request_rejected_code = app->GetRequestRejectedCode(); impl_->application_ = std::move(app); + + // A client can start its session after its keys were installed (from JS + // run when the handshake completes), in which case the key callbacks that + // would start the Application have already run, so we have to retrigger + // app.start() here: + if (keys_ready_ && !application().Start()) { + Debug(this, "Application start failed"); + flags_.application_start_failed = 1; + } } const SocketAddress& Session::remote_address() const { @@ -2858,15 +2897,16 @@ bool Session::AfterNgtcp2Read(int err) { if (is_destroyed()) return true; // The ClientHello has been processed: SNI and ALPN are selected and - // the Application is installed, but the handshake is stopped short - // of ticket decryption, so no early data exists yet. Surface the - // session, then let the handshake run on. The guard makes this fire - // exactly once, on whichever packet completed the ClientHello, so a - // ClientHello split across datagrams is handled correctly. + // the Application is installed (unless JS is to start one), but the + // handshake is stopped short of ticket decryption, so no early data + // exists yet. Surface the session, then let the handshake run on. The + // guard makes this fire exactly once, on whichever packet completed + // the ClientHello, so a ClientHello split across datagrams is handled + // correctly. if (is_server() && tls_session().early_selection() == TLSSession::EarlySelection::kSelected) { endpoint().EmitNewSession(BaseObjectPtr(this)); - if (!is_destroyed()) ResumeHandshake(); + if (has_application()) ResumeHandshake(); } } return true; @@ -3021,13 +3061,11 @@ void Session::SendBatch(Packet::Ptr* packets, void Session::FlushPendingData() { DCHECK(!is_destroyed()); - if (impl_->application_) { - // Prefer synchronous sends during the deferred flush to avoid the - // one-tick latency of async uv_udp_send from the uv_check callback. - flags_.prefer_try_send = true; - SendPendingData(); - flags_.prefer_try_send = false; - } + // Prefer synchronous sends during the deferred flush to avoid the + // one-tick latency of async uv_udp_send from the uv_check callback. + flags_.prefer_try_send = true; + SendPendingData(); + flags_.prefer_try_send = false; } void Session::Send(Packet::Ptr packet) { @@ -3700,7 +3738,6 @@ void Session::SendConnectionClose() { void Session::OnTimeout() { if (is_destroyed()) return; - if (!impl_->application_) return; // Hold a strong reference to prevent the Session from being freed during // re-entrant calls. SendPendingData's scope guard calls UpdateTimer(), // which can synchronously re-enter OnTimeout() when the timer has already @@ -3888,6 +3925,10 @@ bool Session::HandshakeCompleted() { EmitHandshakeComplete(); + // The handshake is complete and session.opened has resolved, with its + // microtasks run, so the session needs its Application now. + if (!RequireApplication()) return false; + return true; } @@ -4448,11 +4489,17 @@ void Session::InitPerContext(Realm* realm, Local target) { static_cast(Direction::BIDIRECTIONAL); static constexpr auto STREAM_DIRECTION_UNIDIRECTIONAL = static_cast(Direction::UNIDIRECTIONAL); + static constexpr auto QUIC_APPLICATION_DEFAULT = + static_cast(Application::Type::DEFAULT); + static constexpr auto QUIC_APPLICATION_HTTP3 = + static_cast(Application::Type::HTTP3); static constexpr auto QUIC_PROTO_MAX = NGTCP2_PROTO_VER_MAX; static constexpr auto QUIC_PROTO_MIN = NGTCP2_PROTO_VER_MIN; NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_BIDIRECTIONAL); NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_UNIDIRECTIONAL); + NODE_DEFINE_CONSTANT(target, QUIC_APPLICATION_DEFAULT); + NODE_DEFINE_CONSTANT(target, QUIC_APPLICATION_HTTP3); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LIST_PAIRS); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LENGTH); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); diff --git a/src/quic/session.h b/src/quic/session.h index 9834aa7ec130..12ed0ba69d6d 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -101,7 +101,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { static const Application_Options kDefault; }; - // An Application implements the ALPN-protocol specific semantics on behalf + // An Application implements the protocol-specific semantics on behalf // of a QUIC Session. class Application; @@ -162,6 +162,10 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // ALPN selects Http3ApplicationImpl). Application_Options application_options = Application_Options::kDefault; + // When true, the Application is selected by the negotiated ALPN. When + // false, JavaScript starts one explicitly. + bool auto_start = true; + // When true, QLog output will be enabled for the session. bool qlog = false; @@ -431,14 +435,16 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // to DefaultApplication. Sets the application_type state field. std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); - // Install the Application on the session. Called at construction for - // clients (ALPN known upfront) or from the ClientHello callback for - // servers (ALPN negotiated during handshake). Must be called before any + // Install the Application on the session. Must be called before any // application data is received. void SetApplication(std::unique_ptr app); void InstallApplicationForAlpn(std::string_view alpn); + // Called once an Application is required. False, closing the session, if + // none has been started or it could not be started. + bool RequireApplication(); + // ngtcp2 ignores the duplicate when the TLS stack reports these again. void SetEarlyRemoteTransportParams(std::span params); @@ -737,11 +743,18 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // Set during FlushPendingData to avoid the one-tick latency of // async-only sends from the uv_check callback. uint8_t prefer_try_send : 1 = 0; + // Set if the application couldn't be started, which is fatal to it. + uint8_t application_start_failed : 1 = 0; }; Flags flags_; bool hello_processed_ = false; + // Set once the encryption keys an application needs in order to start are + // installed. An application attached before this point is started by the + // key callbacks; after this it misses those so starts itself. + bool keys_ready_ = false; + QuicConnectionPointer connection_; std::unique_ptr tls_session_; friend struct NgTcp2CallbackScope; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index b5106d27e127..6f1ea7f22cdb 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -397,10 +397,9 @@ crypto::ClientHelloResult TLSContext::OnClientHello( Debug(&session, "ALPN negotiation succeeded: %s", *negotiated); tls_session.set_alpn(*negotiated); - // Install the Application while the handshake is still stopped, so that - // it is in place before a session ticket can be accepted and early data - // can start arriving. - session.InstallApplicationForAlpn(*negotiated); + if (session.options().auto_start) { + session.InstallApplicationForAlpn(*negotiated); + } session.set_hello_processed(); // Stop here. Session::AfterNgtcp2Read surfaces the server session to diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index 8209eda12dbc..35ddb35955ee 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -220,7 +220,7 @@ class TLSContext final : public MemoryRetainer, // The ALPN protocol identifier(s) in wire format (length-prefixed, // concatenated). For clients this is a single entry. For servers // this may contain multiple entries in preference order. - std::string alpn = NGHTTP3_ALPN_H3; + std::string alpn; // The list of TLS ciphers to use for this session. std::string ciphers = DEFAULT_CIPHERS; @@ -367,9 +367,9 @@ class TLSContext final : public MemoryRetainer, // connection cannot be served at all. TLSContext* SelectSNIContext(std::string_view servername); - // Performs the server's early selection: SNI, then ALPN, then the - // Application, and then suspends the handshake. See the comment on - // TLSSession::EarlySelection. + // Performs the server's early selection: SNI, then ALPN, then (with + // autoStart) the Application, and then suspends the handshake. See the + // comment on TLSSession::EarlySelection. static crypto::ClientHelloResult OnClientHello( const crypto::ClientHelloContext& hello); diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index 3d2e333b32c5..45e6abc31098 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -485,7 +485,7 @@ v8::MaybeLocal TransportParams::ToObject(Environment* env) const { } auto obj = tmpl->NewInstance(env->context(), values); - if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { + if (obj->SetPrototype(env->context(), Null(env->isolate())).IsNothing()) { return {}; } return obj; diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index 9cf6e88f6cb3..4398c5437d04 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -15,7 +15,7 @@ const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); // Test h3 ALPN negotiation with Http3ApplicationImpl. -// Both server and client use the default ALPN (h3). +// Both server and client use the h3 ALPN. const serverOpened = Promise.withResolvers(); @@ -25,12 +25,14 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverOpened.resolve(); serverSession.close(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); assert.notStrictEqual(serverEndpoint.address, undefined); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-alpn.mjs b/test/parallel/test-quic-alpn.mjs index 9ca3d40e4fef..494bcd0119ee 100644 --- a/test/parallel/test-quic-alpn.mjs +++ b/test/parallel/test-quic-alpn.mjs @@ -43,6 +43,16 @@ const clientSession = await connect(serverEndpoint.address, { await Promise.all([serverOpened.promise, checkSession(clientSession)]); await clientSession.close(); + +// Omitting ALPN entirely is rejected up front too, with its own error: +// node:quic is transport-only and has no application protocol to default to. +await assert.rejects(listen(mustNotCall(), { + sni: { '*': { keys: [key], certs: [cert] } }, +}), { code: 'ERR_MISSING_OPTION', message: /options\.alpn/ }); +await assert.rejects(connect(serverEndpoint.address, { verifyPeer: 'manual' }), { + code: 'ERR_MISSING_OPTION', message: /options\.alpn/, +}); + await serverEndpoint.close(); // QUIC requires an application protocol, so a server that offers none is diff --git a/test/parallel/test-quic-certificate-compression.mjs b/test/parallel/test-quic-certificate-compression.mjs index 455029bfb648..5c04364389c8 100644 --- a/test/parallel/test-quic-certificate-compression.mjs +++ b/test/parallel/test-quic-certificate-compression.mjs @@ -74,12 +74,14 @@ async function handshake({ serverAlgs, clientAlgs }) { serverOpened.resolve(); serverSession.close(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, ...(serverAlgs !== undefined ? { certificateCompression: serverAlgs } : {}), }); const clientSession = await connect(endpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', ...(clientAlgs !== undefined ? diff --git a/test/parallel/test-quic-datagram-drop-oldest.mjs b/test/parallel/test-quic-datagram-drop-oldest.mjs index 3c1b92be411e..37863b5e62a3 100644 --- a/test/parallel/test-quic-datagram-drop-oldest.mjs +++ b/test/parallel/test-quic-datagram-drop-oldest.mjs @@ -62,6 +62,7 @@ const clientSession = await connect(serverEndpoint.address, { await clientSession.opened; +assert.strictEqual(clientSession.maxPendingDatagrams, 128); clientSession.maxPendingDatagrams = 2; // Send 5 datagrams. With drop-oldest and queue size 2: diff --git a/test/parallel/test-quic-diagnostics-channel-session.mjs b/test/parallel/test-quic-diagnostics-channel-session.mjs index bd5ae93cd3cf..2f292f9deabb 100644 --- a/test/parallel/test-quic-diagnostics-channel-session.mjs +++ b/test/parallel/test-quic-diagnostics-channel-session.mjs @@ -38,7 +38,7 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // Trigger a key update to fire a key update event. -clientSession.updateKey(); +clientSession.connection.updateKey(); await clientSession.closed; await serverEndpoint.close(); diff --git a/test/parallel/test-quic-early-selection-order.mjs b/test/parallel/test-quic-early-selection-order.mjs index f7cc89fb04c0..74a0af30577d 100644 --- a/test/parallel/test-quic-early-selection-order.mjs +++ b/test/parallel/test-quic-early-selection-order.mjs @@ -110,7 +110,7 @@ const decoder = new TextDecoder(); const info = await cs.opened; assert.strictEqual(info.protocol, 'quic-test'); // Validate the HRR happened: we fell back to 2nd group - assert.strictEqual(cs.ephemeralKeyInfo.name, 'secp521r1'); + assert.strictEqual(cs.connection.ephemeralKeyInfo.name, 'secp521r1'); await serverDone.promise; cs.close(); diff --git a/test/parallel/test-quic-edge-destroyed-ops.mjs b/test/parallel/test-quic-edge-destroyed-ops.mjs index b109a3ca998d..8ced22105ad8 100644 --- a/test/parallel/test-quic-edge-destroyed-ops.mjs +++ b/test/parallel/test-quic-edge-destroyed-ops.mjs @@ -35,11 +35,11 @@ clientSession.destroy(); assert.strictEqual(clientSession.destroyed, true); // Properties should return null/undefined gracefully. -assert.strictEqual(clientSession.endpoint, null); -assert.strictEqual(clientSession.path, undefined); -assert.strictEqual(clientSession.certificate, undefined); -assert.strictEqual(clientSession.peerCertificate, undefined); -assert.strictEqual(clientSession.ephemeralKeyInfo, undefined); +assert.strictEqual(clientSession.connection.endpoint, null); +assert.strictEqual(clientSession.connection.path, undefined); +assert.strictEqual(clientSession.connection.certificate, undefined); +assert.strictEqual(clientSession.connection.peerCertificate, undefined); +assert.strictEqual(clientSession.connection.ephemeralKeyInfo, undefined); // destroy() again is idempotent. clientSession.destroy(); diff --git a/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs b/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs index ac0143a2a3cc..54e90f9e0a12 100644 --- a/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs +++ b/test/parallel/test-quic-endpoint-destroy-cascade-close-frame.mjs @@ -49,7 +49,7 @@ const serverError = new Error('cascade close frame test'); // (which is the regression this test is designed to catch). const serverHandshake = Promise.withResolvers(); const onsession = mustCall((serverSession) => { - serverSession.onhandshake = mustCall(() => { + serverSession.connection.onhandshake = mustCall(() => { serverHandshake.resolve(); }); }); diff --git a/test/parallel/test-quic-endpoint-reuse.mjs b/test/parallel/test-quic-endpoint-reuse.mjs index 7171a248024a..d9c6997193c0 100644 --- a/test/parallel/test-quic-endpoint-reuse.mjs +++ b/test/parallel/test-quic-endpoint-reuse.mjs @@ -34,7 +34,8 @@ const { listen, connect } = await import('../common/quic.mjs'); // findSuitableEndpoint returns the first available non-listening // non-closing endpoint. After client1 is created, its endpoint // is available for client2. - assert.strictEqual(client1.endpoint, client2.endpoint); // Client sessions should share an endpoint + // Client sessions should share an endpoint + assert.strictEqual(client1.connection.endpoint, client2.connection.endpoint); await client1.close(); await client2.close(); @@ -57,7 +58,8 @@ const { listen, connect } = await import('../common/quic.mjs'); }); await client2.opened; - assert.notStrictEqual(client1.endpoint, client2.endpoint); // Client sessions should have separate endpoints + // Client sessions should have separate endpoints + assert.notStrictEqual(client1.connection.endpoint, client2.connection.endpoint); await client1.close(); await client2.close(); @@ -77,7 +79,7 @@ const { listen, connect } = await import('../common/quic.mjs'); // the server endpoint is in the registry. Self-connect is excluded // because the client's DCID associations would collide with the // server's session routing on the same endpoint. - assert.notStrictEqual(client.endpoint, serverEndpoint); // Client should not reuse the server endpoint + assert.notStrictEqual(client.connection.endpoint, serverEndpoint); // Client should not reuse the server endpoint await client.close(); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-exports.mjs b/test/parallel/test-quic-exports.mjs index 71771624e31e..3092b9e931e0 100644 --- a/test/parallel/test-quic-exports.mjs +++ b/test/parallel/test-quic-exports.mjs @@ -12,10 +12,11 @@ const quic = await import('node:quic'); assert.strictEqual(typeof quic.connect, 'function'); assert.strictEqual(typeof quic.listen, 'function'); assert.strictEqual(typeof quic.QuicEndpoint, 'function'); +assert.strictEqual(typeof quic.QuicConnection, 'function'); assert.strictEqual(typeof quic.QuicSession, 'function'); assert.strictEqual(typeof quic.QuicStream, 'function'); assert.strictEqual(typeof quic.QuicEndpoint.Stats, 'function'); -assert.strictEqual(typeof quic.QuicSession.Stats, 'function'); +assert.strictEqual(typeof quic.QuicConnection.Stats, 'function'); assert.strictEqual(typeof quic.QuicStream.Stats, 'function'); assert.strictEqual(typeof quic.constants, 'object'); assert.strictEqual(typeof quic.constants.cc, 'object'); diff --git a/test/parallel/test-quic-h3-auto-start.mjs b/test/parallel/test-quic-h3-auto-start.mjs new file mode 100644 index 000000000000..9e920ec09c66 --- /dev/null +++ b/test/parallel/test-quic-h3-auto-start.mjs @@ -0,0 +1,114 @@ +// Flags: --experimental-quic --no-warnings + +// Test: a session matching the ALPN is started automatically, unless +// autoStart is false. Servers know the negotiated protocol before surfacing +// a session, and clients offer exactly one. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { + listen, connect, Http3Session, QuicSession, +} = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const clientOpts = { servername: 'localhost', verifyPeer: 'manual' }; + +const isHttp3 = (session) => session instanceof Http3Session; + +// Both sides start a session by ALPN. +{ + const seen = []; + const endpoint = await listen(mustCall((session) => { + seen.push(session.constructor); + session.onerror = () => {}; + }, 4), { + alpn: ['h3', 'h3-29', 'other'], + sni: { '*': { keys: [key], certs: [cert] } }, + }); + + // One protocol: started up front, before the handshake. + const single = await connect(endpoint.address, { ...clientOpts, alpn: 'h3' }); + assert.ok(isHttp3(single)); + assert.throws(() => QuicSession.start(single.connection), + { code: 'ERR_INVALID_STATE' }); + await single.opened; + await single.close(); + + // The ALPN is read once, so the session always matches what TLS offered. + let reads = 0; + const once = await connect(endpoint.address, { + ...clientOpts, + get alpn() { return reads++ === 0 ? 'h3' : 'other'; }, + }); + assert.strictEqual(reads, 1); + assert.ok(isHttp3(once)); + assert.strictEqual((await once.opened).protocol, 'h3'); + await once.close(); + + // Draft ALPNs count as HTTP/3 too. + const draft = await connect(endpoint.address, { ...clientOpts, alpn: 'h3-29' }); + assert.ok(isHttp3(draft)); + await draft.opened; + await draft.close(); + + // Any other protocol is a raw QuicSession on both sides. + const other = await connect(endpoint.address, { ...clientOpts, alpn: 'other' }); + assert.ok(other instanceof QuicSession); + await other.opened; + await other.close(); + + await endpoint.close(); + assert.deepStrictEqual(seen, [Http3Session, Http3Session, Http3Session, QuicSession]); +} + +// The application option gives the settings for an automatic Http3Session. +{ + const endpoint = await listen(mustCall((session) => { + assert.strictEqual(session.settings.maxHeaderPairs, 33n); + }), { + alpn: ['h3'], + application: { maxHeaderPairs: 33 }, + sni: { '*': { keys: [key], certs: [cert] } }, + }); + const client = await connect(endpoint.address, { + ...clientOpts, + alpn: 'h3', + application: { qpackBlockedStreams: 7n }, + }); + assert.strictEqual(client.settings.qpackBlockedStreams, 7n); + await client.opened; + await client.close(); + await endpoint.close(); + + await assert.rejects(connect('127.0.0.1:1', { + ...clientOpts, + alpn: 'h3', + application: { maxHeaderPairs: 'lots' }, + }), { name: 'TypeError', message: /maxHeaderPairs/ }); +} + +// Session options configure the session autoStart starts, so without it they +// are rejected rather than ignored. +for (const name of ['onerror', 'onstream', 'ondatagram', 'ondatagramstatus', + 'application']) { + await assert.rejects(connect('127.0.0.1:1', { + ...clientOpts, + alpn: 'h3', + autoStart: false, + [name]: name === 'application' ? {} : () => {}, + }), { code: 'ERR_INVALID_ARG_VALUE', message: new RegExp(`options\\.${name}`) }); +} + +// Autostart option rejects invalid values +for (const autoStart of [1, 'yes', null]) { + await assert.rejects(connect('127.0.0.1:1', { ...clientOpts, alpn: 'h3', autoStart }), + { code: 'ERR_INVALID_ARG_TYPE', message: /options\.autoStart/ }); +} diff --git a/test/parallel/test-quic-h3-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index be8ed391b8d5..b6f3d62afc7c 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -33,6 +33,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { await ss.closed; done.resolve(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, onheaders: onheadersHandler, @@ -52,6 +53,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ); const c = await connect(ep.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, @@ -92,6 +94,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ); const c = await connect(ep.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, @@ -137,6 +140,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { ); const c = await connect(ep.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, @@ -174,6 +178,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { const serverEndpoint = await listen(mustCall(async (ss) => { await ss.closed; }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] }, 'example.com': { keys: [key], certs: [cert] }, @@ -186,16 +191,17 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'example.com', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, - onorigin: mustCall(function() { - throw new Error('onorigin error'); - }), onerror: mustCall(function(error) { assert.strictEqual(error.message, 'onorigin error'); }), }); + clientSession.onorigin = mustCall(function() { + throw new Error('onorigin error'); + }); await clientSession.opened; const stream = await clientSession.createBidirectionalStream({ @@ -238,6 +244,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { await ss.closed; serverDone.resolve(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, onheaders: mustCall(function(headers) { @@ -251,6 +258,7 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, diff --git a/test/parallel/test-quic-h3-close-behavior.mjs b/test/parallel/test-quic-h3-close-behavior.mjs index e3b73ce9b0b9..293520bae4f9 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -31,6 +31,7 @@ const decoder = new TextDecoder(); serverSession = ss; ss.onstream = mustCall(2); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall((headers, stream) => { stream.sendHeaders({ ':status': '200' }); @@ -49,6 +50,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index 5bd5635008ca..d5a6c3154ffa 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -39,6 +39,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { })); }, REQUEST_COUNT); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { const path = headers[':path']; @@ -53,6 +54,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs index 38aeb971c8fe..505d9d34206b 100644 --- a/test/parallel/test-quic-h3-datagram.mjs +++ b/test/parallel/test-quic-h3-datagram.mjs @@ -40,6 +40,7 @@ const decoder = new TextDecoder(); ss.close(); serverDone.resolve(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, application: { enableDatagrams: true }, transportParams: { maxDatagramFrameSize: 100 }, @@ -62,6 +63,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', application: { enableDatagrams: true }, @@ -121,6 +123,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, // Server explicitly disables H3 datagrams. application: { enableDatagrams: false }, @@ -136,6 +139,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', application: { enableDatagrams: true }, diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index 3a91a2e8f056..82d087555339 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -33,6 +33,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -42,6 +43,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -84,6 +86,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -93,6 +96,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-flow-control-volume.mjs b/test/parallel/test-quic-h3-flow-control-volume.mjs index e422d1a411ad..5015d1b3b709 100644 --- a/test/parallel/test-quic-h3-flow-control-volume.mjs +++ b/test/parallel/test-quic-h3-flow-control-volume.mjs @@ -62,6 +62,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { initialMaxStreamDataBidiRemote: kStreamWindow, @@ -79,6 +80,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { diff --git a/test/parallel/test-quic-h3-goaway-non-h3.mjs b/test/parallel/test-quic-h3-goaway-non-h3.mjs deleted file mode 100644 index f61df78da9e8..000000000000 --- a/test/parallel/test-quic-h3-goaway-non-h3.mjs +++ /dev/null @@ -1,63 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: Non-H3 session close does not fire ongoaway. -// GOAWAY is an HTTP/3 concept. When a non-H3 session closes, the -// ongoaway callback must not fire. - -import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import { setImmediate } from 'node:timers/promises'; -import * as fixtures from '../common/fixtures.mjs'; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); -const { bytes } = await import('stream/iter'); - -const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const cert = fixtures.readKey('agent1-cert.pem'); -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -const serverDone = Promise.withResolvers(); - -const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - // Read client data, send response, close stream. - const data = await bytes(stream); - assert.strictEqual(decoder.decode(data), 'ping'); - stream.writer.writeSync('pong'); - stream.writer.endSync(); - await stream.closed; - ss.close(); - serverDone.resolve(); - }); -}), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: 'quic-test', -}); - -const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - alpn: 'quic-test', - // Ongoaway must NOT fire for non-H3 sessions. - ongoaway: mustNotCall(), -}); -await clientSession.opened; - -const stream = await clientSession.createBidirectionalStream({ - body: encoder.encode('ping'), -}); - -const response = await bytes(stream); -assert.strictEqual(decoder.decode(response), 'pong'); -await Promise.all([stream.closed, serverDone.promise]); - -// Wait a tick for any deferred callbacks to fire. -await setImmediate(); -await clientSession.close(); -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-goaway.mjs b/test/parallel/test-quic-h3-goaway.mjs index bb0bf8e966c1..fa1ad2902521 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -19,7 +19,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect, Http3Session } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -46,6 +46,7 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { serverSession = ss; ss.onstream = mustCall(2); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { const path = headers[':path']; @@ -66,9 +67,13 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { }, 2), }); - const clientSession = await connect(serverEndpoint.address, { + const connection = await connect(serverEndpoint.address, { + alpn: 'h3', + autoStart: false, servername: 'localhost', verifyPeer: 'manual', + }); + const clientSession = Http3Session.start(connection, { // Ongoaway fires when the peer sends GOAWAY. ongoaway: mustCall(function(lastStreamId) { assert.strictEqual(lastStreamId, -1n); diff --git a/test/parallel/test-quic-h3-handshake-failure.mjs b/test/parallel/test-quic-h3-handshake-failure.mjs index 201d3999d65a..b439180fd664 100644 --- a/test/parallel/test-quic-h3-handshake-failure.mjs +++ b/test/parallel/test-quic-h3-handshake-failure.mjs @@ -7,9 +7,9 @@ // assertion failure in nghttp3 (conn->tx.ctrl != NULL). // // The test creates an H3 server and a client that immediately closes the -// session before the handshake completes. The server creates the H3 -// application during ALPN negotiation, but Start() (which binds control -// streams) hasn't been called yet when the session is torn down. +// session before the handshake completes. The server attaches the H3 +// application, but Start() (which binds control streams) hasn't +// been called yet when the session is torn down. // The server must handle this gracefully without crashing. import { hasQuic, skip, mustNotCall } from '../common/index.mjs'; @@ -29,6 +29,7 @@ const cert = fixtures.readKey('agent1-cert.pem'); const serverEndpoint = await listen(async (serverSession) => { await serverSession.closed; }, { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustNotCall(), }); @@ -41,6 +42,7 @@ const clientSession = await connect(serverEndpoint.address, { verifyPeer: 'manual', // h3 ALPN — must match the server so the H3 application is selected // on the server side before we tear it down. + alpn: 'h3', }); // Close immediately — don't wait for handshake. diff --git a/test/parallel/test-quic-h3-header-interest.mjs b/test/parallel/test-quic-h3-header-interest.mjs index 4fe05b6f6d3e..e221c2ed2853 100644 --- a/test/parallel/test-quic-h3-header-interest.mjs +++ b/test/parallel/test-quic-h3-header-interest.mjs @@ -28,6 +28,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function() { this.sendInformationalHeaders({ @@ -43,6 +44,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index a75a884c39b9..a8ca20adbebf 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -42,6 +42,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { // H3V-01: All header names should be lowercase regardless @@ -73,6 +74,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -121,6 +123,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { // All four required pseudo-headers present. @@ -135,6 +138,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index 357b507ae0db..ac052834755e 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -52,6 +52,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { // Send 103 Early Hints before the final response. @@ -73,6 +74,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-manual-start.mjs b/test/parallel/test-quic-h3-manual-start.mjs new file mode 100644 index 000000000000..7cc3d76ebec3 --- /dev/null +++ b/test/parallel/test-quic-h3-manual-start.mjs @@ -0,0 +1,235 @@ +// Flags: --experimental-quic --no-warnings + +// Test: starting HTTP/3 on a node:quic connection - when it is allowed, what +// it validates, and how it behaves when the window has closed. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect, Http3Session, QuicSession } = await import('node:quic'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); +const cert = fixtures.readKey('agent1-cert.pem'); +const serverOpts = { + alpn: ['h3'], + autoStart: false, + sni: { '*': { keys: [key], certs: [cert] } }, +}; +const clientOpts = { + alpn: 'h3', + autoStart: false, + servername: 'localhost', + verifyPeer: 'manual', +}; + +// Only a QuicConnection can carry a session, started with start(): +for (const Session of [Http3Session, QuicSession]) { + assert.throws(() => Session.start({}), { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => new Session(), { code: 'ERR_ILLEGAL_CONSTRUCTOR' }); +} + +// Validate manually starting both sides of an HTTP/3 session: +{ + const endpoint = await listen(mustCall((connection) => { + const session = Http3Session.start(connection); + assert.strictEqual(session.connection, connection); + + // Can only start once: + assert.throws(() => Http3Session.start(connection), + { code: 'ERR_INVALID_STATE' }); + }), serverOpts); + + const quicClient = await connect(endpoint.address, clientOpts); + + // Options are validated before anything is recorded, so a session can + // still be started after these failures: + assert.throws(() => Http3Session.start(quicClient, null), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => Http3Session.start(quicClient, { ongoaway: 5 }), + { code: 'ERR_INVALID_ARG_TYPE' }); + + const client = Http3Session.start(quicClient); + await client.opened; + + // Connection details, TLS included, stay on the QUIC connection: + assert.strictEqual(client.connection.alpnProtocol, 'h3'); + assert.strictEqual(client.connection.servername, 'localhost'); + assert.strictEqual('peerCertificate' in client, false); + assert.strictEqual(typeof client.stats.createdAt, 'bigint'); + assert.strictEqual(client.closing, client.connection.closing); + await client.close(); + await endpoint.close(); +} + +const alreadyStarted = { + code: 'ERR_INVALID_STATE', + message: /already has a session started/, +}; +const connectionRefused = { code: 'ERR_QUIC_TRANSPORT_ERROR', message: /CONNECTION_REFUSED/ }; +const destroyed = { code: 'ERR_INVALID_STATE', message: /destroyed/ }; + +// A raw QuicSession started first rules out HTTP/3. +{ + const endpoint = await listen(mustCall((connection) => { + QuicSession.start(connection); + assert.throws(() => Http3Session.start(connection), alreadyStarted); + }), serverOpts); + const connection = await connect(endpoint.address, clientOpts); + const client = QuicSession.start(connection); + assert.throws(() => Http3Session.start(connection), alreadyStarted); + await client.opened; + await client.close(); + await endpoint.close(); +} + +// Server: a connection with no session started by the end of the session +// callback is closed with an error, so a deferred start is too late. +{ + const done = Promise.withResolvers(); + const endpoint = await listen(mustCall((connection) => { + setImmediate(mustCall(() => { + assert.throws(() => Http3Session.start(connection), destroyed); + done.resolve(); + })); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + await assert.rejects(client.opened, connectionRefused); + await done.promise; + await endpoint.close(); +} + +// Client: the window stays open during the tick that resolves `opened`, so +// the negotiated ALPN can be read and acted on before starting. +{ + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + const info = await client.opened; + assert.strictEqual(info.protocol, 'h3'); + assert.strictEqual(client.alpnProtocol, 'h3'); + // Further already-settled awaits are still the same checkpoint. + await null; + const http3 = Http3Session.start(client); + assert.strictEqual(http3.connection, client); + await http3.close(); + await endpoint.close(); +} + +// Client: yielding to the event loop closes the window, closing the +// connection with an error as no session was started on it. +{ + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection).closed.catch(() => {}); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + await client.opened; + await new Promise(setImmediate); + await assert.rejects(client.closed, { + code: 'ERR_QUIC_TRANSPORT_ERROR', + message: /INTERNAL_ERROR/, + }); + assert.throws(() => Http3Session.start(client), destroyed); + await endpoint.close(); +} + +// Settings are validated before anything is recorded, so a rejected value +// names the property at fault and leaves the connection still startable. +{ + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + for (const settings of [42, true, 'nope', null]) { + assert.throws(() => Http3Session.start(client, { settings }), + { code: 'ERR_INVALID_ARG_TYPE', message: /options\.settings/ }); + } + const badType = { code: 'ERR_INVALID_ARG_TYPE' }; + const badRange = { code: 'ERR_OUT_OF_RANGE' }; + for (const [settings, expected] of [ + [{ maxHeaderPairs: 'lots' }, badType], + [{ maxHeaderPairs: 1.5 }, badRange], + [{ qpackBlockedStreams: 1n << 65n }, badRange], + [{ enableDatagrams: 1 }, badType], + ]) { + assert.throws(() => Http3Session.start(client, { settings }), (err) => { + assert.strictEqual(err.code, expected.code); + assert.match(err.message, /options\.settings\./); + return true; + }); + } + // Numbers are accepted for bigint settings, which stay in effect through the + // handshake, with anything else left as default: + const http3 = Http3Session.start(client, { settings: { maxHeaderPairs: 12 } }); + assert.strictEqual(http3.settings.maxHeaderPairs, 12n); + await http3.opened; + assert.strictEqual(http3.settings.maxHeaderPairs, 12n); + assert.strictEqual(http3.settings.qpackMaxDtableCapacity, 4096n); + await http3.close(); + await endpoint.close(); +} + +// Parsing the settings runs their property getters, i.e. arbitrary JS, part +// way through the start. We should safely handle even the weirdest things +// you could do as part of that: +{ + // Server: the getter destroys the connection. + const done = Promise.withResolvers(); + const endpoint = await listen(mustCall((connection) => { + const settings = { + get maxHeaderPairs() { connection.destroy(); return 10n; }, + }; + assert.throws(() => Http3Session.start(connection, { settings }), { + code: 'ERR_INVALID_STATE', + message: /destroyed/, + }); + done.resolve(); + }), serverOpts); + const client = await connect(endpoint.address, clientOpts); + await done.promise; + client.destroy(); + await endpoint.close(); +} +{ + // Client: the getter starts another Http3Session. That inner start is the + // one that sticks; the outer one finds the connection already taken. + const endpoint = await listen(mustCall((connection) => { + Http3Session.start(connection); + }), serverOpts); + + const client = await connect(endpoint.address, clientOpts); + let inner; + const settings = { + get maxHeaderPairs() { inner = Http3Session.start(client); return 10n; }, + }; + assert.throws(() => Http3Session.start(client, { settings }), alreadyStarted); + assert.ok(inner instanceof Http3Session); + assert.throws(() => Http3Session.start(client), alreadyStarted); + await inner.opened; + await inner.close(); + await endpoint.close(); +} + +// HTTP/3 has no server-initiated request streams, so a server session must +// refuse to open one however it is asked. +{ + const refused = Promise.withResolvers(); + const endpoint = await listen(mustCall((connection) => { + const server = Http3Session.start(connection); + refused.resolve(assert.rejects(server.createBidirectionalStream(), { + code: 'ERR_INVALID_STATE', + message: /Server sessions cannot open HTTP\/3 request streams/, + })); + }), serverOpts); + const client = Http3Session.start(await connect(endpoint.address, clientOpts)); + await refused.promise; + await client.opened; + await client.close(); + await endpoint.close(); +} diff --git a/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs b/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs index df74e4db408b..65f135dafffd 100644 --- a/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs +++ b/test/parallel/test-quic-h3-maxstreamdata-external-buffer-failure.mjs @@ -37,6 +37,7 @@ const endpoint = await listen((session) => { for await (const _ of stream) { /* reading extends the window */ } }; }, { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { initialMaxStreamDataBidiRemote: WINDOW, @@ -46,6 +47,7 @@ const endpoint = await listen((session) => { }); const session = await connect(endpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 9f80449b6b65..d085a2153e93 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -13,7 +13,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect, Http3Session } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -36,6 +36,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { // Wildcard entry should NOT appear in ORIGIN frame. '*': { keys: [key], certs: [cert] }, @@ -50,9 +51,13 @@ const decoder = new TextDecoder(); }), }); - const clientSession = await connect(serverEndpoint.address, { + const connection = await connect(serverEndpoint.address, { + alpn: 'h3', + autoStart: false, servername: 'example.com', verifyPeer: 'manual', + }); + const clientSession = Http3Session.start(connection, { // Client receives ORIGIN frame via onorigin callback. onorigin: mustCall(function(origins) { assert.ok(Array.isArray(origins)); @@ -104,6 +109,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] }, // Non-default port → origin includes port. @@ -128,9 +134,13 @@ const decoder = new TextDecoder(); }), }); - const clientSession = await connect(serverEndpoint.address, { + const connection = await connect(serverEndpoint.address, { + alpn: 'h3', + autoStart: false, servername: 'custom-port.example.com', verifyPeer: 'manual', + }); + const clientSession = Http3Session.start(connection, { onorigin: mustCall(function(origins) { assert.ok(Array.isArray(origins)); diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index a6e9c8cfd912..4dea83f980bd 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -33,6 +33,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { // Headers were enqueued before the stream opened @@ -47,6 +48,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-post-filehandle.mjs b/test/parallel/test-quic-h3-post-filehandle.mjs index a4c583463d24..20306a6ddc01 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -45,6 +45,7 @@ writeFileSync(testFile, testContent); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':method'], 'POST'); @@ -57,6 +58,7 @@ writeFileSync(testFile, testContent); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index c5d9635a640c..a1c69e09bda4 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -42,6 +42,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':method'], 'POST'); @@ -64,6 +65,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index 10be3d6f216e..598a18a53739 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -39,6 +39,7 @@ const decoder = new TextDecoder(); assert.strictEqual(typeof pri.incremental, 'boolean'); }, 4); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -51,6 +52,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -182,6 +184,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -191,6 +194,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index f30b7163cf6b..10718307ab26 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -53,6 +53,7 @@ async function makeRequest(clientSession, path) { const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(2); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, // Server disables QPACK dynamic table. application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, @@ -67,6 +68,7 @@ async function makeRequest(clientSession, path) { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', // Client also disables QPACK dynamic table. @@ -92,6 +94,7 @@ async function makeRequest(clientSession, path) { const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(2); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, onheaders: mustCall(function(headers) { @@ -105,6 +108,7 @@ async function makeRequest(clientSession, path) { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, diff --git a/test/parallel/test-quic-h3-request-rejected.mjs b/test/parallel/test-quic-h3-request-rejected.mjs index 6ed987b395af..8fe0acceda12 100644 --- a/test/parallel/test-quic-h3-request-rejected.mjs +++ b/test/parallel/test-quic-h3-request-rejected.mjs @@ -28,10 +28,12 @@ const H3_REQUEST_REJECTED = 0x10bn; const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onerror = () => {}; }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index cde16684d7e9..91404e026df8 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -41,11 +41,10 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, - // Default ALPN is h3 — omitted intentionally to exercise the default. - // - // onheaders is provided via listen options so it is applied to - // incoming streams (via kStreamCallbacks) BEFORE onstream fires. + // The onheaders callback is provided via listen options so it is applied + // to incoming streams (via kStreamCallbacks) BEFORE onstream fires. // For H3, onheaders must be set because the H3 application delivers // headers and stream[kHeaders] asserts the callback exists. onheaders: mustCall(function(headers) { @@ -73,9 +72,9 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', - // Default ALPN is h3. }); const info = await clientSession.opened; diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index 733d3865e872..ca2c037ce8a8 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -36,6 +36,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, // Allow 5 header pairs: 4 pseudo-headers + 1 custom. application: { maxHeaderPairs: 5 }, @@ -56,6 +57,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -98,6 +100,7 @@ const decoder = new TextDecoder(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, // Limit total header bytes. The 4 pseudo-headers fit within 100 // bytes, but adding x-long (6 + 200 = 206 bytes) exceeds it. @@ -115,6 +118,7 @@ const decoder = new TextDecoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -146,12 +150,18 @@ const decoder = new TextDecoder(); const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onsettings = mustCall((appopt) => { + assert.strictEqual(appopt.enableDatagrams, true); + assert.strictEqual(appopt.enableConnectProtocol, false); + // Must be false, as this is only sent from server side + }); ss.onstream = mustCall(async (stream) => { await stream.closed; ss.close(); serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, application: { enableConnectProtocol: true, enableDatagrams: true }, onheaders: mustCall(function(headers) { @@ -159,19 +169,15 @@ const decoder = new TextDecoder(); this.writer.writeSync(encoder.encode('settings-ok')); this.writer.endSync(); }), - onapplication: mustCall((appopt) => { - assert.strictEqual(appopt.enableDatagrams, true); - assert.strictEqual(appopt.enableConnectProtocol, false); - // Must be false, as this is only sent from server side - }) }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', application: { enableConnectProtocol: true, enableDatagrams: true }, }); - clientSession.onapplication = mustCall((appopt) => { + clientSession.onsettings = mustCall((appopt) => { assert.strictEqual(appopt.enableConnectProtocol, true); assert.strictEqual(appopt.enableDatagrams, true); }); diff --git a/test/parallel/test-quic-h3-status-code-type.mjs b/test/parallel/test-quic-h3-status-code-type.mjs index a1bc7178e17a..886f321e03e3 100644 --- a/test/parallel/test-quic-h3-status-code-type.mjs +++ b/test/parallel/test-quic-h3-status-code-type.mjs @@ -29,6 +29,7 @@ const serverEndpoint = await listen(mustCall(async (ss) => { } }, codes.length); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function() { const status = codes[serverResponses - 1]; @@ -38,6 +39,7 @@ const serverEndpoint = await listen(mustCall(async (ss) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-stream-credit.mjs b/test/parallel/test-quic-h3-stream-credit.mjs index 276a6ede247a..16f77e9876ee 100644 --- a/test/parallel/test-quic-h3-stream-credit.mjs +++ b/test/parallel/test-quic-h3-stream-credit.mjs @@ -36,6 +36,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { stream.closed.then(mustCall(() => { liveServerStreams--; })); }, kRequests); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, // Only one client-initiated bidi stream may be open at a time. transportParams: { initialMaxStreamsBidi: 1 }, @@ -48,6 +49,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs b/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs index c9e05eb245b1..4b76c4e24e83 100644 --- a/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-no-resurrect.mjs @@ -47,6 +47,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { stream.onerror = () => {}; }, kRequests); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function() { this.sendHeaders({ ':status': '200' }); @@ -55,6 +56,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { diff --git a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs index 2f5739185f99..402bbe3d06ff 100644 --- a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs @@ -26,10 +26,12 @@ const serverEndpoint = await listen(mustCall(async (ss) => { await ss.closed; serverDone.resolve(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-stream-idle-timeout.mjs b/test/parallel/test-quic-h3-stream-idle-timeout.mjs index f3e83a8397ca..59ef9d6a01be 100644 --- a/test/parallel/test-quic-h3-stream-idle-timeout.mjs +++ b/test/parallel/test-quic-h3-stream-idle-timeout.mjs @@ -39,6 +39,7 @@ const encoder = new TextEncoder(); streamDestroyed.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 100, onheaders() { @@ -47,6 +48,7 @@ const encoder = new TextEncoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', transportParams: { maxIdleTimeout: 1 }, @@ -94,6 +96,7 @@ const encoder = new TextEncoder(); await serverSession.close(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 500, onheaders: mustCall(function(headers) { @@ -104,6 +107,7 @@ const encoder = new TextEncoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -146,6 +150,7 @@ const encoder = new TextEncoder(); await setTimeout(700); await serverSession.close(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 0, // Disabled onheaders: mustCall(function(headers) { @@ -154,6 +159,7 @@ const encoder = new TextEncoder(); }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-stream-without-onstream.mjs b/test/parallel/test-quic-h3-stream-without-onstream.mjs index fcd5bbe019ba..f7b7cb3358fb 100644 --- a/test/parallel/test-quic-h3-stream-without-onstream.mjs +++ b/test/parallel/test-quic-h3-stream-without-onstream.mjs @@ -45,6 +45,7 @@ function failOnConsumerWarning(warning) { const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onerror = () => {}; }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { assert.strictEqual(headers[':path'], '/test'); @@ -60,6 +61,7 @@ function failOnConsumerWarning(warning) { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -149,8 +151,9 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; // session actually attaches to a received stream. const bootstrap = await listen(mustCall((session) => { session.onerror = () => {}; - }), { sni: { '*': { keys: [key], certs: [cert] } }, onstream: () => {} }); + }), { alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onstream: () => {} }); const bootSession = await connect(bootstrap.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -171,6 +174,7 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; }), { __proto__: null, ...probes, + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onstream: mustCall((stream) => { applied.resolve(candidates.filter((n) => typeof stream[n] === 'function')); @@ -178,6 +182,7 @@ const kNonConsumerCallbacks = ['oninfo', 'ontrailers', 'onwanttrailers']; }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -218,11 +223,13 @@ for (const callbackName of kNonConsumerCallbacks) { const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onerror = () => {}; }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, [callbackName]: mustNotCall(), }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index f4ffc223d4d5..3224d581277d 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -54,6 +54,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { serverDone.resolve(); }); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { // Send response headers. @@ -78,6 +79,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs b/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs index 78f78ac1d582..dadd150cfa97 100644 --- a/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs +++ b/test/parallel/test-quic-h3-uni-stream-limit-start-failure.mjs @@ -11,24 +11,27 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect, Http3Session } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); +// Server who allows no unidirectional streams const serverEndpoint = await listen(async (serverSession) => { await serverSession.closed; }, { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, // No uni streams allowed: transportParams: { initialMaxStreamsUni: 0 }, onheaders: mustNotCall(), }); -// Expect the client to cleanly fail - not crash the process +// Expect an autostart client to cleanly fail await assert.rejects(async () => { const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', }); @@ -36,3 +39,28 @@ await assert.rejects(async () => { }, { code: 'ERR_QUIC_TRANSPORT_ERROR' }); await serverEndpoint.close(); + +// Expect manual session start to cleanly fail: +{ + const endpoint = await listen(async (serverSession) => { + await serverSession.closed.catch(() => {}); + }, { + alpn: ['h3'], + sni: { '*': { keys: [key], certs: [cert] } }, + transportParams: { initialMaxStreamsUni: 0 }, + }); + const connection = await connect(endpoint.address, { + alpn: 'h3', + autoStart: false, + servername: 'localhost', + verifyPeer: 'manual', + }); + await connection.opened; + const failed = { + code: 'ERR_INVALID_STATE', + message: /could not be started/, + }; + assert.throws(() => Http3Session.start(connection), failed); + await assert.rejects(connection.closed, { code: 'ERR_QUIC_TRANSPORT_ERROR' }); + await endpoint.close(); +} diff --git a/test/parallel/test-quic-h3-uni-stream-teardown.mjs b/test/parallel/test-quic-h3-uni-stream-teardown.mjs deleted file mode 100644 index 6f93ef73eb2d..000000000000 --- a/test/parallel/test-quic-h3-uni-stream-teardown.mjs +++ /dev/null @@ -1,34 +0,0 @@ -// Flags: --experimental-quic --no-warnings - -// Regression test for https://github.com/nodejs/node/issues/65408. -// A client-created unidirectional stream is not a valid HTTP/3 request stream, -// but nghttp3 handles it internally. Destroying the endpoint after receiving -// data on that stream must not crash during process teardown. - -import { hasQuic, skip, mustNotCall } from '../common/index.mjs'; -import * as fixtures from '../common/fixtures.mjs'; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { createPrivateKey } = await import('node:crypto'); -const { listen, connect } = await import('node:quic'); - -const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); -const cert = fixtures.readKey('agent1-cert.pem'); - -const endpoint = await listen(mustNotCall(), { - sni: { '*': { keys: [key], certs: [cert] } }, -}); - -const session = await connect(endpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', -}); - -const stream = await session.createUnidirectionalStream(); -stream.writer.writeSync('x'); - -endpoint.destroy(); -await endpoint.closed; diff --git a/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs b/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs index f724375d9def..cc244f86e866 100644 --- a/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs @@ -20,12 +20,14 @@ const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); const serverEndpoint = await listen(mustNotCall(), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); // Bogus ticket data (random bytes) is rejected at the format level. await assert.rejects( connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', sessionTicket: randomBytes(256), diff --git a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs index 755bde188e0b..5541213d62cd 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -39,6 +39,7 @@ async function getTicket(endpointOptions) { ss.close(); }); }), { + alpn: ['h3'], sni, ...endpointOptions, onheaders: mustCall(function(headers) { @@ -49,6 +50,7 @@ async function getTicket(endpointOptions) { }); const cs = await connect(ep.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', ...endpointOptions, @@ -94,11 +96,13 @@ async function attemptRejected0RTT(endpointOptions, ticket, token) { const ep = await listen(mustCall(async (ss) => { await ss.closed; }), { + alpn: ['h3'], sni, ...endpointOptions, }); const cs = await connect(ep.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', ...endpointOptions, diff --git a/test/parallel/test-quic-h3-zero-rtt.mjs b/test/parallel/test-quic-h3-zero-rtt.mjs index f836caa1ec4f..65ded2fd4a00 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -44,6 +44,7 @@ const serverEndpoint = await listen(mustCall((ss) => { ss.close(); }); }, 2), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, onheaders: mustCall(function(headers) { this.sendHeaders({ ':status': '200' }); @@ -54,6 +55,7 @@ const serverEndpoint = await listen(mustCall((ss) => { // --- First connection: establish H3 session, receive ticket --- const cs1 = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', onsessionticket: mustCall(function(ticket) { @@ -96,6 +98,7 @@ assert.ok(savedToken); // --- Second connection: 0-RTT with H3 --- const cs2 = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', sessionTicket: savedTicket, diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs index 42cfa784263c..5c97fb7ad86c 100644 --- a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs +++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs @@ -17,6 +17,7 @@ const { getQuicEndpointState } = (await import('internal/quic/quic')).default; const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); const cert = fixtures.readKey('agent1-cert.pem'); const sni = { '*': { keys: [key], certs: [cert] } }; +const alpn = ['quic-test']; const endpoint = new QuicEndpoint(); const state = getQuicEndpointState(endpoint); @@ -26,7 +27,7 @@ assert.ok(!state.isListening); assert.strictEqual(endpoint.address, undefined); -await assert.rejects(listen(123, { sni, endpoint }), { +await assert.rejects(listen(123, { alpn, sni, endpoint }), { code: 'ERR_INVALID_ARG_TYPE', }); // Buffer is not detached. @@ -36,11 +37,11 @@ await assert.rejects(listen(mustNotCall(), 123), { code: 'ERR_INVALID_ARG_TYPE', }); -await listen(mustNotCall(), { sni, endpoint }); +await listen(mustNotCall(), { alpn, sni, endpoint }); // Buffer is not detached. assert.strictEqual(cert.buffer.detached, false); -await assert.rejects(listen(mustNotCall(), { sni, endpoint }), { +await assert.rejects(listen(mustNotCall(), { alpn, sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. @@ -64,7 +65,7 @@ assert.strictEqual(endpoint.closed, endpoint.close()); await endpoint.closed; assert.ok(endpoint.destroyed); -await assert.rejects(listen(mustNotCall(), { sni, endpoint }), { +await assert.rejects(listen(mustNotCall(), { alpn, sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index 2f4ce6ccc141..0843af71938c 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -9,11 +9,11 @@ if (!hasQuic) { const { QuicEndpoint } = await import('node:quic'); const { - QuicSessionState, + QuicConnectionState, QuicStreamState, } = (await import('internal/quic/state')).default; const { - QuicSessionStats, + QuicConnectionStats, QuicStreamStats, } = (await import('internal/quic/stats')).default; const { @@ -145,7 +145,7 @@ const { // temporarily while the rest of the functionality is being // implemented. const streamState = new QuicStreamState(kPrivateConstructor, new ArrayBuffer(1024)); -const sessionState = new QuicSessionState(kPrivateConstructor, new ArrayBuffer(1024)); +const sessionState = new QuicConnectionState(kPrivateConstructor, new ArrayBuffer(1024)); assert.strictEqual(streamState.pending, false); assert.strictEqual(streamState.finSent, false); @@ -184,7 +184,7 @@ assert.strictEqual(typeof inspect(streamState), 'string'); assert.strictEqual(typeof inspect(sessionState), 'string'); const streamStats = new QuicStreamStats(kPrivateConstructor, new ArrayBuffer(1024)); -const sessionStats = new QuicSessionStats(kPrivateConstructor, new ArrayBuffer(1024)); +const sessionStats = new QuicConnectionStats(kPrivateConstructor, new ArrayBuffer(1024)); assert.strictEqual(streamStats.createdAt, 0n); assert.strictEqual(streamStats.openedAt, 0n); assert.strictEqual(streamStats.receivedAt, 0n); diff --git a/test/parallel/test-quic-key-update-peer.mjs b/test/parallel/test-quic-key-update-peer.mjs index 1a0fed1a1672..58ce1aa47f84 100644 --- a/test/parallel/test-quic-key-update-peer.mjs +++ b/test/parallel/test-quic-key-update-peer.mjs @@ -21,7 +21,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { await serverSession.opened; // Server initiates key update. - serverSession.updateKey(); + serverSession.connection.updateKey(); serverSession.onstream = mustCall(async (stream) => { const data = await bytes(stream); diff --git a/test/parallel/test-quic-key-update.mjs b/test/parallel/test-quic-key-update.mjs index b01288e90f41..d128f07bc162 100644 --- a/test/parallel/test-quic-key-update.mjs +++ b/test/parallel/test-quic-key-update.mjs @@ -36,7 +36,7 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // Initiate key update before sending data. -clientSession.updateKey(); +clientSession.connection.updateKey(); // Open a stream and send data — should work with new keys. const stream = await clientSession.createBidirectionalStream(); diff --git a/test/parallel/test-quic-multipacket-clienthello.mjs b/test/parallel/test-quic-multipacket-clienthello.mjs index 2d701a899fc4..e101cee72a36 100644 --- a/test/parallel/test-quic-multipacket-clienthello.mjs +++ b/test/parallel/test-quic-multipacket-clienthello.mjs @@ -24,7 +24,7 @@ const streamReceived = Promise.withResolvers(); const endpoint = await listen(mustCall(async (session) => { const info = await session.opened; - assert.strictEqual(session.alpnProtocol, alpn); + assert.strictEqual(session.connection.alpnProtocol, alpn); assert.strictEqual(info.cipherVersion, 'TLSv1.3'); session.onstream = mustCall(async (stream) => { diff --git a/test/parallel/test-quic-perf-hooks.mjs b/test/parallel/test-quic-perf-hooks.mjs index fb9f90c5f472..322ec84b6146 100644 --- a/test/parallel/test-quic-perf-hooks.mjs +++ b/test/parallel/test-quic-perf-hooks.mjs @@ -63,11 +63,11 @@ obs.disconnect(); // Verify we got all expected entry types. const endpointEntries = entries.filter((e) => e.name === 'QuicEndpoint'); -const sessionEntries = entries.filter((e) => e.name === 'QuicSession'); +const sessionEntries = entries.filter((e) => e.name === 'QuicConnection'); const streamEntries = entries.filter((e) => e.name === 'QuicStream'); assert.ok(endpointEntries.length >= 1, `Expected QuicEndpoint entries, got ${endpointEntries.length}`); -assert.ok(sessionEntries.length >= 2, `Expected >= 2 QuicSession entries, got ${sessionEntries.length}`); +assert.ok(sessionEntries.length >= 2, `Expected >= 2 QuicConnection entries, got ${sessionEntries.length}`); assert.ok(streamEntries.length >= 2, `Expected >= 2 QuicStream entries, got ${streamEntries.length}`); // Verify common fields on all entries. diff --git a/test/parallel/test-quic-session-application-options.mjs b/test/parallel/test-quic-session-application-options.mjs index b119207bcc1f..5f98cc6a1d25 100644 --- a/test/parallel/test-quic-session-application-options.mjs +++ b/test/parallel/test-quic-session-application-options.mjs @@ -31,7 +31,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { // After the stream arrives, the handshake and ALPN negotiation are // complete, so applicationOptions should be available. - const opts = serverSession.applicationOptions; + const opts = serverSession.connection.applicationOptions; assert.ok(opts != null, 'server applicationOptions should be available after handshake'); assert.strictEqual(typeof opts, 'object'); @@ -68,7 +68,7 @@ await clientSession.opened; // After opened, ALPN negotiation is complete and applicationOptions // should be available on the client session. -const clientOpts = clientSession.applicationOptions; +const clientOpts = clientSession.connection.applicationOptions; assert.ok(clientOpts != null, 'client applicationOptions should be available after handshake'); assert.strictEqual(typeof clientOpts, 'object'); assert.strictEqual(Object.getPrototypeOf(clientOpts), null); @@ -98,6 +98,6 @@ await Promise.all([stream.closed, serverDone.promise]); // After close, applicationOptions should return null. await clientSession.close(); -assert.strictEqual(clientSession.applicationOptions, null); +assert.strictEqual(clientSession.connection.applicationOptions, null); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-destroy-reentrant.mjs b/test/parallel/test-quic-session-destroy-reentrant.mjs index e429987aab5b..ef9ffc7ed2b4 100644 --- a/test/parallel/test-quic-session-destroy-reentrant.mjs +++ b/test/parallel/test-quic-session-destroy-reentrant.mjs @@ -59,10 +59,10 @@ const transportParams = { maxIdleTimeout: 1 }; // `onerror` handler), the `#destroying` guard makes the second call // a true no-op so each channel publishes exactly once. const errSub = mustCall((msg) => { - assert.strictEqual(msg.session, clientSession); + assert.strictEqual(msg.session, clientSession.connection); }); const closedSub = mustCall((msg) => { - assert.strictEqual(msg.session, clientSession); + assert.strictEqual(msg.session, clientSession.connection); }); diagnostics_channel.subscribe('quic.session.error', errSub); diagnostics_channel.subscribe('quic.session.closed', closedSub); diff --git a/test/parallel/test-quic-session-emit-ordering.mjs b/test/parallel/test-quic-session-emit-ordering.mjs index 1b2d972ad7db..3a05331737f1 100644 --- a/test/parallel/test-quic-session-emit-ordering.mjs +++ b/test/parallel/test-quic-session-emit-ordering.mjs @@ -12,7 +12,7 @@ if (!hasQuic) { const { createRequire } = await import('node:module'); const require = createRequire(import.meta.url); -const { getQuicSessionState } = require('internal/quic/quic'); +const { getQuicConnectionState } = require('internal/quic/quic'); const { listen, connect } = await import('../common/quic.mjs'); const sessionSeen = Promise.withResolvers(); @@ -21,19 +21,19 @@ const serverEndpoint = await listen(mustCall((serverSession) => { // All assertions run synchronously in the onsession emit frame. // The TLS details from the ClientHello are readable on the session. - assert.strictEqual(serverSession.servername, 'localhost'); - assert.strictEqual(serverSession.alpnProtocol, 'quic-test'); + assert.strictEqual(serverSession.connection.servername, 'localhost'); + assert.strictEqual(serverSession.connection.alpnProtocol, 'quic-test'); // The client's transport params arrived in the first flight and have // been processed by the time the session is surfaced. - const params = serverSession.remoteTransportParams; + const params = serverSession.connection.remoteTransportParams; assert.notStrictEqual(params, undefined); assert.notStrictEqual(params, null); assert.ok(params.initialMaxStreamsBidi >= 0n); // ALPN negotiation has completed: headers support is resolved (2 = // unsupported, confirming non-h3 test ALPN) - assert.strictEqual(getQuicSessionState(serverSession).headersSupported, 2); + assert.strictEqual(getQuicConnectionState(serverSession.connection).headersSupported, 2); sessionSeen.resolve(); })); diff --git a/test/parallel/test-quic-session-preferred-address-ipv6.mjs b/test/parallel/test-quic-session-preferred-address-ipv6.mjs index 8c1d9c474ccf..1eb28f8933c0 100644 --- a/test/parallel/test-quic-session-preferred-address-ipv6.mjs +++ b/test/parallel/test-quic-session-preferred-address-ipv6.mjs @@ -85,7 +85,7 @@ const clientSession = await connect(serverEndpoint.address, { }, 4), onpathvalidation: mustCall((result, newLocal, newRemote, oldLocal, oldRemote, preferred) => { assert.strictEqual(result, 'success'); - assertEqualAddress(newLocal, clientSession.endpoint.address); + assertEqualAddress(newLocal, clientSession.connection.endpoint.address); assertEqualAddress(newRemote, preferredEndpoint.address); assert.strictEqual(oldLocal, null); assert.strictEqual(oldRemote, null); diff --git a/test/parallel/test-quic-session-preferred-address.mjs b/test/parallel/test-quic-session-preferred-address.mjs index 92194cf42a87..8086c88137c7 100644 --- a/test/parallel/test-quic-session-preferred-address.mjs +++ b/test/parallel/test-quic-session-preferred-address.mjs @@ -70,7 +70,7 @@ const clientSession = await connect(serverEndpoint.address, { }, 4), onpathvalidation: mustCall((result, newLocal, newRemote, oldLocal, oldRemote, preferred) => { assert.strictEqual(result, 'success'); - assertEqualAddress(newLocal, clientSession.endpoint.address); + assertEqualAddress(newLocal, clientSession.connection.endpoint.address); assertEqualAddress(newRemote, preferredEndpoint.address); assert.strictEqual(oldLocal, null); assert.strictEqual(oldRemote, null); diff --git a/test/parallel/test-quic-session-properties.mjs b/test/parallel/test-quic-session-properties.mjs index 8d757303e966..c640117a9610 100644 --- a/test/parallel/test-quic-session-properties.mjs +++ b/test/parallel/test-quic-session-properties.mjs @@ -35,16 +35,16 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { await serverSession.opened; // PATH-03/06: Server path has local and remote. - const path = serverSession.path; + const path = serverSession.connection.path; assert.ok(path); assert.ok(path.local); assert.ok(path.remote); // Cached. - assert.strictEqual(serverSession.path, path); + assert.strictEqual(serverSession.connection.path, path); // Own certificate. - const cert = serverSession.certificate; + const cert = serverSession.connection.certificate; assert.ok(cert instanceof X509Certificate); assert.strictEqual(cert.subject, expectedCert.subject); assert.strictEqual(cert.issuer, expectedCert.issuer); @@ -52,10 +52,10 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { // Peer certificate (client's cert — not set in this // test since we don't use verifyClient, so it's undefined). - assert.strictEqual(serverSession.peerCertificate, undefined); + assert.strictEqual(serverSession.connection.peerCertificate, undefined); // Cached. - assert.strictEqual(serverSession.certificate, cert); + assert.strictEqual(serverSession.connection.certificate, cert); await serverSession.close(); serverDone.resolve(); @@ -65,37 +65,37 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // PATH-03/06: Client path. -const path = clientSession.path; +const path = clientSession.connection.path; assert.ok(path); assert.ok(path.local); assert.ok(path.remote); // Cached. -assert.strictEqual(clientSession.path, path); +assert.strictEqual(clientSession.connection.path, path); // Peer certificate (server's cert). -const peerCert = clientSession.peerCertificate; +const peerCert = clientSession.connection.peerCertificate; assert.ok(peerCert instanceof X509Certificate); assert.strictEqual(peerCert.subject, expectedCert.subject); assert.strictEqual(peerCert.issuer, expectedCert.issuer); assert.strictEqual(peerCert.fingerprint256, expectedCert.fingerprint256); // Ephemeral key info (client only). -const keyInfo = clientSession.ephemeralKeyInfo; +const keyInfo = clientSession.connection.ephemeralKeyInfo; assert.ok(keyInfo); // Cached. -assert.strictEqual(clientSession.peerCertificate, peerCert); -assert.strictEqual(clientSession.ephemeralKeyInfo, keyInfo); +assert.strictEqual(clientSession.connection.peerCertificate, peerCert); +assert.strictEqual(clientSession.connection.ephemeralKeyInfo, keyInfo); await Promise.all([clientSession.closed, serverDone.promise]); // Returns undefined after destroy. -assert.strictEqual(clientSession.path, undefined); +assert.strictEqual(clientSession.connection.path, undefined); // Returns undefined after destroy. -assert.strictEqual(clientSession.certificate, undefined); -assert.strictEqual(clientSession.peerCertificate, undefined); -assert.strictEqual(clientSession.ephemeralKeyInfo, undefined); +assert.strictEqual(clientSession.connection.certificate, undefined); +assert.strictEqual(clientSession.connection.peerCertificate, undefined); +assert.strictEqual(clientSession.connection.ephemeralKeyInfo, undefined); await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-stream-lifecycle.mjs b/test/parallel/test-quic-session-stream-lifecycle.mjs index 3c24810bd9a6..b7073d3efc42 100644 --- a/test/parallel/test-quic-session-stream-lifecycle.mjs +++ b/test/parallel/test-quic-session-stream-lifecycle.mjs @@ -20,7 +20,7 @@ const serverDone = Promise.withResolvers(); // Create a server endpoint const serverEndpoint = await quic.listen(mustCall(async (serverSession) => { await serverSession.opened; - assert.ok(serverSession.endpoint !== null); + assert.ok(serverSession.connection.endpoint !== null); assert.strictEqual(serverSession.destroyed, false); const stats = serverSession.stats; @@ -31,7 +31,7 @@ const serverEndpoint = await quic.listen(mustCall(async (serverSession) => { serverDone.resolve(); serverSession.close(); -}), { sni: { '*': { keys, certs } } }); +}), { alpn: ['quic-test'], sni: { '*': { keys, certs } } }); assert.strictEqual(serverEndpoint.busy, false); assert.strictEqual(serverEndpoint.closing, false); @@ -50,16 +50,17 @@ assert.ok(epStats.createdAt > 0n); // Connect with a client const clientSession = await quic.connect(serverEndpoint.address, { + alpn: 'quic-test', verifyPeer: 'manual', }); assert.strictEqual(clientSession.destroyed, false); -assert.ok(clientSession.endpoint !== null); +assert.ok(clientSession.connection.endpoint !== null); assert.strictEqual(clientSession.stats.isConnected, true); const clientInfo = await clientSession.opened; assert.strictEqual(clientInfo.servername, 'localhost'); -assert.strictEqual(clientInfo.protocol, 'h3'); +assert.strictEqual(clientInfo.protocol, 'quic-test'); assert.strictEqual(clientInfo.cipherVersion, 'TLSv1.3'); assert.ok(clientInfo.local !== undefined); assert.ok(clientInfo.remote !== undefined); @@ -85,7 +86,7 @@ assert.strictEqual(stream.stats.isConnected, true); // Destroying the session should destroy it and the stream, and clear its properties. clientSession.destroy(); assert.strictEqual(clientSession.destroyed, true); -assert.strictEqual(clientSession.endpoint, null); +assert.strictEqual(clientSession.connection.endpoint, null); assert.strictEqual(clientSession.stats.isConnected, false); assert.strictEqual(typeof clientSession.stats.cwnd, 'bigint'); assert.strictEqual(typeof clientSession.stats.streamsIdleTimedOut, 'bigint'); diff --git a/test/parallel/test-quic-session-transport-params.mjs b/test/parallel/test-quic-session-transport-params.mjs index 6fd92419196f..a4f6b7e138cf 100644 --- a/test/parallel/test-quic-session-transport-params.mjs +++ b/test/parallel/test-quic-session-transport-params.mjs @@ -30,7 +30,7 @@ let serverRemoteParams; const serverEndpoint = await listen(mustCall((serverSession) => { // localTransportParams should be available immediately. - serverLocalParams = serverSession.localTransportParams; + serverLocalParams = serverSession.connection.localTransportParams; assert.ok(serverLocalParams != null, 'server localTransportParams should be available immediately'); assert.strictEqual(typeof serverLocalParams, 'object'); assert.strictEqual(Object.getPrototypeOf(serverLocalParams), null); @@ -53,7 +53,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { // After the stream arrives, the handshake is complete and // remoteTransportParams should be available. - serverRemoteParams = serverSession.remoteTransportParams; + serverRemoteParams = serverSession.connection.remoteTransportParams; assert.ok(serverRemoteParams != null, 'server remoteTransportParams should be available after handshake'); assert.strictEqual(typeof serverRemoteParams, 'object'); @@ -86,7 +86,7 @@ await clientSession.opened; // After opened, the handshake is complete. Both local and remote // transport params should be available on the client session. -const clientLocalParams = clientSession.localTransportParams; +const clientLocalParams = clientSession.connection.localTransportParams; assert.ok(clientLocalParams != null, 'client localTransportParams should be available'); assert.strictEqual(typeof clientLocalParams, 'object'); assert.strictEqual(Object.getPrototypeOf(clientLocalParams), null); @@ -97,7 +97,7 @@ assert.strictEqual(clientLocalParams.initialMaxStreamsBidi, assert.strictEqual(clientLocalParams.initialMaxData, BigInt(clientTransportParams.initialMaxData)); -const clientRemoteParams = clientSession.remoteTransportParams; +const clientRemoteParams = clientSession.connection.remoteTransportParams; assert.ok(clientRemoteParams != null, 'client remoteTransportParams should be available after handshake'); assert.strictEqual(typeof clientRemoteParams, 'object'); diff --git a/test/parallel/test-quic-sni-setcontexts.mjs b/test/parallel/test-quic-sni-setcontexts.mjs index 56200bd192ed..be30db5d9abe 100644 --- a/test/parallel/test-quic-sni-setcontexts.mjs +++ b/test/parallel/test-quic-sni-setcontexts.mjs @@ -48,7 +48,10 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { } endpoint.setSNIContexts( - { '*': { keys: [key2], certs: [cert2] } }, + { + '*': { keys: [key2], certs: [cert2] }, + 'localhost': { keys: [key2], certs: [cert2] }, + }, { replace: true }, ); diff --git a/test/parallel/test-quic-stats-tojson-inspect.mjs b/test/parallel/test-quic-stats-tojson-inspect.mjs index 560acda49172..b11ec1e3208a 100644 --- a/test/parallel/test-quic-stats-tojson-inspect.mjs +++ b/test/parallel/test-quic-stats-tojson-inspect.mjs @@ -24,7 +24,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { assert.strictEqual(typeof sessionStatsJson.bytesSent, 'string'); const sessionStatsInspect = inspect(serverSession.stats); - assert.ok(sessionStatsInspect.includes('QuicSessionStats')); + assert.ok(sessionStatsInspect.includes('QuicConnectionStats')); serverSession.onstream = mustCall(async (stream) => { for await (const _ of stream) { /* drain */ } // eslint-disable-line no-unused-vars @@ -52,7 +52,7 @@ assert.ok(clientStatsJson); assert.strictEqual(typeof clientStatsJson.createdAt, 'string'); const clientStatsInspect = inspect(clientSession.stats); -assert.ok(clientStatsInspect.includes('QuicSessionStats')); +assert.ok(clientStatsInspect.includes('QuicConnectionStats')); const stream = await clientSession.createBidirectionalStream({ body: new TextEncoder().encode('test'), diff --git a/test/parallel/test-quic-tls-verify-client.mjs b/test/parallel/test-quic-tls-verify-client.mjs index 46f0808c34a3..4a8d7c20aa3c 100644 --- a/test/parallel/test-quic-tls-verify-client.mjs +++ b/test/parallel/test-quic-tls-verify-client.mjs @@ -27,7 +27,7 @@ const clientCert = fixtures.readKey('agent2-cert.pem'); const serverEndpoint = await listen(mustCall(async (serverSession) => { await serverSession.opened; // The server should see the client's certificate. - assert.ok(serverSession.peerCertificate); + assert.ok(serverSession.connection.peerCertificate); await serverSession.close(); }), { sni: { '*': { keys: [serverKey], certs: [serverCert] } },