diff --git a/doc/api/http3.md b/doc/api/http3.md new file mode 100644 index 00000000000000..a856ffb7cf1103 --- /dev/null +++ b/doc/api/http3.md @@ -0,0 +1,729 @@ +# HTTP/3 + + + + + +> Stability: 1.0 - Early development + + + +The `node:http3` module provides an implementation of the HTTP/3 protocol on +top of the QUIC transport in [`node:quic`][]. To access it, start Node.js with +the `--experimental-quic` option and: + +```mjs +import http3 from 'node:http3'; +``` + +```cjs +const http3 = require('node:http3'); +``` + +The module is only available under the `node:` scheme. + +## Overview + +`node:http3` allows you to send & receive HTTP/3 requests and responses. It +builds on top of the QUIC transport from [`node:quic`][]. + +Each connection is an {Http3Session}, wrapping a [`QuicSession`][], and +each request/response exchange is an {Http3Stream}, wrapping a +[`QuicStream`][]. + +Servers call [`listen()`][] to create an endpoint, from which sessions will +be emitted for incoming connections. Clients call [`connect()`][] to create +a session with a remote server. + +Transport-level configuration (TLS certificates, SNI, address validation, +flow control, and so on) is identical to `node:quic`. QUIC options not +managed by `node:http3` can be passed straight through to the underlying +endpoint, session, or stream. + +HTTP/3 requests are always client-initiated. A server session receives request +streams via its `onstream` callback and cannot itself call `request()`. + +### Example + +Server: + +```mjs +import { listen } from 'node:http3'; + +const endpoint = await listen((session) => { + session.onstream = (stream) => { + stream.onheaders = (headers) => { + // headers[':method'], headers[':path'], headers[':authority'] ... + stream.sendHeaders({ ':status': '200', 'content-type': 'text/plain' }); + const w = stream.writer; + w.writeSync('hello h3'); + w.endSync(); + }; + }; +}, { + sni: { '*': { keys: [key], certs: [cert] } }, +}); +``` + +Client: + +```mjs +import { connect } from 'node:http3'; +import { bytes } from 'stream/iter'; + +const session = await connect(endpoint.address, { servername: 'localhost' }); +await session.opened; + +const stream = await session.request({ + ':method': 'GET', + ':path': '/hello', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: (headers) => { /* headers[':status'] === '200' */ }, +}); + +const body = new TextDecoder().decode(await bytes(stream)); +// body === 'hello h3' + +await session.close(); +``` + +## `http3.connect(address[, options])` + + + +* `address` {string|net.SocketAddress} The server address. See [`quic.connect()`][]. +* `options` {Object} HTTP/3 options. Other [`quic.connect()`][] session options + may also be provided and will be passed through to the underlying QUIC session. + * `settings` {Http3Settings} Local HTTP/3 SETTINGS to advertise to the peer. + * `ongoaway` {http3.OnGoawayCallback} The session's initial `ongoaway` callback. + * `onorigin` {http3.OnOriginCallback} The session's initial `onorigin` callback. + * `onsettings` {http3.OnSettingsCallback} The session's initial `onsettings` callback. +* Returns: {Promise} Fulfilled with an {Http3Session}. + +Opens a client connection to an HTTP/3 server. The ALPN identifier is fixed to +`h3`. Await [`session.opened`][] before sending requests: the TLS handshake — +including certificate validation — completes asynchronously, and any handshake +or connection failure is reported through `opened` rather than by `connect()`. + +## `http3.listen(onsession[, options])` + + + +* `onsession` {http3.OnSessionCallback} Invoked with each new {Http3Session}. +* `options` {Object} The same HTTP/3 options as [`connect()`][], plus any + [`quic.listen()`][] option (for example `sni`), passed through to the + endpoint. +* Returns: {Promise} Fulfilled with the listening {quic.QuicEndpoint}. + +Creates a server endpoint that negotiates the `h3` ALPN identifier and invokes +`onsession` for each incoming connection. + +## Class: `Http3Session` + + + +An `Http3Session` wraps a {quic.QuicSession} with HTTP/3 semantics. Servers receive +one through the [`listen()`][] callback; clients create one with [`connect()`][]. +Alternatively, you can call the constructor directly on a QuicSession if it's not +already active. + +### `new Http3Session(session[, callbacks])` + + + +* `session` {quic.QuicSession} The raw transport session to wrap. +* `callbacks` {Object} + * `ongoaway` {http3.OnGoawayCallback} The initial `ongoaway` callback. + * `onorigin` {http3.OnOriginCallback} The initial `onorigin` callback. + * `onsettings` {http3.OnSettingsCallback} The initial `onsettings` callback. + +Attaches HTTP/3 to an existing `node:quic` session. + +The HTTP/3 session must be attached before the QUIC session becomes active and +begins emitting events: for servers that means synchronously inside a server's +`onsession` callback, or for clients before the handshake completes. Throws +`ERR_INVALID_STATE` if the QUIC session is already attached or active. + +### `session.request(headers[, options])` + + + +* `headers` {Object} The request pseudo-headers (`:method`, `:path`, + `:scheme`, `:authority`) and regular headers. When omitted, headers can be + sent later with `sendHeaders()`. +* `options` {Object} Any {quic.QuicStream} option (for example `body`) may also be + given and is passed through. + * `priority` {string} Initial priority level: `'high'`, `'default'`, or + `'low'`. **Default:** `'default'`. + * `incremental` {boolean} Whether the stream may be served incrementally. + **Default:** `false`. + * `onheaders` {http3.OnHeadersCallback} The new stream's `onheaders` callback. + * `oninfo` {http3.OnInfoCallback} The new stream's `oninfo` callback. + * `ontrailers` {http3.OnTrailersCallback} The new stream's `ontrailers` callback. + * `onwanttrailers` {http3.OnWantTrailersCallback} The new stream's `onwanttrailers` + callback. + * `onreset` {http3.OnResetCallback} The new stream's `onreset` callback. + * `onerror` {http3.OnErrorCallback} The new stream's `onerror` callback. +* Returns: {Promise} Fulfilled with an {Http3Stream}. + +Opens a request stream. Client only — throws `ERR_INVALID_STATE` on a server +session. The per-stream callbacks are passed here so they are attached before +any event can be delivered on the new stream. + +### `session.close([options])` + + + +* `options` {Object} +* Returns: {Promise} + +Gracefully closes the session, delegating to the underlying QUIC session. + +### `session.destroy([error[, options]])` + + + +* `error` {Error} +* `options` {Object} + +Immediately destroys the session and all of its streams. + +### `session.settings` + + + +* Type: {Http3Settings|null} + +The effective HTTP/3 settings for the session: the locally configured values +plus any updates received in the peer's SETTINGS frame. Returns `null` once the +session is destroyed. Read only. + +### `session.opened` + + + +* Type: {Promise} + +Resolves with the handshake details once the TLS handshake completes, or +rejects if the session fails to open. Read only. + +### `session.closed` + + + +* Type: {Promise} + +Resolves when the session has fully closed, or rejects if it ends with an +error. Read only. + +### `session.servername` + + + +* Type: {string|undefined} + +The SNI servername of the session, or `undefined` if none was negotiated. +Read only. + +### `session.alpnProtocol` + + + +* Type: {string|undefined} + +The negotiated ALPN protocol (`'h3'`), or `undefined` before negotiation +completes. Read only. + +### `session.session` + + + +* Type: {quic.QuicSession} + +The underlying transport session. Read only. + +### `session.onstream` + + + +* Type: {http3.OnStreamCallback} + +Server callback invoked with each incoming request stream. Read/write. + +### `session.ongoaway` + + + +* Type: {http3.OnGoawayCallback} + +Invoked when the peer sends an HTTP/3 GOAWAY frame. Read/write. + +### `session.onorigin` + + + +* Type: {http3.OnOriginCallback} + +Invoked when the peer sends an HTTP/3 ORIGIN frame (RFC 9412). Read/write. + +### `session.onsettings` + + + +* Type: {http3.OnSettingsCallback} + +Invoked when the peer's SETTINGS frame is received, which may be after the +session opens. The current values are also available synchronously via +`session.settings`. Read/write. + +### `session.onerror` + + + +* Type: {http3.OnErrorCallback} + +Invoked when the session encounters an error. Read/write. + +## Class: `Http3Stream` + + + +An `Http3Stream` represents a single HTTP/3 request/response exchange, wrapping +a {quic.QuicStream}. It is async-iterable: `for await (const chunk of stream)` (or +the `stream/iter` `bytes()` helper) reads the inbound body. + +### `stream.sendHeaders(headers[, options])` + + + +* `headers` {Object} The header block to send — a server's response headers or + a client's request headers. +* `options` {Object} + * `terminal` {boolean} When `true`, the header block is the final frame and + no body follows. **Default:** `false`. +* Returns: {boolean} `true` if the headers were scheduled to be sent. + +Sends the initial request or response header block. + +### `stream.sendInformationalHeaders(headers)` + + + +* `headers` {Object} +* Returns: {boolean} `true` if the headers were scheduled to be sent. + +Sends an informational (1xx) response header block. Server only. + +### `stream.sendTrailers(headers)` + + + +* `headers` {Object} +* Returns: {boolean} `true` if the trailers were scheduled to be sent. + +Sends a trailing header block. Must be called synchronously from the +`onwanttrailers` callback. + +### `stream.setPriority([options])` + + + +* `options` {Object} + * `level` {string} `'high'`, `'default'`, or `'low'`. **Default:** + `'default'`. + * `incremental` {boolean} Whether the stream may be served incrementally. + **Default:** `false`. + +Updates the stream priority. Before the request is submitted the priority is +carried in the initial header block; afterwards it is signaled with a +PRIORITY\_UPDATE frame. + +### `stream.stopSending([code])` + + + +* `code` {bigint|number} The application error code sent to the peer. + **Default:** `0n`. + +Asks the peer to stop sending data on this stream, delegating to the underlying +QUIC stream. + +### `stream.resetStream([code])` + + + +* `code` {bigint|number} The application error code sent to the peer. + **Default:** `0n`. + +Tells the peer this end will send no more data on this stream, delegating to +the underlying QUIC stream. + +### `stream.destroy([error[, options]])` + + + +* `error` {Error} +* `options` {Object} + +Immediately destroys the stream. + +### `stream[Symbol.asyncIterator]()` + + + +Iterates the inbound body bytes, ending at the stream FIN. Equivalent to +iterating the underlying [`QuicStream`][]. + +### `stream.writer` + + + +* Type: {QuicStreamWriter} + +The outbound body writer (`write`, `writeSync`, `end`, `endSync`). Read only. + +### `stream.headers` + + + +* Type: {Object} + +The most recently received header block, or `undefined` before any headers +arrive. Read only. + +### `stream.priority` + + + +* Type: {Object|null} + +The stream's priority as `{ level, incremental }` — on a client the requested +value, on a server the peer's requested priority. `null` once the stream is +destroyed. Read only. + +### `stream.id` + + + +* Type: {bigint} + +The QUIC stream id. Read only. + +### `stream.direction` + + + +* Type: {string} + +`'bidi'` or `'uni'`. Read only. + +### `stream.early` + + + +* Type: {boolean} + +`true` if the stream's data arrived as 0-RTT early data. Read only. + +### `stream.closed` + + + +* Type: {Promise} + +Resolves when the stream has closed. Read only. + +### `stream.destroyed` + + + +* Type: {boolean} + +`true` once the stream has been destroyed. Read only. + +### `stream.session` + + + +* Type: {Http3Session} + +The session that owns this stream. Read only. + +### `stream.onheaders` + + + +* Type: {http3.OnHeadersCallback} + +Invoked when the initial response/request header block is received. Read/write. + +### `stream.oninfo` + + + +* Type: {http3.OnInfoCallback} + +Invoked when an informational (1xx) header block is received. Read/write. + +### `stream.ontrailers` + + + +* Type: {http3.OnTrailersCallback} + +Invoked when a trailing header block is received. Read/write. + +### `stream.onwanttrailers` + + + +* Type: {http3.OnWantTrailersCallback} + +Invoked when trailers may be sent. Setting it keeps the stream open after the +body; send trailers synchronously with `sendTrailers()`. Read/write. + +### `stream.onreset` + + + +* Type: {http3.OnResetCallback} + +Invoked when the peer aborts the stream with a `RESET_STREAM` frame. Read/write. + +### `stream.onerror` + + + +* Type: {http3.OnErrorCallback} + +Invoked when the stream encounters an error. Read/write. + +## Types + +### Type: `Http3Settings` + + + +* `maxFieldSectionSize` {number} Maximum size, in bytes, of a header field + section the endpoint will accept. +* `qpackMaxDtableCapacity` {number} Maximum QPACK dynamic table capacity. + **Default:** `4096`. +* `qpackEncoderMaxDtableCapacity` {number} Maximum QPACK encoder dynamic table + capacity. **Default:** `4096`. +* `qpackBlockedStreams` {number} Maximum number of streams that may be blocked + waiting for QPACK state. **Default:** `100`. +* `enableConnectProtocol` {boolean} Whether the Extended CONNECT protocol + (RFC 9220) is enabled. **Default:** `true`. + +The HTTP/3 SETTINGS for a session (RFC 9114). Advertised to the peer via the +`settings` option, and read back — merged with the peer's values — through +`session.settings`. + +## Callbacks + +### Callback: `OnSessionCallback` + + + +* `session` {Http3Session} + +Invoked by [`listen()`][] for each new server session. + +### Callback: `OnStreamCallback` + + + +* `stream` {Http3Stream} + +Invoked with each incoming request stream (server side). + +### Callback: `OnGoawayCallback` + + + +* `lastStreamId` {bigint} + +Invoked when the peer sends an HTTP/3 GOAWAY frame. + +### Callback: `OnOriginCallback` + + + +* `origins` {string\[]} + +Invoked when the peer sends an HTTP/3 ORIGIN frame (RFC 9412). + +### Callback: `OnSettingsCallback` + + + +* `settings` {Http3Settings} + +Invoked when the peer's SETTINGS frame is received. + +### Callback: `OnHeadersCallback` + + + +* `headers` {Object} + +Invoked when a response or request header block is received. + +### Callback: `OnInfoCallback` + + + +* `headers` {Object} + +Invoked when an informational (1xx) header block is received. + +### Callback: `OnTrailersCallback` + + + +* `headers` {Object} + +Invoked when a trailing header block is received. + +### Callback: `OnWantTrailersCallback` + + + +Invoked with no arguments when trailers may be sent. + +### Callback: `OnResetCallback` + + + +* `error` {Error} + +Invoked when the peer aborts the stream with a `RESET_STREAM` frame. + +### Callback: `OnErrorCallback` + + + +* `error` {Error} + +Invoked when the session or stream encounters an error. + +[`QuicSession`]: quic.md#class-quicsession +[`QuicStream`]: quic.md#class-quicstream +[`connect()`]: #http3connectaddress-options +[`listen()`]: #http3listenonsession-options +[`node:quic`]: quic.md +[`quic.connect()`]: quic.md#quicconnectaddress-options +[`quic.listen()`]: quic.md#quiclistenonsession-options +[`session.opened`]: #sessionopened diff --git a/doc/api/quic.md b/doc/api/quic.md index ce0307514508af..2dbc3f2ca73760 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -235,24 +235,26 @@ 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. - -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. - -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 -API. +All QUIC sessions are associated with a single application protocol, negotiated +via ALPN during the TLS handshake. The `quic` module is an application-agnostic +transport on which application protocols can be implemented. + +HTTP/3 is provided separately by the `node:http3` module, which provides HTTP/3 +features on top of a `QuicSession`. Other application protocols can be +implemented with their own message framing and multiplexing on top of the core +QUIC transport features. + +To select a protocol, when connecting via QUIC, the client will send a list of +supported protocols via ALPN. The server selects one of these protocols, if it +accepts one, and confirms that to the client to complete connection setup. +The negotiated protocol is exposed as `session.alpnProtocol`, which can be used +to select the right application protocol to use: either your own implementation, +or activating the built-in HTTP/3 protocol on the session with +`new Http3Session(quicSession)`. + +Alternatively, if you use the `node:http3` module directly, the server or client +will be preconfigured for HTTP/3 application protocol, and will automatically +handle this for you. ### Configuration @@ -340,11 +342,9 @@ compatible with `node:stream/iter` utilities such as `Stream.bytes()`, In addition to streams, QUIC supports unreliable datagrams ([RFC 9221][]) for use cases that require low-latency, best-effort messaging. -Datagram support is enabled at two levels. At the QUIC transport level, both -peers must advertise a non-zero [`maxDatagramFrameSize`][] transport parameter -during the handshake. For HTTP/3 sessions, both peers must additionally set -[`application.enableDatagrams`][] to `true`, which exchanges the -`SETTINGS_H3_DATAGRAM` setting on the HTTP/3 control stream. +Datagram support is enabled at the QUIC transport level: both peers must +advertise a non-zero [`maxDatagramFrameSize`][] transport parameter during the +handshake. A datagram is sent with a single call to [`session.sendDatagram()`][]. Each datagram must fit within a single QUIC packet — datagrams cannot be @@ -404,8 +404,9 @@ A typical client session progresses through these stages: (also available as `session.closed`) resolves when teardown is complete. On the server side, call [`quic.listen()`][] with a callback. The callback -fires for each incoming session after the TLS handshake begins. Incoming -streams arrive via the [`session.onstream`][] callback. +fires for each incoming session, once the TLS handshake client hello has +been processed. Incoming streams arrive via the [`session.onstream`][] callback. +`session.opened` can be used to wait for the TLS handshake to fully complete. [`session.destroy()`][] is available for immediate teardown — all open streams are destroyed and the session is closed without waiting for them to finish. @@ -923,20 +924,6 @@ added: v23.8.0 A `QuicSession` represents the local side of a QUIC connection. -### `session.applicationOptions` - - - -* Type: {quic.ApplicationOptions} - -The current application-level options for this session. These include settings -that are specific to the negotiated application protocol (e.g. HTTP/3) and may -be negotiated separately from the transport parameters. Read only. -You can use the callback [`session.onapplication`][] to be informed, when settings -from the remote arrive. - ### `session.close([options])` - -* Type: {quic.OnApplicationCallback} - -The callback to invoke when new application options, e.g. HTTP/3 settings arrived. - ### `session.onerror` - -* 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. - -### `session.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, `session.createBidirectionalStream()` will -throw `ERR_INVALID_STATE`. Existing streams continue until they -complete or the session closes. - -This callback is only relevant for HTTP/3 sessions. Read/write. - ### `session.onkeylog` - -* Type: {Object|undefined} - -The buffered initial headers received on this stream, or `undefined` if the -application does not support headers or no headers have been received yet. -For server-side streams, this contains the request headers (e.g., `:method`, -`:path`, `:scheme`). For client-side streams, this contains the response -headers (e.g., `:status`). - -Header names are lowercase strings. Multi-value headers are represented as -arrays. The object has `__proto__: null`. - -### `stream.onheaders` - - - -* Type: {Function} - -The callback to invoke when initial headers are received on the stream. The -callback receives `(headers)` where `headers` is an object (same format as -`stream.headers`). For HTTP/3, this delivers request pseudo-headers on the -server side and response headers on the client side. Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.ontrailers` - - - -* Type: {Function} - -The callback to invoke when trailing headers are received from the peer. -The callback receives `(trailers)` where `trailers` is an object in the -same format as `stream.headers`. Throws `ERR_INVALID_STATE` if set on a -session that does not support headers. Read/write. - -### `stream.oninfo` - - - -* Type: {Function} - -The callback to invoke when informational (1xx) headers are received from -the server. The callback receives `(headers)` where `headers` is an object -in the same format as `stream.headers`. Informational headers are sent -before the final response (e.g., 103 Early Hints). Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.onwanttrailers` - - - -* Type: {Function} - -The callback to invoke when the application is ready for trailing headers -to be sent. This is called synchronously — the user must call -[`stream.sendTrailers()`][] within this callback. Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.pendingTrailers` - - - -* Type: {Object|undefined} - -Set trailing headers to be sent automatically when the application requests -them. This is an alternative to the [`stream.onwanttrailers`][] callback -for cases where the trailers are known before the body completes. Throws -`ERR_INVALID_STATE` if set on a session that does not support headers. -Read/write. - -### `stream.sendHeaders(headers[, options])` - - - -* `headers` {Object} Header object with string keys and string or - string-array values. Pseudo-headers (`:method`, `:path`, etc.) must - appear before regular headers. -* `options` {Object} - * `terminal` {boolean} If `true`, the stream is closed for sending - after the headers (no body will follow). **Default:** `false`. -* Returns: {boolean} - -Sends initial or response headers on the stream. For client-side streams, -this sends request headers. For server-side streams, this sends response -headers. Throws `ERR_INVALID_STATE` if the session does not support headers. - -### `stream.sendInformationalHeaders(headers)` - - - -* `headers` {Object} Header object. Must include `:status` with a 1xx - value (e.g., `{ ':status': '103', 'link': '; rel=preload' }`). -* Returns: {boolean} - -Sends informational (1xx) response headers. Server only. Throws -`ERR_INVALID_STATE` if the session does not support headers. - -### `stream.sendTrailers(headers)` - - - -* `headers` {Object} Trailing header object. Pseudo-headers must not be - included in trailers. -* Returns: {boolean} - -Sends trailing headers on the stream. Must be called synchronously during -the [`stream.onwanttrailers`][] callback, or set ahead of time via -[`stream.pendingTrailers`][]. Throws `ERR_INVALID_STATE` if the session -does not support headers. - -### `stream.priority` - - - -* Type: {Object|null} - * `level` {string} One of `'high'`, `'default'`, or `'low'`. - * `incremental` {boolean} Whether the stream data should be interleaved - with other streams of the same priority level. - -The current priority of the stream. Returns `null` if the session does not -support priority (e.g. non-HTTP/3) or if the stream has been destroyed. -Read only. Use [`stream.setPriority()`][] to change the priority. - -On client-side HTTP/3 sessions, the value reflects what was set via -[`stream.setPriority()`][]. On server-side HTTP/3 sessions, the value -reflects the peer's requested priority (e.g., from `PRIORITY_UPDATE` frames). - -### `stream.setPriority([options])` - - - -* `options` {Object} - * `level` {string} The priority level. One of `'high'`, `'default'`, or - `'low'`. **Default:** `'default'`. - * `incremental` {boolean} When `true`, data from this stream may be - interleaved with data from other streams of the same priority level. - **Default:** `false`. - -Sets the priority of the stream. Throws `ERR_INVALID_STATE` if the session -does not support priority (e.g. non-HTTP/3). Has no effect if the stream -has been destroyed. - ### `stream[Symbol.asyncIterator]()` - -* Type: {Object} - -The application specific options. - -#### `applicationOptions.maxHeaderPairs` - -* Type: {bigint|number} - -Maximum number of header name-value pairs accepted per header block. -Headers beyond this limit are silently dropped. **Default:** `128` - -#### `applicationOptions.maxHeaderLength` - -* Type: {bigint|number} - -Maximum total byte length of all header names and values combined per header -block. Headers that would push the total over this limit are silently -dropped. **Default:** `8192` - -#### `applicationOptions.maxFieldSectionSize` - -* Type: {bigint|number} - -Maximum size of a compressed header field section (QPACK). `0` means -unlimited. **Default:** `0` - -#### `applicationOptions.qpackMaxDTableCapacity` - -* Type: {bigint|number} - -QPACK dynamic table capacity in bytes. Set to `0` to disable the dynamic -table. **Default:** `4096` - -#### `applicationOptions.qpackEncoderMaxDTableCapacity` - -* Type: {bigint|number} - -QPACK encoder maximum dynamic table capacity. **Default:** `4096` - -#### `applicationOptions.qpackBlockedStreams` - -* Type: {bigint|number} - -Maximum number of streams that can e blocked waiting for QPACK dynamic table -updates. **Default:** `100` - -#### `applicationOptions.enableConnectProtocol` - -* Type: {boolean} - -Enable the extended CONNECT protocol (RFC 9220). **Default:** `false` - -#### `applicationOptions.enableDatagrams` - -* Type: {boolean} - -Enable HTTP/3 datagrams (RFC 9297). **Default:** `false` - ### Type: `EndpointOptions` - -* Type: {quic.ApplicationOptions} - -Application-specific options. - -```mjs -const { listen } = await import('node:quic'); - -await listen((session) => { /* ... */ }, { - application: { - maxHeaderPairs: 64, - qpackMaxDTableCapacity: 8192, - enableDatagrams: true, - }, - // ... other session options -}); -``` +The negotiated identifier is exposed as `session.alpnProtocol`. This can be +read to confirm which application protocol to use in the session. See the +Applications section for more information about enabling HTTP/3 when the +`h3` ALPN protocol is configured. #### `sessionOptions.ca` (client only) @@ -3535,13 +3178,11 @@ functions. If a callback throws synchronously or returns a promise that rejects, the error is caught and the owning session or stream is destroyed with that error: -* Stream callbacks (`onblocked`, `onreset`, `onheaders`, `ontrailers`, - `oninfo`, `onwanttrailers`): the stream is destroyed. -* Session callbacks (`onapplication`, `onstream`, `ondatagram`, - `ondatagramstatus`, `onpathvalidation`, `onsessionticket`, - `onnewtoken`, `onversionnegotiation`, `onorigin`, `ongoaway`, - `onhandshake`, `onkeylog`, `onqlog`): the session is destroyed along - with all of its streams. +* Stream callbacks (`onblocked`, `onreset`): the stream is destroyed. +* Session callbacks (`onstream`, `ondatagram`, `ondatagramstatus`, + `onpathvalidation`, `onsessionticket`, `onnewtoken`, + `onversionnegotiation`, `onhandshake`, `onkeylog`, `onqlog`): the session + is destroyed along with all of its streams. Before destruction, the optional [`session.onerror`][] or [`stream.onerror`][] callback is invoked (if set), giving the application a @@ -3595,19 +3236,6 @@ added: v23.8.0 datagram was never sent on the wire (dropped due to queue overflow, send attempt limit exceeded, or frame size rejection). -### Callback: `OnApplicationCallback` - - - -* `this` {quic.QuicSession} -* `applicationoption` {quic.QuicSession} - -The callback function that is invoked when application options change. -E.g. for http/3 settings are included in applications options and -may arrive after the connection is established. - ### Callback: `OnPathValidationCallback` - -* `this` {quic.QuicSession} -* `origins` {string\[]} The list of origins the server is authoritative for. - ### Callback: `OnKeylogCallback` - -* `this` {quic.QuicStream} -* `headers` {Object} Header object with lowercase string keys and - string or string-array values. - -Called when initial request or response headers are received. For HTTP/3, -this delivers request pseudo-headers on the server and response headers -on the client. - -### Callback: `OnTrailersCallback` - - - -* `this` {quic.QuicStream} -* `trailers` {Object} Trailing header object. - -Called when trailing headers are received from the peer. - -### Callback: `OnInfoCallback` - - - -* `this` {quic.QuicStream} -* `headers` {Object} Informational header object. - -Called when informational (1xx) headers are received from the server -(e.g., 103 Early Hints). - -## HTTP/3 support - - - -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. - -Selecting the HTTP/3 application enables a number of stream- and -session-level capabilities that are not available to non-HTTP/3 -applications: - -* **Headers and trailers** — request and response header blocks - (including pseudo-headers such as `:method`, `:path`, `:scheme`, - `:authority`, and `:status`), trailing headers, and informational - (`1xx`) responses. See [`stream.sendHeaders()`][], - [`stream.sendTrailers()`][], and - [`stream.sendInformationalHeaders()`][]. -* **Stream priority (RFC 9218)** — per-stream urgency and - incremental flags. See [`stream.priority`][] and - [`stream.setPriority()`][]. -* **HTTP/3 datagrams (RFC 9297)** — unreliable application-layer - datagrams. The peer must advertise `SETTINGS_H3_DATAGRAM=1`, which - is enabled by setting [`application.enableDatagrams`][] to `true` - on both peers. See [`session.sendDatagram()`][] and - [`session.ondatagram`][]. -* **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`][]. -* **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. -* **Extended CONNECT settings (RFC 9220)** — the - `SETTINGS_ENABLE_CONNECT_PROTOCOL` setting can be enabled via - [`application.enableConnectProtocol`][]. 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. - -### Minimal HTTP/3 client - -```mjs -import { connect } from 'node:quic'; -import process from 'node:process'; - -const session = await connect('example.com:443', { - // ALPN defaults to 'h3'. - servername: 'example.com', -}); -await session.opened; - -const stream = await session.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'example.com', - }, - onheaders(headers) { - console.log('status:', headers[':status']); - }, -}); - -const decoder = new TextDecoder(); -for await (const chunks of stream) { - for (const chunk of chunks) { - process.stdout.write(decoder.decode(chunk, { stream: true })); - } -} - -await session.close(); -``` - -A few things to note: - -* `session.createBidirectionalStream({ headers })` automatically - marks the HEADERS frame as terminal when no `body` is provided — - the request is `HEADERS` followed by `END_STREAM`. -* The `onheaders` callback receives the response pseudo-headers and - regular headers in a single object with lowercase string keys. - After the callback returns, the same object is also accessible - via [`stream.headers`][]. -* Reading `for await (const chunks of stream)` consumes the response - body. Each iteration yields a `Uint8Array[]` batch of chunks. -* HTTP semantic helpers (URL parsing, method/status validation, - redirects, content negotiation, and so on) are intentionally not - built in. The caller is responsible for any HTTP-level handling - beyond the wire framing. - -### Minimal HTTP/3 server - -```mjs -import { listen } from 'node:quic'; - -const encoder = new TextEncoder(); - -const endpoint = await listen((session) => { - // The session.onstream callback fires for each new client-initiated stream. -}, { - 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`, - // `:authority`). - if (headers[':path'] === '/health') { - this.sendHeaders({ ':status': '200', 'content-type': 'text/plain' }); - const w = this.writer; - w.writeSync(encoder.encode('ok\n')); - w.endSync(); - } else { - this.sendHeaders({ ':status': '404' }, { terminal: true }); - } - }, -}); - -console.log('listening on', endpoint.address); -``` - -Server-side notes: - -* Setting `onheaders` at the [`listen()`][`quic.listen()`] level - applies it to every incoming stream (it is wired up before - `onstream` fires). Setting it inside `onstream` is too late for - HTTP/3, where the request HEADERS frame is the first thing that - arrives on the stream. -* `this.sendHeaders(headers, { terminal: true })` marks the - response HEADERS frame as terminal (no body follows). -* For body responses, send headers first, then write to - `this.writer` and call `endSync()` to send the body and close the - stream cleanly. - -### What is not implemented - -* **Server push** — `PUSH_PROMISE` and the related push-stream - machinery are not implemented and are not on the near-term - roadmap. Server push has limited deployment in practice, and most - use cases are better served by Early Hints (`103`) or by direct - fetches from the client. -* **WebTransport / extended-CONNECT helpers** — the - `SETTINGS_ENABLE_CONNECT_PROTOCOL` setting can be negotiated but - there is no built-in support for the `:protocol` pseudo-header, - WebTransport datagram demultiplexing, or capsule framing. -* **Higher-level HTTP semantics** — there is no built-in - request/response router, URL parsing, content-encoding - negotiation, body-type coercion, redirect following, or - cookie handling. These are deliberately left to higher-level - libraries built on top of `node:quic`. - ## Performance measurement -* `applicationoptions` {quic.ApplicationOptions} Current application options. +* `applicationoptions` {Object} Current application options. * `session` {quic.QuicSession} -Published when a locally-initiated stream is opened. +Published when the peer's application settings are received (for HTTP/3, its +SETTINGS frame). ### Channel: `quic.session.created.client` @@ -4447,9 +3875,6 @@ throughput issues caused by flow control. [`PerformanceObserver`]: perf_hooks.md#class-performanceobserver [`QuicEndpoint`]: #class-quicendpoint [`QuicError`]: #class-quicerror -[`application.enableConnectProtocol`]: #sessionoptionsapplication -[`application.enableDatagrams`]: #sessionoptionsapplication -[`application.qpackMaxDTableCapacity`]: #sessionoptionsapplication [`endpoint.busy`]: #endpointbusy [`endpoint.maxConnectionsPerHost`]: #endpointmaxconnectionsperhost [`endpoint.maxConnectionsTotal`]: #endpointmaxconnectionstotal @@ -4476,15 +3901,11 @@ throughput issues caused by flow control. [`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 @@ -4499,16 +3920,8 @@ throughput issues caused by flow control. [`sessionOptions.sni`]: #sessionoptionssni-server-only [`sessionOptions.token`]: #sessionoptionstoken-client-only [`stream.destroy()`]: #streamdestroyerror-options -[`stream.headers`]: #streamheaders [`stream.onerror`]: #streamonerror -[`stream.onwanttrailers`]: #streamonwanttrailers -[`stream.pendingTrailers`]: #streampendingtrailers -[`stream.priority`]: #streampriority -[`stream.sendHeaders()`]: #streamsendheadersheaders-options -[`stream.sendInformationalHeaders()`]: #streamsendinformationalheadersheaders -[`stream.sendTrailers()`]: #streamsendtrailersheaders [`stream.setBody()`]: #streamsetbodybody -[`stream.setPriority()`]: #streamsetpriorityoptions [`stream.writer`]: #streamwriter [`writer.fail()`]: #streamwriter [`writer.fail(reason)`]: #streamwriter diff --git a/doc/type-map.json b/doc/type-map.json index 4741264f60e46f..438bc0fb617721 100644 --- a/doc/type-map.json +++ b/doc/type-map.json @@ -123,5 +123,19 @@ "zlib options": "zlib.html#class-options", "zstd options": "zlib.html#class-zstdoptions", "HTTP/2 Headers Object": "http2.html#headers-object", - "HTTP/2 Settings Object": "http2.html#settings-object" + "HTTP/2 Settings Object": "http2.html#settings-object", + "Http3Session": "http3.html#class-http3session", + "Http3Stream": "http3.html#class-http3stream", + "Http3Settings": "http3.html#type-http3settings", + "http3.OnSessionCallback": "http3.html#callback-onsessioncallback", + "http3.OnStreamCallback": "http3.html#callback-onstreamcallback", + "http3.OnGoawayCallback": "http3.html#callback-ongoawaycallback", + "http3.OnOriginCallback": "http3.html#callback-onorigincallback", + "http3.OnSettingsCallback": "http3.html#callback-onsettingscallback", + "http3.OnHeadersCallback": "http3.html#callback-onheaderscallback", + "http3.OnInfoCallback": "http3.html#callback-oninfocallback", + "http3.OnTrailersCallback": "http3.html#callback-ontrailerscallback", + "http3.OnWantTrailersCallback": "http3.html#callback-onwanttrailerscallback", + "http3.OnResetCallback": "http3.html#callback-onresetcallback", + "http3.OnErrorCallback": "http3.html#callback-onerrorcallback" } diff --git a/lib/http3.js b/lib/http3.js new file mode 100644 index 00000000000000..2f09aa66342ac1 --- /dev/null +++ b/lib/http3.js @@ -0,0 +1,20 @@ +'use strict'; + +const { + emitExperimentalWarning, +} = require('internal/util'); +emitExperimentalWarning('http3'); + +const { + connect, + listen, + Http3Session, + Http3Stream, +} = require('internal/quic/http3'); + +module.exports = { + connect, + listen, + Http3Session, + Http3Stream, +}; diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 8a4d179806aa53..5549ba35e18356 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -126,6 +126,7 @@ const legacyWrapperList = new SafeSet([ const schemelessBlockList = new SafeSet([ 'dtls', 'ffi', + 'http3', 'sea', 'sqlite', 'quic', @@ -137,6 +138,7 @@ const schemelessBlockList = new SafeSet([ const experimentalModuleList = new SafeSet([ 'dtls', 'ffi', + 'http3', 'quic', 'sqlite', 'stream/iter', diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 219618da8a8a93..d7f98b9454f534 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -496,7 +496,7 @@ function initializeCJS() { modules = modules.filter((i) => i !== 'node:dtls'); } if (!getOptionValue('--experimental-quic')) { - modules = modules.filter((i) => i !== 'node:quic'); + modules = modules.filter((i) => i !== 'node:quic' && i !== 'node:http3'); } if (!getOptionValue('--experimental-ffi')) { modules = modules.filter((i) => i !== 'node:ffi'); diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 38ea6675928ad8..7e9ac9a02eb4a1 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -430,6 +430,7 @@ function setupQuic() { const { BuiltinModule } = require('internal/bootstrap/realm'); BuiltinModule.allowRequireByUsers('quic'); + BuiltinModule.allowRequireByUsers('http3'); } function setupVfs() { diff --git a/lib/internal/quic/http3.js b/lib/internal/quic/http3.js new file mode 100644 index 00000000000000..1f5e8f37210910 --- /dev/null +++ b/lib/internal/quic/http3.js @@ -0,0 +1,851 @@ +'use strict'; + +const { + ArrayIsArray, + ArrayPrototypePush, + ObjectHasOwn, + Symbol, + SymbolAsyncIterator, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +// HTTP/3 requires QUIC, which requires that Node.js be compiled with +// crypto support and that the --experimental-quic flag be enabled. +if (!process.features.quic || !getOptionValue('--experimental-quic')) { + return; +} + +// Internal, experimental HTTP/3 consumer layer over node:quic. +// +// Http3Session wraps a QuicSession and surfaces incoming/outgoing request +// streams as Http3Stream objects. + +const { + connect: quicConnect, + listen: quicListen, + QuicSession, + QuicStream, + kApplication, + kApplicationSettings, + kStreamHandle, + kSessionHandle, + markSessionClosing, + getQuicStreamState, + getQuicSessionState, + safeCallbackInvoke: quicSafeCallbackInvoke, + kApplicationOwner, +} = require('internal/quic/quic'); + +const { + setHttp3Callbacks, + createHttp3Handle, + + QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, + QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, + QUIC_STREAM_HEADERS_KIND_TRAILING: kHeadersKindTrailing, + QUIC_STREAM_HEADERS_FLAGS_NONE: kHeadersFlagsNone, + QUIC_STREAM_HEADERS_FLAGS_TERMINAL: kHeadersFlagsTerminal, +} = internalBinding('quic'); + +const { + buildNgHeaderString, + assertValidPseudoHeader, + assertValidPseudoHeaderTrailer, +} = require('internal/http2/util'); + +const { + onSessionApplicationChannel, + onSessionClosingChannel, + onSessionGoawayChannel, + onSessionOriginChannel, + onStreamHeadersChannel, + onStreamTrailersChannel, + onStreamInfoChannel, +} = require('internal/quic/diagnostics'); + +const { + validateBoolean, + validateFunction, + validateObject, + validateOneOf, +} = require('internal/validators'); + +const { + codes: { + ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_QUIC_OPEN_STREAM_FAILED, + }, +} = require('internal/errors'); + +const assert = require('internal/assert'); + +const kEmptyObject = { __proto__: null }; +const kHttp3Alpn = 'h3'; + +// Module-private: routing of the HTTP/3 application events (registered +// via setHttp3Callbacks below) into the wrappers. +const kOnHeaders = Symbol('kOnHeaders'); +const kOnWantTrailers = Symbol('kOnWantTrailers'); +const kOnGoaway = Symbol('kOnGoaway'); +const kOnOrigin = Symbol('kOnOrigin'); +const kOnSettings = Symbol('kOnSettings'); + +function isQuicSession(value) { + return value instanceof QuicSession; +} + +function isQuicStream(value) { + return value instanceof QuicStream; +} + +/** + * Parses an alternating [name, value, name, value, ...] array from the + * application into a plain header object. Multi-value headers become arrays. + * @param {string[]} pairs + * @returns {object} + */ +function parseHeaderPairs(pairs) { + assert(ArrayIsArray(pairs)); + assert(pairs.length % 2 === 0); + const block = { __proto__: null }; + for (let n = 0; n + 1 < pairs.length; n += 2) { + if (block[pairs[n]] !== undefined) { + if (ArrayIsArray(block[pairs[n]])) { + ArrayPrototypePush(block[pairs[n]], pairs[n + 1]); + } else { + block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; + } + } else { + block[pairs[n]] = pairs[n + 1]; + } + } + return block; +} + +/** + * @typedef {object} SendHeadersOptions + * @property {boolean} [terminal] When true, indicates that no body data + * will be sent after these headers. + */ + +const kGetHttp3Handle = Symbol('kGetHttp3Handle'); +const kSubmitInitialHeaders = Symbol('kSubmitInitialHeaders'); + +function priorityFieldValue(level, incremental) { + const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; + if (urgency === 3 && !incremental) return undefined; + return incremental ? `u=${urgency}, i` : `u=${urgency}`; +} + +class Http3Stream { + #stream; + #session; + #h3handle; + #headers = undefined; + #onheaders = undefined; + #oninfo = undefined; + #ontrailers = undefined; + #onwanttrailers = undefined; + // Client-side stored priority (the value this side requested). The server + // side reads the peer's requested priority from nghttp3 instead. + #priority = { __proto__: null, level: 'default', incremental: false }; + // Have we submitted an initial header block (request/response) to nghttp3? + #headersSubmitted = false; + + /** + * @param {QuicStream} stream the underlying QUIC stream + * @param {Http3Session} session the owning session wrapper + * @param {object} [callbacks] creation-time stream callbacks + */ + constructor(stream, session, callbacks = kEmptyObject) { + if (!isQuicStream(stream)) { + throw new ERR_INVALID_ARG_TYPE('stream', 'QuicStream', stream); + } + this.#stream = stream; + this.#session = session; + this.#h3handle = session[kGetHttp3Handle](); + const handle = stream[kStreamHandle]; + if (handle !== undefined) { + handle[kApplicationOwner] = this; + } + const { + onheaders, + oninfo, + ontrailers, + onwanttrailers, + onreset, + onerror, + } = callbacks; + if (onheaders !== undefined) this.onheaders = onheaders; + if (oninfo !== undefined) this.oninfo = oninfo; + if (ontrailers !== undefined) this.ontrailers = ontrailers; + if (onwanttrailers !== undefined) this.onwanttrailers = onwanttrailers; + if (onreset !== undefined) this.onreset = onreset; + if (onerror !== undefined) this.onerror = onerror; + } + + [kOnHeaders](pairs, kind) { + if (this.#stream.destroyed) return; + quicSafeCallbackInvoke(() => this.#onHeaderBlock(pairs, kind), this.#stream); + } + + [kOnWantTrailers]() { + if (this.#stream.destroyed) return; + if (typeof this.#onwanttrailers !== 'function') return; + quicSafeCallbackInvoke(() => this.#onwanttrailers(), this.#stream); + } + + #onHeaderBlock(pairs, kind) { + const block = parseHeaderPairs(pairs); + const stream = this.#stream; + switch (kind) { + case kHeadersKindInitial: + this.#headers ??= block; + if (onStreamHeadersChannel.hasSubscribers) { + onStreamHeadersChannel.publish({ + __proto__: null, + stream, + session: stream.session, + headers: block, + }); + } + if (typeof this.#onheaders === 'function') { + return this.#onheaders(block); + } + return undefined; + case kHeadersKindTrailing: + if (onStreamTrailersChannel.hasSubscribers) { + onStreamTrailersChannel.publish({ + __proto__: null, + stream, + session: stream.session, + trailers: block, + }); + } + if (typeof this.#ontrailers === 'function') { + return this.#ontrailers(block); + } + return undefined; + case kHeadersKindHints: + if (onStreamInfoChannel.hasSubscribers) { + onStreamInfoChannel.publish({ + __proto__: null, + stream, + session: stream.session, + headers: block, + }); + } + if (typeof this.#oninfo === 'function') { + return this.#oninfo(block); + } + return undefined; + } + } + + /** @type {Http3Session} */ + get session() { return this.#session; } + + /** @type {bigint} */ + get id() { return this.#stream.id; } + + /** @type {'bidi'|'uni'} */ + get direction() { return this.#stream.direction; } + + // Received header block (after onheaders fires). + get headers() { return this.#headers; } + + // True when the stream's data was received as 0-RTT early data. + get early() { return this.#stream.early; } + + get #isServer() { + return getQuicSessionState(this.#session.session).isServer; + } + + get #knownToNgHttp3() { + return this.#isServer || this.#headersSubmitted; + } + + // The stream's priority. On a client this is the value we requested; on a + // server it is the peer's requested priority read from nghttp3. + get priority() { + const stream = this.#stream; + if (stream.destroyed || this.#h3handle === undefined) return null; + if (!this.#isServer) { + return { level: this.#priority.level, + incremental: this.#priority.incremental }; + } + const packed = this.#h3handle.getPriority(stream[kStreamHandle]); + if (packed === undefined) return null; + const urgency = packed >> 1; + const incremental = !!(packed & 1); + const level = urgency < 3 ? 'high' : urgency > 3 ? 'low' : 'default'; + return { level, incremental }; + } + + setPriority(options = kEmptyObject) { + const stream = this.#stream; + if (stream.destroyed) return; + validateObject(options, 'options'); + const { level = 'default', incremental = false } = options; + validateOneOf(level, 'options.level', ['default', 'low', 'high']); + validateBoolean(incremental, 'options.incremental'); + this.#priority = { __proto__: null, level, incremental }; + + // Before the stream is known to nghttp3 (client, pre-submit) the requested + // priority is carried in the initial header block by sendHeaders; + // afterwards a change is signaled with a PRIORITY_UPDATE frame. + if (this.#knownToNgHttp3 && this.#h3handle !== undefined) { + const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; + this.#h3handle.setPriority( + stream[kStreamHandle], (urgency << 1) | (incremental ? 1 : 0)); + } + } + + [kSubmitInitialHeaders](headerString, flags) { + const stream = this.#stream; + if (stream.destroyed || this.#h3handle === undefined) return false; + this.#headersSubmitted = true; + return this.#h3handle.sendHeaders( + stream[kStreamHandle], headerString, flags); + } + + #updateHeaderInterest() { + getQuicStreamState(this.#stream).wantsHeaders = + this.#onheaders !== undefined || + this.#oninfo !== undefined || + this.#ontrailers !== undefined; + } + + get onheaders() { return this.#onheaders; } + set onheaders(fn) { + if (fn !== undefined) validateFunction(fn, 'onheaders'); + this.#onheaders = fn; + this.#updateHeaderInterest(); + } + + get oninfo() { return this.#oninfo; } + set oninfo(fn) { + if (fn !== undefined) validateFunction(fn, 'oninfo'); + this.#oninfo = fn; + this.#updateHeaderInterest(); + } + + get ontrailers() { return this.#ontrailers; } + set ontrailers(fn) { + if (fn !== undefined) validateFunction(fn, 'ontrailers'); + this.#ontrailers = fn; + this.#updateHeaderInterest(); + } + + get onwanttrailers() { return this.#onwanttrailers; } + set onwanttrailers(fn) { + if (fn === undefined) { + this.#onwanttrailers = undefined; + // The application ends the stream after the body when no trailers + // are expected. + getQuicStreamState(this.#stream).wantsTrailers = false; + } else { + validateFunction(fn, 'onwanttrailers'); + this.#onwanttrailers = fn; + // Tell the application to keep the stream open after the body so + // trailers can be submitted. + getQuicStreamState(this.#stream).wantsTrailers = true; + } + } + + get onreset() { return this.#stream.onreset; } + set onreset(fn) { this.#stream.onreset = fn; } + + get onerror() { return this.#stream.onerror; } + set onerror(fn) { this.#stream.onerror = fn; } + + /** + * Sends the initial request or response header block. + * @param {object} headers + * @param {SendHeadersOptions} [options] + * @returns {boolean} true if the headers were scheduled to be sent. + */ + sendHeaders(headers, options = kEmptyObject) { + const stream = this.#stream; + if (stream.destroyed || this.#h3handle === undefined) return false; + validateObject(headers, 'headers'); + const { terminal = false } = options; + + // A client request carries its requested priority as a priority header + // when set - server responses signal via setPriority instead. + let toSend = headers; + if (!this.#isServer) { + const pri = priorityFieldValue( + this.#priority.level, this.#priority.incremental); + if (pri !== undefined && !ObjectHasOwn(headers, 'priority')) { + toSend = { __proto__: null, ...headers, priority: pri }; + } + } + + const headerString = buildNgHeaderString( + toSend, assertValidPseudoHeader, true /* strictSingleValueFields */); + const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; + // The stream now exists in nghttp3; later priority changes use + // PRIORITY_UPDATE rather than the header block. + this.#headersSubmitted = true; + return this.#h3handle.sendHeaders( + stream[kStreamHandle], headerString, flags); + } + + /** + * Sends informational (1xx) headers on this stream. Server only. + * @param {object} headers + * @returns {boolean} true if the headers were scheduled to be sent. + */ + sendInformationalHeaders(headers) { + const stream = this.#stream; + if (stream.destroyed) return false; + if (this.#h3handle === undefined) return false; + validateObject(headers, 'headers'); + const headerString = buildNgHeaderString( + headers, assertValidPseudoHeader, true); + return this.#h3handle.sendInformationalHeaders( + stream[kStreamHandle], headerString); + } + + /** + * Sends trailing headers on this stream. Must be called synchronously + * during the onwanttrailers callback. + * @param {object} headers + * @returns {boolean} true if the trailers were scheduled to be sent. + */ + sendTrailers(headers) { + const stream = this.#stream; + if (stream.destroyed) return false; + if (this.#h3handle === undefined) return false; + validateObject(headers, 'headers'); + const headerString = + buildNgHeaderString(headers, assertValidPseudoHeaderTrailer); + return this.#h3handle.sendTrailers( + stream[kStreamHandle], headerString); + } + + // Outbound body writer (stream/iter push writer). + get writer() { return this.#stream.writer; } + + // Inbound body bytes; ends at FIN. + [SymbolAsyncIterator]() { + return this.#stream[SymbolAsyncIterator](); + } + + /** @type {Promise} */ + get closed() { return this.#stream.closed; } + + get destroyed() { return this.#stream.destroyed; } + + destroy(error, options) { return this.#stream.destroy(error, options); } + + stopSending(code) { return this.#stream.stopSending(code); } + + resetStream(code) { return this.#stream.resetStream(code); } +} + +class Http3Session { + #session; + #h3handle; + #onstream = undefined; + #ongoaway = undefined; + #onorigin = undefined; + #onsettings = undefined; + + /** + * Wraps an existing (built-in) QuicSession. Must be constructed + * synchronously in the frame the session is delivered, before any I/O + * tick, so that no incoming stream or session event is missed. + * @param {QuicSession} session the QUIC session to wrap + * @param {object} [callbacks] creation-time session callbacks + */ + constructor(session, callbacks = kEmptyObject) { + if (!isQuicSession(session)) { + // Foreign QuicLike transports take the slow path; that adapter is not + // wired up yet, so only built-in sessions are accepted for now. + throw new ERR_INVALID_ARG_TYPE('session', 'QuicSession', session); + } + this.#session = session; + // Receive the raw HTTP/3 application events for this session (GOAWAY, + // ORIGIN): they are emitted on the session's binding handle and + // routed back here. + const handle = session[kSessionHandle]; + if (handle !== undefined) { + if (handle[kApplicationOwner] !== undefined) { + throw new ERR_INVALID_STATE( + 'The QUIC session already has an application attached'); + } + this.#h3handle = createHttp3Handle(handle); + handle[kApplicationOwner] = this; + } + // Claim the session's incoming streams. This takes the single + // public onstream slot; a tap chains it explicitly (grab the + // previous handler and call both). + session.onstream = (stream) => { + if (typeof this.#onstream !== 'function') { + process.emitWarning( + 'A new HTTP/3 stream was received but no onstream callback ' + + 'was provided'); + stream.destroy(); + return; + } + + return this.#onstream(new Http3Stream(stream, this)); + }; + const { ongoaway, onorigin, onsettings } = callbacks; + if (ongoaway !== undefined) this.ongoaway = ongoaway; + if (onorigin !== undefined) this.onorigin = onorigin; + if (onsettings !== undefined) this.onsettings = onsettings; + } + + // Internal: hands the per-session HTTP/3 handle to this session's streams. + [kGetHttp3Handle]() { return this.#h3handle; } + + /** + * The peer initiated a graceful shutdown of the session (HTTP/3 + * GOAWAY). The session stops allowing new streams. + * @param {bigint} lastStreamId The highest stream ID the peer may + * have processed - streams above it were not processed and may be + * retried. + */ + [kOnGoaway](lastStreamId) { + const session = this.#session; + if (session.destroyed) return; + markSessionClosing(session); + if (onSessionClosingChannel.hasSubscribers) { + onSessionClosingChannel.publish({ __proto__: null, session }); + } + if (onSessionGoawayChannel.hasSubscribers) { + onSessionGoawayChannel.publish({ + __proto__: null, + session, + lastStreamId, + }); + } + if (typeof this.#ongoaway === 'function') { + // A sync throw or async rejection from the user callback + // destroys the session with that error. + quicSafeCallbackInvoke(() => this.#ongoaway(lastStreamId), session); + } + } + + /** + * The peer announced the origins it claims authority for (HTTP/3 + * ORIGIN frame). + * @param {string[]} origins + */ + [kOnOrigin](origins) { + const session = this.#session; + if (session.destroyed) return; + if (onSessionOriginChannel.hasSubscribers) { + onSessionOriginChannel.publish({ + __proto__: null, + origins, + session, + }); + } + if (typeof this.#onorigin === 'function') { + quicSafeCallbackInvoke(() => this.#onorigin(origins), session); + } + } + + /** + * The peer's HTTP/3 SETTINGS were received (or updated). The negotiated + * values are now readable via `session.settings` and are passed to the + * callback for convenience. + */ + [kOnSettings]() { + const session = this.#session; + if (session.destroyed) return; + const settings = this.settings; + if (onSessionApplicationChannel.hasSubscribers) { + onSessionApplicationChannel.publish({ + __proto__: null, + settings, + session, + }); + } + if (typeof this.#onsettings === 'function') { + quicSafeCallbackInvoke(() => this.#onsettings(settings), session); + } + } + + // The underlying QUIC session (transitional escape hatch). + get session() { return this.#session; } + + /** + * The effective HTTP/3 settings for this session. Reflects the + * configured values plus any updates received from the peer's + * SETTINGS frame (e.g. enableConnectProtocol). Returns null once the + * session is destroyed. + * @type {object|null} + */ + get settings() { + if (this.#session.destroyed) return null; + // Not cached: the values can be updated by the peer's SETTINGS. + return this.#session[kSessionHandle].applicationSettings(); + } + + get servername() { return this.#session.servername; } + + get alpnProtocol() { return this.#session.alpnProtocol; } + + get onstream() { return this.#onstream; } + set onstream(fn) { + if (fn !== undefined) validateFunction(fn, 'onstream'); + this.#onstream = fn; + } + + // Receives the last-processed stream id when the peer sends an + // HTTP/3 GOAWAY frame. + get ongoaway() { return this.#ongoaway; } + set ongoaway(fn) { + if (fn !== undefined) validateFunction(fn, 'ongoaway'); + this.#ongoaway = fn; + } + + // Receives the origin list when the peer sends an HTTP/3 ORIGIN + // frame (RFC 9412). + get onorigin() { return this.#onorigin; } + set onorigin(fn) { + if (fn === undefined) { + this.#onorigin = undefined; + // Tell the application to stop delivering origin announcements. + getQuicSessionState(this.#session).hasOriginListener = false; + } else { + validateFunction(fn, 'onorigin'); + this.#onorigin = fn; + // Tell the application that origin announcements have a consumer. + getQuicSessionState(this.#session).hasOriginListener = true; + } + } + + // Receives the negotiated HTTP/3 settings when the peer's SETTINGS + // frame arrives (which may be after the session opens). The current + // values are also available synchronously via `session.settings`. + get onsettings() { return this.#onsettings; } + set onsettings(fn) { + if (fn !== undefined) validateFunction(fn, 'onsettings'); + this.#onsettings = fn; + } + + get onerror() { return this.#session.onerror; } + set onerror(fn) { this.#session.onerror = fn; } + + + /** @type {Promise} */ + get opened() { return this.#session.opened; } + + /** @type {Promise} */ + get closed() { return this.#session.closed; } + + /** + * Opens a request stream: a bidirectional stream carrying the given + * request headers. If not specified, they can be sent later with + * stream.sendHeaders() instead. + * + * Stream callbacks (onheaders, oninfo, ontrailers, onwanttrailers, + * onreset, onerror) are passed in options so they are + * attached before any event can be delivered. + * @param {object} [headers] the request header block + * @param {object} [options] stream options (body, callbacks, ...) + * @returns {Promise} + */ + async request(headers, options = kEmptyObject) { + if (getQuicSessionState(this.#session).isServer) { + throw new ERR_INVALID_STATE( + 'Server sessions cannot open HTTP/3 request streams'); + } + if (headers !== undefined) validateObject(headers, 'headers'); + validateObject(options, 'options'); + // The HTTP/3-level callbacks stay at this layer; only the + // QUIC-level stream options (body, priority, highWaterMark, ...) + // are passed down. + const { + onheaders, + oninfo, + ontrailers, + onwanttrailers, + onreset, + onerror, + priority, + incremental, + ...quicOptions + } = options; + + let headerString; + if (headers !== undefined) { + let toSend = headers; + const pri = priorityFieldValue(priority ?? 'default', incremental ?? false); + if (pri !== undefined && !ObjectHasOwn(headers, 'priority')) { + toSend = { __proto__: null, ...headers, priority: pri }; + } + headerString = buildNgHeaderString( + toSend, assertValidPseudoHeader, true /* strictSingleValueFields */); + } + + const stream = await this.#session.createBidirectionalStream(quicOptions); + const wrapped = new Http3Stream(stream, this, { + __proto__: null, + onheaders, + oninfo, + ontrailers, + onwanttrailers, + onreset, + onerror, + }); + + if (priority !== undefined || incremental !== undefined) { + wrapped.setPriority({ __proto__: null, level: priority, incremental }); + } + + // Submit the request headers only after the callbacks above are + // attached. Nothing for this stream can hit the wire before the + // headers are submitted: the HTTP/3 application only learns about the + // stream (including any body configured at creation) from the + // header submission itself. When there is no body, the header + // block also terminates the stream. + if (headerString !== undefined) { + const flags = quicOptions.body === undefined ? + kHeadersFlagsTerminal : kHeadersFlagsNone; + if (!wrapped[kSubmitInitialHeaders](headerString, flags)) { + wrapped.destroy(); + throw new ERR_QUIC_OPEN_STREAM_FAILED(); + } + } + return wrapped; + } + + close(options) { return this.#session.close(options); } + + destroy(error, options) { return this.#session.destroy(error, options); } +} + +// Register the HTTP/3 application-event callbacks with the binding. The +// C++ layer invokes each with `this` set to the binding handle (stream or +// session) the event belongs to; route it to the wrapper. Events for a +// handle with no live wrapper are dropped. This runs once per realm at +// module load: any HTTP/3 session requires this module, so the callbacks +// are always installed before an event can fire. +setHttp3Callbacks({ + /** + * A block of headers ([name, value, ...] pairs plus the + * application's kind constant) was received on a stream. + * @param {string[]} headers + * @param {number} kind + */ + onStreamHeaders(headers, kind) { + this[kApplicationOwner]?.[kOnHeaders](headers, kind); + }, + /** + * The application is ready for a stream's trailing headers. + */ + onStreamTrailers() { + this[kApplicationOwner]?.[kOnWantTrailers](); + }, + /** + * The peer initiated a graceful shutdown of a session. + * @param {bigint} lastStreamId + */ + onSessionGoaway(lastStreamId) { + this[kApplicationOwner]?.[kOnGoaway](lastStreamId); + }, + /** + * The peer announced the origins it claims authority for. + * @param {string[]} origins + */ + onSessionOrigin(origins) { + this[kApplicationOwner]?.[kOnOrigin](origins); + }, + /** + * The peer's HTTP/3 SETTINGS were received or updated. + */ + onSessionApplication() { + this[kApplicationOwner]?.[kOnSettings](); + }, +}); + +/** + * The HTTP/3 settings (RFC 9114 section 7.2 / RFC 9204 SETTINGS values + * advertised to the peer, plus local header-processing limits). + * Validated and applied by the HTTP/3 application in C++; the QUIC + * layer carries them opaquely. + * @typedef {object} Http3Settings + * @property {bigint|number} [maxHeaderPairs] Maximum number of header + * pairs accepted on a stream (local enforcement limit). + * @property {bigint|number} [maxHeaderLength] Maximum total header + * bytes accepted on a stream (local enforcement limit). + * @property {bigint|number} [maxFieldSectionSize] The maximum field + * section size advertised to the peer. + * @property {bigint|number} [qpackMaxDTableCapacity] The QPACK maximum + * dynamic table capacity. + * @property {bigint|number} [qpackEncoderMaxDTableCapacity] The QPACK + * encoder maximum dynamic table capacity. + * @property {bigint|number} [qpackBlockedStreams] The maximum number of + * QPACK blocked streams. + * @property {boolean} [enableConnectProtocol] Enable extended CONNECT + * (RFC 9220). + */ + +/** + * Connects a built-in QUIC session with ALPN h3 and wraps it. The + * HTTP/3-level options (settings, ongoaway, onorigin) stay at this + * layer; the remaining options are passed down to node:quic. + * @param {SocketAddress|string} address the server address + * @param {object} [options] http3 + quic connect options + * @param {Http3Settings} [options.settings] the HTTP/3 settings + * @returns {Promise} + */ +async function connect(address, options = kEmptyObject) { + validateObject(options, 'options'); + const { ongoaway, onorigin, onsettings, settings, ...quicOptions } = options; + const session = await quicConnect(address, { + ...quicOptions, + // The onstream callback is owned by this layer. Datagrams are not (yet) + // exposed for HTTP/3, so suppress ondatagram too. + onstream: undefined, + ondatagram: undefined, + alpn: kHttp3Alpn, + [kApplication]: 'http3', + [kApplicationSettings]: settings, + }); + return new Http3Session(session, { ongoaway, onorigin, onsettings }); +} + +/** + * Listens with ALPN h3; onsession receives an Http3Session, wrapped + * synchronously in the delivery frame per the attach contract. The + * HTTP/3-level options (settings, ongoaway, onorigin) stay at this + * layer; the remaining options are passed down to node:quic. + * @param {Function} onsession invoked with each new Http3Session + * @param {object} [options] http3 + quic listen options + * @param {Http3Settings} [options.settings] the HTTP/3 settings + * @returns {Promise} the listening QuicEndpoint + */ +async function listen(onsession, options = kEmptyObject) { + validateFunction(onsession, 'onsession'); + validateObject(options, 'options'); + const { ongoaway, onorigin, onsettings, settings, ...quicOptions } = options; + return quicListen((session) => { + return onsession(new Http3Session(session, { ongoaway, onorigin, onsettings })); + }, { + ...quicOptions, + onstream: undefined, + ondatagram: undefined, + alpn: kHttp3Alpn, + [kApplication]: 'http3', + [kApplicationSettings]: settings, + }); +} + +module.exports = { + Http3Session, + Http3Stream, + connect, + listen, +}; diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index ac3f50ddd34b8b..98f49eb7bb36b7 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -71,20 +71,8 @@ const { CLOSECONTEXT_RECEIVE_FAILURE: kCloseContextReceiveFailure, CLOSECONTEXT_SEND_FAILURE: kCloseContextSendFailure, CLOSECONTEXT_START_FAILURE: kCloseContextStartFailure, - QUIC_STREAM_HEADERS_KIND_INITIAL: kHeadersKindInitial, - QUIC_STREAM_HEADERS_KIND_HINTS: kHeadersKindHints, - QUIC_STREAM_HEADERS_KIND_TRAILING: kHeadersKindTrailing, - QUIC_STREAM_HEADERS_FLAGS_NONE: kHeadersFlagsNone, - QUIC_STREAM_HEADERS_FLAGS_TERMINAL: kHeadersFlagsTerminal, } = internalBinding('quic'); -// Maps the numeric HeadersKind constants from C++ to user-facing strings. -// Indexed by the enum value (HINTS=0, INITIAL=1, TRAILING=2). -const kHeadersKindName = []; -kHeadersKindName[kHeadersKindHints] = 'hints'; -kHeadersKindName[kHeadersKindInitial] = 'initial'; -kHeadersKindName[kHeadersKindTrailing] = 'trailing'; - const { markPromiseAsHandled, } = internalBinding('util'); @@ -168,11 +156,6 @@ const { validateString, } = require('internal/validators'); -const { - buildNgHeaderString, - assertValidPseudoHeader, -} = require('internal/http2/util'); - const kEmptyObject = { __proto__: null }; const { @@ -184,11 +167,9 @@ const { kDrain, kEarlyDataRejected, kFinishClose, - kGoaway, kHandshake, kHandshakeCompleted, kVerifyPeer, - kHeaders, kOwner, kRemoveSession, kKeylog, @@ -198,15 +179,10 @@ const { kRemoveStream, kNewStream, kNewToken, - kOrigin, - kStreamCallbacks, kPathValidation, kPrivateConstructor, kReset, - kSendHeaders, - kSessionApplication, kSessionTicket, - kTrailers, kVersionNegotiation, kInspect, } = require('internal/quic/symbols'); @@ -253,17 +229,11 @@ const { onSessionReceiveDatagramStatusChannel, onSessionPathValidationChannel, onSessionNewTokenChannel, - onSessionApplicationChannel, onSessionTicketChannel, onSessionVersionNegotiationChannel, - onSessionOriginChannel, onSessionHandshakeChannel, - onSessionGoawayChannel, onSessionEarlyRejectedChannel, onStreamClosedChannel, - onStreamHeadersChannel, - onStreamTrailersChannel, - onStreamInfoChannel, onStreamResetChannel, onStreamBlockedChannel, onSessionErrorChannel, @@ -272,6 +242,29 @@ const { const kNilDatagramId = 0n; +// Constructor option symbol for installing a protocol application on a +// session. This is a private API for configuring native integrations, not a +// public option: installing an application without its consumer layer on top +// breaks the session, as applications integrate natively with their matching +// consumer (e.g. node:http3). +const kApplication = Symbol('kApplication'); + +// Constructor option symbol carrying the application-specific settings for +// the application named by kApplication. The value is opaque to the QUIC +// layer: it is parsed by the registered application's own settings parser +// in C++ and carried through the session options untouched. +const kApplicationSettings = Symbol('kApplicationSettings'); + +// Internal symbols to allow a protocol application's consumer layer (e.g. +// node:http3) to reach the binding handles of a stream or session: +const kStreamHandle = Symbol('kStreamHandle'); +const kSessionHandle = Symbol('kSessionHandle'); +// Brands a C++ handle object with its application-layer owner (e.g. the +// Http3Stream/Http3Session wrapper). Lives here rather than in the +// application module: any application module would share it, and the +// handle objects it brands are this layer's objects. +const kApplicationOwner = Symbol('kApplicationOwner'); + // Module-level registry of all live QuicEndpoint instances. Used by // connect() and listen() to find existing endpoints for reuse instead // of creating a new one per session. @@ -288,18 +281,8 @@ const endpointRegistry = new SafeSet(); * FileHandle|AsyncIterable|Iterable|Promise|null} [body] The outbound * body source. See the public docs for `stream.setBody()` for details * on supported types. When omitted, the stream is closed immediately. - * @property {object} [headers] Initial request or response headers to - * send. Only used when the negotiated application supports headers - * (e.g. HTTP/3). - * @property {'high'|'default'|'low'} [priority] The priority level of the stream. - * @property {boolean} [incremental] Whether to interleave data with same-priority streams. * @property {number} [highWaterMark] The high water mark for write * backpressure, in bytes. **Default:** `65536`. - * @property {OnHeadersCallback} [onheaders] Callback for incoming initial headers - * @property {OnTrailersCallback} [ontrailers] Callback for incoming trailing headers - * @property {OnInfoCallback} [oninfo] Callback for informational (1xx) headers - * @property {OnWantTrailersCallback} [onwanttrailers] Callback fired when the - * transport is ready to send trailers for this stream. */ /** @@ -353,18 +336,8 @@ const endpointRegistry = new SafeSet(); * @property {bigint|number} [ackDelayExponent] The acknowledgment delay exponent * @property {bigint|number} [maxAckDelay] The maximum acknowledgment delay * @property {bigint|number} [maxDatagramFrameSize] The maximum datagram frame size - */ - -/** - * @typedef {object} ApplicationOptions - * @property {bigint|number} [maxHeaderPairs] The maximum header pairs - * @property {bigint|number} [maxHeaderLength] The maximum header length - * @property {bigint|number} [maxFieldSectionSize] The maximum field section size - * @property {bigint|number} [qpackMaxDTableCapacity] The qpack maximum dynamic table capacity - * @property {bigint|number} [qpackEncoderMaxDTableCapacity] The qpack encoder maximum dynamic table capacity - * @property {bigint|number} [qpackBlockedStreams] The qpack blocked streams - * @property {boolean} [enableConnectProtocol] Enable the connect protocol - * @property {boolean} [enableDatagrams] Enable datagrams + * (RFC 9221). Setting `false` advertises a maximum datagram frame size + * of zero, disabling datagrams entirely. **Default:** `true`. */ /** @@ -375,11 +348,13 @@ const endpointRegistry = new SafeSet(); * @property {ArrayBuffer|ArrayBufferView|Array} certs The TLS certificates. * @property {boolean} [verifyPrivateKey] Verify the private key. * **Default:** `false`. - * @property {number} [port] The port to advertise in HTTP/3 ORIGIN frames - * for this host name. **Default:** `443`. + * @property {number} [port] The port to advertise for this host name + * when the session's protocol application announces server authority + * (origin advertisement). **Default:** `443`. * @property {boolean} [authoritative] Whether to include this host name - * in HTTP/3 ORIGIN frames. **Default:** `true`. Wildcard (`'*'`) - * entries are always excluded regardless of this setting. + * in the protocol application's origin advertisement. **Default:** `true`. + * Wildcard (`'*'`) entries are always excluded regardless of this + * setting. */ /** @@ -392,7 +367,6 @@ const endpointRegistry = new SafeSet(); * @property {number} [minVersion] The minimum acceptable QUIC version * @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy * @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only) - * @property {ApplicationOptions} [application] The application options * @property {TransportParams} [transportParams] The transport parameters * @property {string} [servername] The server name identifier (client only) * @property {string|string[]} [alpn] The ALPN protocol identifier(s). @@ -421,6 +395,10 @@ const endpointRegistry = new SafeSet(); * prior session, used to resume that session (client only). * @property {ArrayBufferView} [token] An opaque address validation token * previously received from the server via `onnewtoken` (client only). + * @property {ArrayBufferView} [appTicketData] Opaque application data to embed + * in issued session tickets (server only). This is written into new tickets, + * and validated against received 0-RTT early data tickets to confirm if they + * can be accepted (anything but an exact match is rejected). * @property {bigint|number} [handshakeTimeout] The handshake timeout * @property {bigint|number} [initialRtt] The initial round-trip time estimate in milliseconds. * Used for PTO computation and initial pacing before the first RTT sample. Default uses @@ -451,16 +429,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. - * @property {OnWantTrailersCallback} [onwanttrailers] Default per-stream - * want-trailers callback. */ /** @@ -500,18 +470,6 @@ const endpointRegistry = new SafeSet(); * frames do not themselves carry a reason field over the wire. */ -/** - * @typedef {object} SendHeadersOptions - * @property {boolean} [terminal] When true, indicates that no body data will be - * sent after these headers. - */ - -/** - * @typedef {object} StreamPriority - * @property {'default' | 'low' | 'high'} level The priority level of the stream. - * @property {boolean} incremental Whether to interleave data with same-priority streams. - */ - /** * @typedef {object} QuicSessionPath * @property {SocketAddress} local The local address for this path @@ -586,13 +544,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * @callback OnApplicationCallback - * @this {QuicSession} - * @param {ApplicationOptions} applicationoptions - * @returns {void} - */ - /** * @callback OnSessionTicketCallback * @this {QuicSession} @@ -635,22 +586,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * Called when the server sends an ORIGIN frame. - * @callback OnOriginCallback - * @this {QuicSession} - * @param {string[]} origins The list of origins the server claims authority for - * @returns {void} - */ - -/** - * Called when the peer sends a GOAWAY frame (HTTP/3 only). - * @callback OnGoawayCallback - * @this {QuicSession} - * @param {bigint} lastStreamId The highest stream ID the peer may have processed - * @returns {void} - */ - /** * Called when TLS key-log material is available. Only fires when * `sessionOptions.keylog` is `true`. @@ -670,14 +605,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * Called when `ApplicationOptions` are changed, e.g. HTTP/3 settings. - * @callback OnApplicationCallback - * @this {QuicSession} - * @param {ApplicationOptions} applicationoptions ApplicationOptions object - * @returns {void} - */ - /** * @callback OnBlockedCallback * @this {QuicStream} @@ -691,41 +618,6 @@ const endpointRegistry = new SafeSet(); * @returns {void} */ -/** - * Called when initial request or response headers are received. - * @callback OnHeadersCallback - * @this {QuicStream} - * @param {object} headers Header object with lowercase string keys and - * string or string-array values. - * @returns {void} - */ - -/** - * Called when trailing headers are received from the peer. - * @callback OnTrailersCallback - * @this {QuicStream} - * @param {object} trailers Trailing header object. - * @returns {void} - */ - -/** - * Called when informational (1xx) headers are received from the server - * (e.g. 103 Early Hints). - * @callback OnInfoCallback - * @this {QuicStream} - * @param {object} headers Informational header object. - * @returns {void} - */ - -/** - * Called when the transport is ready to send trailers for this stream. - * The handler should call `stream.sendTrailers(...)` (or - * `stream.sendTrailers()` with previously-set trailers) to provide them. - * @callback OnWantTrailersCallback - * @this {QuicStream} - * @returns {void} - */ - setCallbacks({ // QuicEndpoint callbacks @@ -740,7 +632,9 @@ setCallbacks({ this[kOwner][kFinishClose](context, status); }, /** - * Called when the QuicEndpoint C++ handle receives a new server-side session + * Called when a new server session is surfaced. The emit happens after + * the session's first datagram (the ClientHello) has been processed, + * so the session's servername/protocol getters are already readable. * @param {object} session The QuicSession C++ handle */ onSessionNew(session) { @@ -764,16 +658,6 @@ setCallbacks({ this[kOwner][kFinishClose](errorType, code, reason, errorName); }, - /** - * Called when the peer sends a GOAWAY frame (HTTP/3 only). - * @param {bigint} lastStreamId The highest stream ID the peer may have - * processed. Streams above this ID were not processed and can be retried. - */ - onSessionGoaway(lastStreamId) { - debug('session goaway callback', lastStreamId); - this[kOwner][kGoaway](lastStreamId); - }, - /** * Called when a datagram is received on this session. * @param {Uint8Array} uint8Array @@ -835,16 +719,6 @@ setCallbacks({ preferredAddress); }, - /** - * Called when the session's application object is updated - * E.g. http/3 session arrived. - * @param {ApplicationOptions} applicationoptions An application object - */ - onSessionApplication(applicationoptions) { - debug('session application callback', this[kOwner]); - this[kOwner][kSessionApplication](applicationoptions); - }, - /** * Called when the session generates a new TLS session ticket * @param {object} ticket An opaque session ticket @@ -876,15 +750,6 @@ setCallbacks({ this[kOwner][kEarlyDataRejected](); }, - /** - * Called when the session receives an ORIGIN frame from the peer (RFC 9412). - * @param {string[]} origins The list of origins the peer claims authority for - */ - onSessionOrigin(origins) { - debug('session origin callback', this[kOwner]); - this[kOwner][kOrigin](origins); - }, - /** * Called when the session receives a session version negotiation request * @param {number} version @@ -981,18 +846,6 @@ setCallbacks({ debug('stream reset callback', this[kOwner], error); this[kOwner][kReset](error); }, - - onStreamHeaders(headers, kind) { - // Called when the stream C++ handle has received a full block of headers. - debug(`stream ${this[kOwner].id} headers callback`, headers, kind); - this[kOwner][kHeaders](headers, kind); - }, - - onStreamTrailers() { - // Called when the stream C++ handle is ready to receive trailing headers. - debug('stream want trailers callback', this[kOwner]); - this[kOwner][kTrailers](); - }, }); function assertPrivateSymbol(privateSymbol) { @@ -1256,31 +1109,7 @@ function validateBody(body) { } /** - * Parses an alternating [name, value, name, value, ...] array from C++ - * into a plain header object. Multi-value headers become arrays. - * @param {string[]} pairs - * @returns {object} - */ -function parseHeaderPairs(pairs) { - assert(ArrayIsArray(pairs)); - assert(pairs.length % 2 === 0); - const block = { __proto__: null }; - for (let n = 0; n + 1 < pairs.length; n += 2) { - if (block[pairs[n]] !== undefined) { - if (ArrayIsArray(block[pairs[n]])) { - ArrayPrototypePush(block[pairs[n]], pairs[n + 1]); - } else { - block[pairs[n]] = [block[pairs[n]], pairs[n + 1]]; - } - } else { - block[pairs[n]] = pairs[n + 1]; - } - } - return block; -} - -/** - * Applies session and stream callbacks from an options object to a session. + * Applies session callbacks from an options object to a session. * @param {QuicSession} session * @param {object} cbs */ @@ -1295,20 +1124,8 @@ function applyCallbacks(session, cbs) { 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; - if (cbs.onheaders || cbs.ontrailers || cbs.oninfo || cbs.onwanttrailers) { - session[kStreamCallbacks] = { - __proto__: null, - onheaders: cbs.onheaders, - ontrailers: cbs.ontrailers, - oninfo: cbs.oninfo, - onwanttrailers: cbs.onwanttrailers, - }; - } } /** @@ -1512,10 +1329,10 @@ function isSyncIterable(obj) { let getQuicStreamState; let getQuicSessionState; let getQuicEndpointState; +let markSessionClosing; let assertIsQuicEndpoint; let assertIsQuicStream; let assertIsQuicSession; -let assertHeadersSupported; let assertEndpointNotClosedOrClosing; let assertEndpointIsNotBusy; let isQuicStream; @@ -1562,15 +1379,9 @@ class QuicStream { outboundSet: false, writer: undefined, fileHandle: undefined, - headers: undefined, - pendingTrailers: undefined, onerror: undefined, onblocked: undefined, onreset: undefined, - onheaders: undefined, - ontrailers: undefined, - oninfo: undefined, - onwanttrailers: undefined, }; static { @@ -1584,13 +1395,6 @@ class QuicStream { } }; - assertHeadersSupported = function(session) { - if (getQuicSessionState(session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - }; - getQuicStreamState = function(stream) { assertIsQuicStream(stream); return stream.#inner.state; @@ -1772,112 +1576,14 @@ class QuicStream { } } - /** @type {OnHeadersCallback} */ - get onheaders() { - assertIsQuicStream(this); - return this.#inner.onheaders; - } - - set onheaders(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onheaders = undefined; - inner.state.wantsHeaders = false; - } else { - validateFunction(fn, 'onheaders'); - assertHeadersSupported(inner.session); - inner.onheaders = FunctionPrototypeBind(fn, this); - inner.state.wantsHeaders = true; - } - } - - /** @type {Function|undefined} */ - get oninfo() { - assertIsQuicStream(this); - return this.#inner.oninfo; - } - - set oninfo(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.oninfo = undefined; - } else { - validateFunction(fn, 'oninfo'); - assertHeadersSupported(inner.session); - inner.oninfo = FunctionPrototypeBind(fn, this); - } - } - - /** @type {Function|undefined} */ - get ontrailers() { - assertIsQuicStream(this); - return this.#inner.ontrailers; - } - - set ontrailers(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.ontrailers = undefined; - } else { - validateFunction(fn, 'ontrailers'); - assertHeadersSupported(inner.session); - inner.ontrailers = FunctionPrototypeBind(fn, this); - } - } - - /** @type {Function|undefined} */ - get onwanttrailers() { - assertIsQuicStream(this); - return this.#inner.onwanttrailers; - } - - set onwanttrailers(fn) { - assertIsQuicStream(this); - const inner = this.#inner; - if (fn === undefined) { - inner.onwanttrailers = undefined; - inner.state.wantsTrailers = false; - } else { - validateFunction(fn, 'onwanttrailers'); - assertHeadersSupported(inner.session); - inner.onwanttrailers = FunctionPrototypeBind(fn, this); - inner.state.wantsTrailers = true; - } - } - /** - * The buffered initial headers received on this stream, or undefined - * if the application does not support headers or no headers have - * been received yet. + * Accessor to allow applications (e.g. http3) to access the stream handle + * directly for internal operations. Returns undefined once destroyed. * @type {object|undefined} */ - get headers() { + get [kStreamHandle]() { assertIsQuicStream(this); - return this.#inner.headers; - } - - /** - * Set trailing headers to be sent when nghttp3 asks for them. - * @type {object|undefined} - */ - get pendingTrailers() { - assertIsQuicStream(this); - return this.#inner.pendingTrailers; - } - - set pendingTrailers(headers) { - const inner = this.#inner; - assertIsQuicStream(this); - assertHeadersSupported(inner.session); - if (headers === undefined) { - inner.pendingTrailers = undefined; - return; - } - validateObject(headers, 'headers'); - inner.pendingTrailers = headers; + return this.#handle; } /** @@ -1950,7 +1656,7 @@ class QuicStream { * are emitted to the peer for any still-open writable / readable * 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" + * `QuicError`) -> the installed application's "internal error" * code from `QuicSessionState.internalErrorCode`. * @param {any} error * @param {QuicStreamDestroyOptions} [options] @@ -2043,66 +1749,6 @@ class QuicStream { this.#handle.attachSource(validateBody(outbound)); } - /** - * @param {object} headers - * @param {SendHeadersOptions} [options] - * @returns {boolean} - */ - sendHeaders(headers, options = kEmptyObject) { - assertIsQuicStream(this); - if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - validateObject(headers, 'headers'); - const { terminal = false } = options; - const headerString = buildNgHeaderString( - headers, assertValidPseudoHeader, true /* strictSingleValueFields */); - const flags = terminal ? kHeadersFlagsTerminal : kHeadersFlagsNone; - return this.#handle.sendHeaders(kHeadersKindInitial, headerString, flags); - } - - /** - * Send informational (1xx) headers on this stream. Server only. - * Throws if the application does not support headers. - * @param {object} headers - * @returns {boolean} - */ - sendInformationalHeaders(headers) { - assertIsQuicStream(this); - if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - validateObject(headers, 'headers'); - const headerString = buildNgHeaderString( - headers, assertValidPseudoHeader, true); - return this.#handle.sendHeaders( - kHeadersKindHints, headerString, kHeadersFlagsNone); - } - - /** - * Send trailing headers on this stream. Must be called synchronously - * during the onwanttrailers callback, or set via pendingTrailers before - * the body completes. Throws if the application does not support headers. - * @param {object} headers - * @returns {boolean} - */ - sendTrailers(headers) { - assertIsQuicStream(this); - if (this.destroyed) return false; - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - validateObject(headers, 'headers'); - const headerString = buildNgHeaderString(headers); - return this.#handle.sendHeaders( - kHeadersKindTrailing, headerString, kHeadersFlagsNone); - } - /** * Returns a Writer for pushing data to this stream incrementally. * Only available when no body source was provided at creation time @@ -2298,7 +1944,7 @@ class QuicStream { // order: // 1. If `reason` is a `QuicError`, use its explicit // `errorCode`. - // 2. Otherwise fall back to the negotiated application's + // 2. Otherwise fall back to the installed application's // "internal error" code, surfaced via // `QuicSessionState.internalErrorCode`. For HTTP/3 this is // `H3_INTERNAL_ERROR` (0x102); for raw QUIC applications @@ -2425,77 +2071,6 @@ class QuicStream { this.#handle.resetStream(BigInt(code)); } - /** - * The priority of the stream. If the stream is destroyed or if - * the session does not support priority, `null` will be - * returned. - * @type {StreamPriority | null} - */ - get priority() { - assertIsQuicStream(this); - if (this.destroyed || - !getQuicSessionState(this.#inner.session).isPrioritySupported) return null; - const packed = this.#handle.getPriority(); - const urgency = packed >> 1; - const incremental = !!(packed & 1); - const level = urgency < 3 ? 'high' : urgency > 3 ? 'low' : 'default'; - return { level, incremental }; - } - - /** - * Sets the priority of the stream. - * @param {StreamPriority} [options] - */ - setPriority(options = kEmptyObject) { - assertIsQuicStream(this); - if (this.destroyed) return; - if (!getQuicSessionState(this.#inner.session).isPrioritySupported) { - throw new ERR_INVALID_STATE( - 'The session does not support stream priority'); - } - validateObject(options, 'options'); - const { - level = 'default', - incremental = false, - } = options; - validateOneOf(level, 'options.level', ['default', 'low', 'high']); - validateBoolean(incremental, 'options.incremental'); - const urgency = level === 'high' ? 0 : level === 'low' ? 7 : 3; - this.#handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); - } - - /** - * Send a block of headers. The headers are formatted as an array - * of key, value pairs. The reason we don't use a Headers object - * here is because this needs to be able to represent headers like - * :method which the high-level Headers API does not allow. - * - * Note that QUIC in general does not support headers. This method - * is in place to support HTTP3 and is therefore not generally - * exposed except via a private symbol. - * @param {object} headers - * @returns {boolean} true if the headers were scheduled to be sent. - */ - [kSendHeaders](headers, kind = kHeadersKindInitial, - flags = kHeadersFlagsTerminal) { - validateObject(headers, 'headers'); - if (getQuicSessionState(this.#inner.session).headersSupported === 2) { - throw new ERR_INVALID_STATE( - 'The negotiated QUIC application protocol does not support headers'); - } - if (this.pending) { - debug('pending stream enqueuing headers', headers); - } else { - debug(`stream ${this.id} sending headers`, headers); - } - const headerString = buildNgHeaderString( - headers, - assertValidPseudoHeader, - true, // This could become an option in future - ); - return this.#handle.sendHeaders(kind, headerString, flags); - } - [kFinishClose](error) { const inner = this.#inner; inner.pendingClose ??= PromiseWithResolvers(); @@ -2534,13 +2109,7 @@ class QuicStream { inner.pendingClose.resolve = undefined; inner.onblocked = undefined; inner.onreset = undefined; - inner.onheaders = undefined; inner.onerror = undefined; - inner.ontrailers = undefined; - inner.oninfo = undefined; - inner.onwanttrailers = undefined; - inner.headers = undefined; - inner.pendingTrailers = undefined; this.#handle = undefined; if (inner.fileHandle !== undefined) { // Close the FileHandle that was used as a body source. The close @@ -2587,66 +2156,6 @@ class QuicStream { safeCallbackInvoke(inner.onreset, this, error); } - [kHeaders](headers, kind) { - const block = parseHeaderPairs(headers); - const kindName = kHeadersKindName[kind] ?? kind; - const inner = this.#inner; - - switch (kindName) { - case 'initial': - assert(inner.onheaders, 'Unexpected stream headers event'); - inner.headers ??= block; - if (onStreamHeadersChannel.hasSubscribers) { - onStreamHeadersChannel.publish({ - __proto__: null, - stream: this, - session: inner.session, - headers: block, - }); - } - safeCallbackInvoke(inner.onheaders, this, block); - break; - case 'trailing': - if (onStreamTrailersChannel.hasSubscribers) { - onStreamTrailersChannel.publish({ - __proto__: null, - stream: this, - session: inner.session, - trailers: block, - }); - } - if (inner.ontrailers) - safeCallbackInvoke(inner.ontrailers, this, block); - break; - case 'hints': - if (onStreamInfoChannel.hasSubscribers) { - onStreamInfoChannel.publish({ - __proto__: null, - stream: this, - session: inner.session, - headers: block, - }); - } - if (typeof inner.oninfo === 'function') - safeCallbackInvoke(inner.oninfo, this, block); - break; - } - } - - [kTrailers]() { - if (this.destroyed) return; - const inner = this.#inner; - - // nghttp3 is asking us to provide trailers to send. - // Check for pre-set pendingTrailers first, then the callback. - if (inner.pendingTrailers) { - this.sendTrailers(inner.pendingTrailers); - inner.pendingTrailers = undefined; - } else if (typeof inner.onwanttrailers === 'function') { - safeCallbackInvoke(inner.onwanttrailers, this); - } - } - [kInspect](depth, options) { if (depth < 0) { return 'QuicStream { }'; @@ -2707,8 +2216,6 @@ class QuicSession { onhandshake: undefined, onnewtoken: undefined, onearlyrejected: undefined, - onorigin: undefined, - ongoaway: undefined, onkeylog: undefined, onqlog: undefined, pendingQlog: undefined, @@ -2742,6 +2249,14 @@ class QuicSession { assertIsQuicSession(session); return session.#inner.state; }; + + // Marks the session as having a close pending: no new streams or + // datagrams may be initiated locally. Used by protocol applications to + // report that the peer initiated a graceful shutdown of the session. + markSessionClosing = function(session) { + assertIsQuicSession(session); + session.#inner.isPendingClose = true; + }; } /** @@ -2775,14 +2290,6 @@ class QuicSession { debug('session created'); } - get applicationOptions() { - // We don't cache application options because they may be updated by the - // C++ layer after session creation depending on the behavior of the - // application. - if (this.destroyed) return null; - return this.#handle.applicationOptions(); - } - get localTransportParams() { if (this.#inner.localTransportParams !== undefined) { return this.#inner.localTransportParams; @@ -2827,6 +2334,16 @@ class QuicSession { return this.#handle === undefined || this.#inner.isPendingClose; } + /** + * Accessor to allow applications (e.g. http3) to access the session handle + * directly for internal operations. Returns undefined once destroyed. + * @type {object|undefined} + */ + get [kSessionHandle]() { + assertIsQuicSession(this); + return this.#handle; + } + /** @type {Function|undefined} */ get onerror() { assertIsQuicSession(this); @@ -2873,6 +2390,26 @@ class QuicSession { } } + /** + * The SNI servername, or undefined when none was sent. + * @type {string|undefined} + */ + get servername() { + assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#handle.getServername(); + } + + /** + * The negotiated ALPN protocol. + * @type {string|undefined} + */ + get alpnProtocol() { + assertIsQuicSession(this); + if (this.destroyed) return undefined; + return this.#handle.getAlpnProtocol(); + } + /** @type {OnDatagramCallback} */ get ondatagram() { assertIsQuicSession(this); @@ -2993,25 +2530,6 @@ 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); @@ -3082,42 +2600,6 @@ 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. @@ -3232,19 +2714,9 @@ class QuicSession { validateObject(options, 'options'); const { body, - priority = 'default', - incremental = false, highWaterMark = kDefaultHighWaterMark, - headers, - onheaders, - ontrailers, - oninfo, - onwanttrailers, } = options; - validateOneOf(priority, 'options.priority', ['default', 'low', 'high']); - validateBoolean(incremental, 'options.incremental'); - const validatedBody = validateBody(body); const handle = this.#handle.openStream(direction, validatedBody); @@ -3252,11 +2724,6 @@ class QuicSession { throw new ERR_QUIC_OPEN_STREAM_FAILED(); } - if (inner.state.isPrioritySupported) { - const urgency = priority === 'high' ? 0 : priority === 'low' ? 7 : 3; - handle.setPriority((urgency << 1) | (incremental ? 1 : 0)); - } - const stream = new QuicStream( kPrivateConstructor, handle, this, direction, true /* isLocal */); inner.streams.add(stream); @@ -3273,16 +2740,6 @@ class QuicSession { // Set the high water mark for backpressure. stream.highWaterMark = highWaterMark; - // Set stream callbacks before sending headers to avoid missing events. - if (onheaders) stream.onheaders = onheaders; - if (ontrailers) stream.ontrailers = ontrailers; - if (oninfo) stream.oninfo = oninfo; - if (onwanttrailers) stream.onwanttrailers = onwanttrailers; - - if (headers !== undefined) { - stream.sendHeaders(headers, { terminal: validatedBody === undefined }); - } - if (onSessionOpenStreamChannel.hasSubscribers) { onSessionOpenStreamChannel.publish({ __proto__: null, @@ -3599,13 +3056,10 @@ class QuicSession { inner.ondatagramstatus = undefined; inner.onpathvalidation = undefined; inner.onsessionticket = undefined; - inner.onapplication = undefined; inner.onkeylog = undefined; inner.onversionnegotiation = undefined; inner.onhandshake = undefined; inner.onnewtoken = undefined; - inner.onorigin = undefined; - inner.ongoaway = undefined; inner.path = undefined; inner.certificate = undefined; inner.peerCertificate = undefined; @@ -3630,30 +3084,6 @@ class QuicSession { } } - /** - * Called when the peer sends a GOAWAY frame (HTTP/3 only). The - * lastStreamId indicates the highest stream ID the peer may have - * processed - streams above it were not processed and may be retried. - * @param {bigint} lastStreamId - */ - [kGoaway](lastStreamId) { - const inner = this.#inner; - inner.isPendingClose = true; - if (onSessionClosingChannel.hasSubscribers) { - onSessionClosingChannel.publish({ __proto__: null, session: this }); - } - if (onSessionGoawayChannel.hasSubscribers) { - onSessionGoawayChannel.publish({ - __proto__: null, - session: this, - lastStreamId, - }); - } - if (typeof inner.ongoaway === 'function') { - safeCallbackInvoke(inner.ongoaway, this, lastStreamId); - } - } - /** * @param {number} errorType * @param {number} code @@ -3736,10 +3166,7 @@ class QuicSession { * @param {boolean} early A boolean indicating whether this datagram was received before the handshake completed */ [kDatagram](u8, early) { - // The datagram event should only be called if the session has - // an ondatagram callback. The callback should always exist here. const inner = this.#inner; - assert(typeof inner.ondatagram === 'function', 'Unexpected datagram event'); if (this.destroyed) return; const length = TypedArrayPrototypeGetByteLength(u8); if (onSessionReceiveDatagramChannel.hasSubscribers) { @@ -3750,6 +3177,7 @@ class QuicSession { session: this, }); } + assert(typeof inner.ondatagram === 'function', 'Unexpected datagram event'); safeCallbackInvoke(inner.ondatagram, this, u8, early); } @@ -3828,23 +3256,6 @@ class QuicSession { safeCallbackInvoke(inner.onsessionticket, this, ticket); } - /** - * @param {ApplicationOptions} applicationoptions - */ - [kSessionApplication](applicationoptions) { - if (this.destroyed) return; - if (onSessionApplicationChannel.hasSubscribers) { - onSessionApplicationChannel.publish({ - __proto__: null, - applicationoptions, - session: this, - }); - } - const inner = this.#inner; - if (typeof inner.onapplication === 'function') - safeCallbackInvoke(inner.onapplication, this, applicationoptions); - } - /** * @param {Buffer} token * @param {SocketAddress} address @@ -3906,24 +3317,6 @@ class QuicSession { this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR()); } - /** - * Called when the session receives an ORIGIN frame (RFC 9412). - * @param {string[]} origins - */ - [kOrigin](origins) { - if (this.destroyed) return; - const inner = this.#inner; - assert(typeof inner.onorigin === 'function', 'Unexpected origin event'); - if (onSessionOriginChannel.hasSubscribers) { - onSessionOriginChannel.publish({ - __proto__: null, - origins, - session: this, - }); - } - safeCallbackInvoke(inner.onorigin, this, origins); - } - /** * @param {string} servername * @param {string} protocol @@ -4028,8 +3421,8 @@ class QuicSession { // Set the default high water mark for received streams. stream.highWaterMark = kDefaultHighWaterMark; - // A new stream was received. If we don't have an onstream callback, then - // there's nothing we can do about it. Destroy the stream in this case. + // A new stream was received. If no onstream callback can claim it, + // drop it. if (typeof inner.onstream !== 'function') { process.emitWarning('A new stream was received but no onstream callback was provided'); stream.destroy(); @@ -4043,16 +3436,6 @@ class QuicSession { markPromiseAsHandled(stream.closed); } - // Apply default stream callbacks set at listen time before - // notifying onstream, so the user sees them already set. - const scbs = this[kStreamCallbacks]; - if (scbs) { - if (scbs.onheaders) stream.onheaders = scbs.onheaders; - if (scbs.ontrailers) stream.ontrailers = scbs.ontrailers; - if (scbs.oninfo) stream.oninfo = scbs.oninfo; - if (scbs.onwanttrailers) stream.onwanttrailers = scbs.onwanttrailers; - } - if (onSessionReceivedStreamChannel.hasSubscribers) { onSessionReceivedStreamChannel.publish({ __proto__: null, @@ -4418,20 +3801,12 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - // Stream-level callbacks applied to each incoming stream. - onheaders, - ontrailers, - oninfo, - onwanttrailers, ...rest } = options; - // Store session and stream callbacks to apply to each new incoming session. + // Store session callbacks to apply to each new incoming session. inner.sessionCallbacks = { __proto__: null, onerror, @@ -4444,15 +3819,8 @@ class QuicEndpoint { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - onheaders, - ontrailers, - oninfo, - onwanttrailers, }; this.#handle.listen(rest); @@ -4944,35 +4312,35 @@ function processTlsOptions(tls, forServer) { validateBoolean(tlsTrace, 'options.tlsTrace'); // 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 ? - (ArrayIsArray(alpn) ? alpn : [alpn]) : - [alpn]; - if (!forServer) { - validateString(alpn, 'options.alpn'); - } - let totalLen = 0; - for (let i = 0; i < protocols.length; i++) { - validateString(protocols[i], `options.alpn[${i}]`); - if (protocols[i].length === 0 || protocols[i].length > 255) { - throw new ERR_INVALID_ARG_VALUE(`options.alpn[${i}]`, protocols[i], - 'must be between 1 and 255 characters'); - } - totalLen += 1 + protocols[i].length; - } - // Build wire format: [len1][name1][len2][name2]... - const buf = Buffer.allocUnsafe(totalLen); - let offset = 0; - for (let i = 0; i < protocols.length; i++) { - buf[offset++] = protocols[i].length; - buf.write(protocols[i], offset, 'ascii'); - offset += protocols[i].length; - } - encodedAlpn = buf.toString('latin1'); - } + if (alpn === undefined) { + throw new ERR_INVALID_ARG_VALUE( + 'options.alpn', alpn, + 'is required'); + } + const protocols = forServer ? + (ArrayIsArray(alpn) ? alpn : [alpn]) : + [alpn]; + if (!forServer) { + validateString(alpn, 'options.alpn'); + } + let totalLen = 0; + for (let i = 0; i < protocols.length; i++) { + validateString(protocols[i], `options.alpn[${i}]`); + if (protocols[i].length === 0 || protocols[i].length > 255) { + throw new ERR_INVALID_ARG_VALUE(`options.alpn[${i}]`, protocols[i], + 'must be between 1 and 255 characters'); + } + totalLen += 1 + protocols[i].length; + } + // Build wire format: [len1][name1][len2][name2]... + const buf = Buffer.allocUnsafe(totalLen); + let offset = 0; + for (let i = 0; i < protocols.length; i++) { + buf[offset++] = protocols[i].length; + buf.write(protocols[i], offset, 'ascii'); + offset += protocols[i].length; + } + const encodedAlpn = buf.toString('latin1'); if (ca !== undefined) { const caInputs = ArrayIsArray(ca) ? ca : [ca]; @@ -5054,12 +4422,14 @@ function processTlsOptions(tls, forServer) { if (identity.certs === undefined) { throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].certs`); } - // Extract ORIGIN frame options from the SNI entry. + // Extract the origin-advertisement options from the SNI entry; + // they are passed through to the protocol application. const { port, authoritative, } = sni[hostname]; - // Build a full TLS options object: shared + identity + origin options. + // Build a full TLS options object: shared + identity + + // origin-advertisement options. sniEntries[hostname] = { __proto__: null, ...shared, @@ -5148,6 +4518,7 @@ function processSessionOptions(options, config = kEmptyObject) { qlog = false, sessionTicket, token, + appTicketData, maxPayloadSize, unacknowledgedPacketThreshold = 0, handshakeTimeout, @@ -5161,9 +4532,7 @@ function processSessionOptions(options, config = kEmptyObject) { maxDatagramSendAttempts = 5, streamIdleTimeout, verifyPeer = 'auto', - // HTTP/3 application-specific options. Nested under `application` - // to separate protocol-specific settings from transport-level ones. - application = kEmptyObject, + // Session callbacks that can be set at construction time to avoid // race conditions with events that fire during or immediately // after the handshake. @@ -5177,17 +4546,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, - oninfo, - onwanttrailers, } = options; const { @@ -5195,6 +4555,13 @@ function processSessionOptions(options, config = kEmptyObject) { targetAddress, } = config; + // The protocol application to install (internal-only; set by the + // application's consumer layer, e.g. node:http3). Bad values are core + // bugs, not user errors, so this is an internal assert. + const application = options[kApplication]; + assert(application === undefined || typeof application === 'string', + 'options[kApplication] must be a registered application name'); + if (token !== undefined) { if (!isArrayBufferView(token)) { throw new ERR_INVALID_ARG_TYPE('options.token', @@ -5202,6 +4569,18 @@ function processSessionOptions(options, config = kEmptyObject) { } } + if (appTicketData !== undefined) { + if (!isArrayBufferView(appTicketData)) { + throw new ERR_INVALID_ARG_TYPE('options.appTicketData', + ['ArrayBufferView'], appTicketData); + } + if (application !== undefined) { + throw new ERR_INVALID_ARG_VALUE( + 'options.appTicketData', appTicketData, + 'cannot be combined with a session application'); + } + } + if (cc !== undefined) { validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]); } @@ -5284,12 +4663,14 @@ function processSessionOptions(options, config = kEmptyObject) { maxWindow, sessionTicket, token, + appTicketData, cc, datagramDropPolicy, drainingPeriodMultiplier, maxDatagramSendAttempts, streamIdleTimeout, application, + applicationSettings: options[kApplicationSettings], onerror, onstream, ondatagram, @@ -5300,15 +4681,8 @@ function processSessionOptions(options, config = kEmptyObject) { onhandshake, onnewtoken, onearlyrejected, - onorigin, - ongoaway, onkeylog, onqlog, - onapplication, - onheaders, - ontrailers, - oninfo, - onwanttrailers, }; } @@ -5426,9 +4800,20 @@ module.exports = { CC_ALGO_BBR, DEFAULT_CIPHERS, DEFAULT_GROUPS, - // These are exported only for internal testing purposes. + // Internal-only, protocol-neutral hooks for a protocol application's + // consumer layer (e.g. node:http3). Never exported from node:quic. + kApplication, + kApplicationSettings, + kStreamHandle, + kSessionHandle, + kApplicationOwner, + markSessionClosing, + safeCallbackInvoke, + // State accessors, used by the consumer layer (consumer-interest + // signaling) and by tests. getQuicStreamState, getQuicSessionState, + // These are exported only for internal testing purposes. getQuicEndpointState, listEndpoints, }; diff --git a/lib/internal/quic/state.js b/lib/internal/quic/state.js index 22763c1df31b68..11931526eb9e53 100644 --- a/lib/internal/quic/state.js +++ b/lib/internal/quic/state.js @@ -70,10 +70,9 @@ const { IDX_STATE_SESSION_HANDSHAKE_COMPLETED, IDX_STATE_SESSION_HANDSHAKE_CONFIRMED, IDX_STATE_SESSION_STREAM_OPEN_ALLOWED, - IDX_STATE_SESSION_PRIORITY_SUPPORTED, - IDX_STATE_SESSION_HEADERS_SUPPORTED, IDX_STATE_SESSION_WRAPPED, - IDX_STATE_SESSION_APPLICATION_TYPE, + IDX_STATE_SESSION_IS_SERVER, + IDX_STATE_SESSION_HAS_APPLICATION, IDX_STATE_SESSION_NO_ERROR_CODE, IDX_STATE_SESSION_INTERNAL_ERROR_CODE, IDX_STATE_SESSION_MAX_DATAGRAM_SIZE, @@ -116,10 +115,9 @@ assert(IDX_STATE_SESSION_STATELESS_RESET !== undefined); assert(IDX_STATE_SESSION_HANDSHAKE_COMPLETED !== undefined); assert(IDX_STATE_SESSION_HANDSHAKE_CONFIRMED !== undefined); assert(IDX_STATE_SESSION_STREAM_OPEN_ALLOWED !== undefined); -assert(IDX_STATE_SESSION_PRIORITY_SUPPORTED !== undefined); -assert(IDX_STATE_SESSION_HEADERS_SUPPORTED !== undefined); +assert(IDX_STATE_SESSION_HAS_APPLICATION !== undefined); +assert(IDX_STATE_SESSION_IS_SERVER !== undefined); assert(IDX_STATE_SESSION_WRAPPED !== undefined); -assert(IDX_STATE_SESSION_APPLICATION_TYPE !== undefined); assert(IDX_STATE_SESSION_NO_ERROR_CODE !== undefined); assert(IDX_STATE_SESSION_INTERNAL_ERROR_CODE !== undefined); assert(IDX_STATE_SESSION_MAX_DATAGRAM_SIZE !== undefined); @@ -349,7 +347,6 @@ class QuicSessionState { static #LISTENER_SESSION_TICKET = 1 << 3; static #LISTENER_NEW_TOKEN = 1 << 4; static #LISTENER_ORIGIN = 1 << 5; - static #LISTENER_APPLICATION = 1 << 6; #getListenerFlag(flag) { const handle = this.#handle; @@ -368,13 +365,6 @@ class QuicSessionState { val ? (current | flag) : (current & ~flag), kIsLittleEndian); } - /** @type {boolean} */ - get hasApplicationListener() { - return this.#getListenerFlag(QuicSessionState.#LISTENER_APPLICATION); - } - set hasApplicationListener(val) { - this.#setListenerFlag(QuicSessionState.#LISTENER_APPLICATION, val); - } /** @type {boolean} */ get hasPathValidationListener() { @@ -473,22 +463,15 @@ class QuicSessionState { return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_STREAM_OPEN_ALLOWED) !== 0; } - /** @type {boolean} */ - get isPrioritySupported() { - const handle = this.#handle; - if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_PRIORITY_SUPPORTED) !== 0; - } - /** - * Whether the negotiated application protocol supports headers. - * Returns 0 (unknown), 1 (supported), or 2 (not supported). - * @type {number} + * Whether a protocol application (vs the native raw-stream path) is + * installed on the session. + * @type {boolean} */ - get headersSupported() { + get hasApplication() { const handle = this.#handle; if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HEADERS_SUPPORTED); + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_HAS_APPLICATION) !== 0; } /** @type {boolean} */ @@ -498,17 +481,21 @@ class QuicSessionState { return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_WRAPPED) !== 0; } - /** @type {number} */ - get applicationType() { + /** + * True for server (accepted) sessions, false for client (initiated) + * sessions. Fixed for the session's lifetime. + * @type {boolean} + */ + get isServer() { const handle = this.#handle; if (handle === undefined) return undefined; - return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_APPLICATION_TYPE); + return DataViewPrototypeGetUint8(handle, this.#offset + IDX_STATE_SESSION_IS_SERVER) !== 0; } /** - * The negotiated application protocol's "no error" code, populated - * by the C++ layer when the application is selected during ALPN - * negotiation. For raw QUIC this is `0n`; for HTTP/3 this is + * The installed application's "no error" code, populated + * by the C++ layer when the application is installed. + * For raw QUIC this is `0n`; for HTTP/3 this is * `0x100n` (`H3_NO_ERROR`). * @type {bigint} */ @@ -520,7 +507,7 @@ class QuicSessionState { } /** - * The negotiated application protocol's "internal error" code, + * The installed application's "internal error" code, * populated by the C++ layer when the application is selected * during ALPN negotiation. Used as the wire code for `RESET_STREAM` * frames when a stream is aborted without a more specific code. @@ -588,10 +575,9 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode, internalErrorCode, maxDatagramSize, @@ -613,10 +599,9 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode: `${noErrorCode}`, internalErrorCode: `${internalErrorCode}`, maxDatagramSize: `${maxDatagramSize}`, @@ -654,10 +639,9 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode, internalErrorCode, maxDatagramSize, @@ -679,10 +663,9 @@ class QuicSessionState { isHandshakeCompleted, isHandshakeConfirmed, isStreamOpenAllowed, - isPrioritySupported, - headersSupported, + hasApplication, isWrapped, - applicationType, + isServer, noErrorCode, internalErrorCode, maxDatagramSize, diff --git a/lib/internal/quic/symbols.js b/lib/internal/quic/symbols.js index 665ab7ca1911c2..e41f1273ce4b7f 100644 --- a/lib/internal/quic/symbols.js +++ b/lib/internal/quic/symbols.js @@ -35,29 +35,22 @@ const kDatagram = Symbol('kDatagram'); const kDatagramStatus = Symbol('kDatagramStatus'); const kEarlyDataRejected = Symbol('kEarlyDataRejected'); const kFinishClose = Symbol('kFinishClose'); -const kGoaway = Symbol('kGoaway'); const kHandshake = Symbol('kHandshake'); const kHandshakeCompleted = Symbol('kHandshakeCompleted'); const kVerifyPeer = Symbol('kVerifyPeer'); -const kHeaders = Symbol('kHeaders'); const kKeylog = Symbol('kKeylog'); const kListen = Symbol('kListen'); const kQlog = Symbol('kQlog'); const kNewSession = Symbol('kNewSession'); const kNewStream = Symbol('kNewStream'); const kNewToken = Symbol('kNewToken'); -const kStreamCallbacks = Symbol('kStreamCallbacks'); -const kOrigin = Symbol('kOrigin'); const kOwner = Symbol('kOwner'); const kPathValidation = Symbol('kPathValidation'); const kPrivateConstructor = Symbol('kPrivateConstructor'); const kRemoveSession = Symbol('kRemoveSession'); const kRemoveStream = Symbol('kRemoveStream'); const kReset = Symbol('kReset'); -const kSendHeaders = Symbol('kSendHeaders'); -const kSessionApplication = Symbol('kSessionApplication'); const kSessionTicket = Symbol('kSessionTicket'); -const kTrailers = Symbol('kTrailers'); const kVersionNegotiation = Symbol('kVersionNegotiation'); module.exports = { @@ -69,11 +62,9 @@ module.exports = { kDrain, kEarlyDataRejected, kFinishClose, - kGoaway, kHandshake, kHandshakeCompleted, kVerifyPeer, - kHeaders, kInspect, kKeylog, kKeyObjectHandle, @@ -81,8 +72,6 @@ module.exports = { kNewSession, kNewStream, kNewToken, - kStreamCallbacks, - kOrigin, kOwner, kQlog, kPathValidation, @@ -90,10 +79,7 @@ module.exports = { kRemoveSession, kRemoveStream, kReset, - kSendHeaders, - kSessionApplication, kSessionTicket, - kTrailers, kVersionNegotiation, }; diff --git a/src/node_builtins.cc b/src/node_builtins.cc index c19fe65cb102fe..c9bc85dd6c694c 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -138,7 +138,8 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { #endif // !HAVE_OPENSSL #ifndef OPENSSL_NO_QUIC "internal/quic/quic", "internal/quic/symbols", "internal/quic/stats", - "internal/quic/state", + "internal/quic/state", "internal/quic/diagnostics", + "internal/quic/http3", #endif // !OPENSSL_NO_QUIC #if HAVE_DTLS "internal/dtls/dtls", "internal/dtls/symbols", "internal/dtls/stats", @@ -149,6 +150,7 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { #endif // !HAVE_FFI "dtls", // Experimental. "ffi", // Experimental. + "http3", // Experimental. "quic", // Experimental. "sqlite", // Experimental. "stream/iter", // Experimental. diff --git a/src/quic/README.md b/src/quic/README.md index 9583acbcd76417..c7c8d20c44ebaf 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 (optional) │ │ Stream — Bidirectional data flow │ ├─────────────────────────────────────────────┤ │ ngtcp2 / nghttp3 / OpenSSL │ @@ -25,10 +25,11 @@ 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. +**Sessions**. Each Session wraps an `ngtcp2_conn` and may delegate +protocol-specific behavior to an optional **Application**. With no +Application, the Session handles raw streams itself. Sessions contain +**Streams** — bidirectional or unidirectional data channels that +carry application data. ## File Map @@ -52,13 +53,13 @@ data channels that carry application data. ### Core -| File | Purpose | -| ------------------ | ------------------------------------------------------------ | -| `endpoint.h/cc` | `Endpoint` — UDP binding, packet dispatch, retry/validation | -| `session.h/cc` | `Session` — QUIC connection state machine (\~3,500 lines) | -| `streams.h/cc` | `Stream`, `Outbound`, `PendingStream` — data flow | -| `application.h/cc` | `Session::Application` base + `DefaultApplication` | -| `http3.h/cc` | `Http3ApplicationImpl` — nghttp3 integration (\~1,400 lines) | +| File | Purpose | +| ------------------ | ----------------------------------------------------------- | +| `endpoint.h/cc` | `Endpoint` — UDP binding, packet dispatch, retry/validation | +| `session.h/cc` | `Session` — QUIC connection state machine (\~4,700 lines) | +| `streams.h/cc` | `Stream`, `Outbound`, `PendingStream` — data flow | +| `application.h/cc` | `Session::Application` base + name-keyed factory registry | +| `http3.h/cc` | `Http3Application` — nghttp3 integration (\~1,950 lines) | ### Infrastructure @@ -134,21 +135,35 @@ re-reading from the source. ### Application Abstraction -`Session::Application` is a virtual interface that the Session delegates -ALPN-specific behavior to. Two implementations exist: +`Session::Application` is a protocol-agnostic virtual interface that the +Session delegates protocol-specific behavior to. Implementations register +under a name via `RegisterApplicationFactory`; a Session installs one from +its `options.application` option, if the application is known in advance, or +a consumer attaches one later to wrap the session (see timing below). +When no application is requested or attached, the Session runs a native +raw-stream path itself. -* **`DefaultApplication`** (`application.cc`): Used for non-HTTP/3 ALPN - protocols. Maintains its own stream scheduling queue. Streams are scheduled - via an intrusive linked list. +In effect, an Application acts as an optimization layer for built-in protocols +like HTTP/3: instead of working entirely through the JS interface, it +integrates at the C++ layer for performance and low-level control. -* **`Http3ApplicationImpl`** (`http3.cc`): Used when ALPN negotiates `h3`. - Wraps `nghttp3_conn` for HTTP/3 framing, header compression (QPACK), - server push, and stream prioritization. Manages unidirectional control - streams internally. +One implementation currently ships in core: -The Application is selected during ALPN negotiation — immediately for -clients (ALPN known upfront), during the `OnSelectAlpn` TLS callback for -servers. +* **`Http3Application`** (`http3.cc`): Registered under the name `http3`. + Wraps `nghttp3_conn` for HTTP/3 framing, header compression (QPACK), and + stream prioritization. Manages unidirectional control streams internally. + +Install timing has two paths. Using the `options.application` option configured +up front, the Application is installed early, so it exists before any 0-RTT +early data: immediately at construction for clients, and during the TLS +callback for servers. Alternatively, the consumer can install it later to +wrap an existing session - this must occur either synchronously in the +server's 'session' emit frame, or for a client before its handshake completes. +Effectively the application must be attached before any events are emitted. + +Late attach only supports 1-RTT data for now, but can be expanded to support +0-RTT use cases in future. Attaching once the session is active (`is_active()`, +i.e. delivering events to JS) is rejected. ### Thread-Local Allocator @@ -179,12 +194,14 @@ succeed but memory tracking is silently skipped. **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()`. An Application configured statically is +installed immediately, or alternatively consumers can install one later. **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, during ALPN -negotiation in the TLS handshake. +→ `ngtcp2_conn_server_new()`. An Application configured statically is +installed during the initial handshake; otherwise a consumer may install +one in the session-delivery tick. ### The Receive Path @@ -341,7 +358,7 @@ connections from the same address to skip validation entirely. ## HTTP/3 Application (`http3.cc`) -The `Http3ApplicationImpl` wraps `nghttp3_conn` and handles: +The `Http3Application` wraps `nghttp3_conn` and handles: * **Header compression**: QPACK encoding/decoding via nghttp3's internal encoder/decoder streams (unidirectional). @@ -360,13 +377,12 @@ The `Http3ApplicationImpl` wraps `nghttp3_conn` and handles: deferred to `PostReceive()` (outside callback scopes) so it can safely invoke JavaScript. * **Settings**: HTTP/3 SETTINGS (max field section size, QPACK capacities, - CONNECT protocol, datagrams) are negotiated and enforced. Datagram - support follows RFC 9297 — when the peer's SETTINGS disable datagrams, - `sendDatagram()` is blocked. -* **0-RTT**: Early data settings are validated during ticket extraction - (`ValidateTicketData` in `ExtractSessionTicketAppData`). If the server's - settings changed incompatibly, the ticket is rejected before TLS accepts - it. + CONNECT protocol) are negotiated and enforced. `SETTINGS_H3_DATAGRAM` + (RFC 9297) is reserved but not advertised (not yet supported). +* **0-RTT**: Early data settings are validated when the session ticket is + parsed (the `parse_ticket` factory hook, `ParseHttp3Ticket`). If the + server's settings changed incompatibly, the ticket is rejected before + TLS accepts it. ## Error Handling diff --git a/src/quic/application.cc b/src/quic/application.cc index ce5d5e12154d8a..a41d41f58d2c70 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -4,187 +4,57 @@ #ifndef OPENSSL_NO_QUIC #include #include -#include -#include -#include -#include #include -#include +#include +#include +#include #include "application.h" #include "defs.h" -#include "endpoint.h" -#include "http3.h" -#include "packet.h" #include "session.h" namespace node { - -using v8::BigInt; -using v8::Boolean; -using v8::DictionaryTemplate; -using v8::Just; -using v8::Local; -using v8::Maybe; -using v8::MaybeLocal; -using v8::Nothing; -using v8::Object; -using v8::Value; - namespace quic { // ============================================================================ -// Session::Application_Options -const Session::Application_Options Session::Application_Options::kDefault = {}; - -Session::Application_Options::operator const nghttp3_settings() const { - // In theory, Application::Options might contain options for more than just - // HTTP/3. Here we extract only the properties that are relevant to HTTP/3. - // Later if we add more application types we can add more properties or - // divide this up into multiple option structs. - return nghttp3_settings{ - .max_field_section_size = max_field_section_size, - .qpack_max_dtable_capacity = - static_cast(qpack_max_dtable_capacity), - .qpack_encoder_max_dtable_capacity = - static_cast(qpack_encoder_max_dtable_capacity), - .qpack_blocked_streams = static_cast(qpack_blocked_streams), - .enable_connect_protocol = enable_connect_protocol, - .h3_datagram = enable_datagrams, - // origin_list is nullptr here because it is set directly on the - // nghttp3_settings in Http3ApplicationImpl::InitializeConnection() - // from the SNI configuration. - .origin_list = nullptr, - .glitch_ratelim_burst = 1000, - .glitch_ratelim_rate = 33, - .qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_EAGER, - }; -} - -std::string Session::Application_Options::ToString() const { - DebugIndentScope indent; - auto prefix = indent.Prefix(); - std::string res("{"); - res += prefix + "max header pairs: " + std::to_string(max_header_pairs); - res += prefix + "max header length: " + std::to_string(max_header_length); - res += prefix + - "max field section size: " + std::to_string(max_field_section_size); - res += prefix + "qpack max dtable capacity: " + - std::to_string(qpack_max_dtable_capacity); - res += prefix + "qpack encoder max dtable capacity: " + - std::to_string(qpack_encoder_max_dtable_capacity); - res += prefix + - "qpack blocked streams: " + std::to_string(qpack_blocked_streams); - res += prefix + "enable connect protocol: " + - (enable_connect_protocol ? std::string("yes") : std::string("no")); - res += prefix + "enable datagrams: " + - (enable_datagrams ? std::string("yes") : std::string("no")); - res += indent.Close(); - return res; -} - -Maybe Session::Application_Options::From( - Environment* env, Local value) { - if (value.IsEmpty()) [[unlikely]] { - THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object"); - return Nothing(); - } +// The application factory registry. - Application_Options options; - auto& state = BindingData::Get(env); - -#define SET(name) \ - SetOption( \ - env, &options, params, state.name##_string()) +namespace { +struct ApplicationFactoryEntry { + std::string name; + ApplicationFactory factory; +}; +// Process-wide registry. Registered once per process at binding +// initialization (registration is idempotent for repeated binding inits, +// e.g. workers); intentionally leaked, process lifetime. +std::mutex application_factories_mutex; +std::vector* application_factories = nullptr; +} // namespace - if (!value->IsUndefined()) { - if (!value->IsObject()) { - THROW_ERR_INVALID_ARG_TYPE(env, "options must be an object"); - return Nothing(); - } - auto params = value.As(); - if (!SET(max_header_pairs) || !SET(max_header_length) || - !SET(max_field_section_size) || !SET(qpack_max_dtable_capacity) || - !SET(qpack_encoder_max_dtable_capacity) || - !SET(qpack_blocked_streams) || !SET(enable_connect_protocol) || - !SET(enable_datagrams)) { - // The call to SetOption should have scheduled an exception to be thrown. - return Nothing(); - } +void RegisterApplicationFactory(std::string_view name, + const ApplicationFactory& factory) { + DCHECK_NOT_NULL(factory.create); + std::lock_guard lock(application_factories_mutex); + if (application_factories == nullptr) { + application_factories = new std::vector(); } - -#undef SET - - // Ensure the advertised max_field_section_size in SETTINGS is at least - // as large as max_header_length. Otherwise the peer would be told to - // restrict headers to a smaller size than what CanAddHeader accepts. - if (options.max_field_section_size < options.max_header_length) { - options.max_field_section_size = options.max_header_length; + for (const auto& entry : *application_factories) { + if (entry.factory.create == factory.create) return; } - - return Just(options); + application_factories->push_back({std::string(name), factory}); } -MaybeLocal Session::Application_Options::ToObject( - Environment* env) const { - auto& binding_data = BindingData::Get(env); - auto tmpl = binding_data.application_options_template(); - static constexpr std::string_view names[] = { - "maxHeaderPairs", - "maxHeaderLength", - "maxFieldSectionSize", - "qpackMaxDtableCapacity", - "qpackEncoderMaxDtableCapacity", - "qpackBlockedStreams", - "enableConnectProtocol", - "enableDatagrams", - }; - if (tmpl.IsEmpty()) { - tmpl = DictionaryTemplate::New(env->isolate(), names); - binding_data.set_application_options_template(tmpl); - } - MaybeLocal values[] = { - BigInt::NewFromUnsigned(env->isolate(), max_header_pairs), - BigInt::NewFromUnsigned(env->isolate(), max_header_length), - BigInt::NewFromUnsigned(env->isolate(), max_field_section_size), - BigInt::NewFromUnsigned(env->isolate(), qpack_max_dtable_capacity), - BigInt::NewFromUnsigned(env->isolate(), - qpack_encoder_max_dtable_capacity), - BigInt::NewFromUnsigned(env->isolate(), qpack_blocked_streams), - Boolean::New(env->isolate(), enable_connect_protocol), - Boolean::New(env->isolate(), enable_datagrams), - }; - static_assert(std::size(values) == std::size(names)); - - auto obj = tmpl->NewInstance(env->context(), values); - if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { - return {}; +const ApplicationFactory* FindApplicationFactory(std::string_view name) { + std::lock_guard lock(application_factories_mutex); + if (application_factories == nullptr) return nullptr; + for (const auto& entry : *application_factories) { + if (entry.name == name) return &entry.factory; } - return obj; + return nullptr; } // ============================================================================ -std::string Session::Application::StreamData::ToString() const { - DebugIndentScope indent; - - size_t total_bytes = 0; - for (size_t n = 0; n < count; n++) { - total_bytes += data[n].len; - } - - auto prefix = indent.Prefix(); - std::string res("{"); - res += prefix + "count: " + std::to_string(count); - res += prefix + "id: " + std::to_string(id); - res += prefix + "fin: " + std::to_string(fin); - res += prefix + "total: " + std::to_string(total_bytes); - res += indent.Close(); - return res; -} - -Session::Application::Application(Session* session, const Options& options) - : session_(session) {} +Session::Application::Application(Session* session) : session_(session) {} bool Session::Application::Start() { // By default there is nothing to do. Specific implementations may @@ -203,62 +73,6 @@ bool Session::Application::AcknowledgeStreamData(stream_id id, size_t datalen) { return true; } -void Session::Application::CollectSessionTicketAppData( - SessionTicket::AppData* app_data) const { - // By default, write just the application type byte. - uint8_t buf[1] = {static_cast(type())}; - app_data->Set(uv_buf_init(reinterpret_cast(buf), 1)); -} - -SessionTicket::AppData::Status -Session::Application::ExtractSessionTicketAppData( - const SessionTicket::AppData& app_data, Flag flag) { - // By default we do not have any application data to retrieve. - return flag == Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; -} - -std::optional Session::Application::ParseTicketData( - const uv_buf_t& data) { - if (data.len == 0 || data.base == nullptr) return std::nullopt; - auto app_type = - static_cast(reinterpret_cast(data.base)[0]); - switch (app_type) { - case Type::DEFAULT: - return DefaultTicketData{}; - case Type::HTTP3: - return ParseHttp3TicketData(data); - default: - return std::nullopt; - } -} - -bool Session::Application::ValidateTicketData( - const PendingTicketAppData& data, const Application_Options& options) { - if (std::holds_alternative(data)) { - // TODO(@jasnell): This validation probably belongs in http3.cc but keeping - // it here for now. - const auto& ticket = std::get(data); - return options.max_field_section_size >= ticket.max_field_section_size && - options.qpack_max_dtable_capacity >= - ticket.qpack_max_dtable_capacity && - options.qpack_encoder_max_dtable_capacity >= - ticket.qpack_encoder_max_dtable_capacity && - options.qpack_blocked_streams >= ticket.qpack_blocked_streams && - (!ticket.enable_connect_protocol || - options.enable_connect_protocol) && - (!ticket.enable_datagrams || options.enable_datagrams); - } - // DefaultTicketData always validates. - return true; -} - -Packet::Ptr Session::Application::CreateStreamDataPacket() { - return session_->endpoint().CreatePacket( - session_->remote_address(), session_->max_packet_size(), "stream data"); -} - void Session::Application::ReceiveStreamClose(Stream* stream, QuicError&& error) { DCHECK_NOT_NULL(stream); @@ -277,621 +91,6 @@ void Session::Application::ReceiveStreamReset(Stream* stream, stream->ReceiveStreamReset(final_size, std::move(error)); } -// Attempts to pack a pending datagram into the current packet. -// Returns the nwrite value from ngtcp2_conn_writev_datagram. -// On fatal error, closes the session and returns the error code. -// The caller should check: -// > 0: packet is complete, send it (pos was NOT advanced — caller -// must add nwrite to pos and send) -// NGTCP2_ERR_WRITE_MORE: datagram packed, room for more -// 0: congestion controlled or doesn't fit, datagram stays in queue -// < 0 (other): fatal error, session already closed -ssize_t Session::Application::TryWritePendingDatagram(PathStorage* path, - uint8_t* dest, - size_t destlen, - uint64_t ts) { - CHECK(session_->HasPendingDatagrams()); - auto max_attempts = session_->config().options.max_datagram_send_attempts; - - // Skip datagrams that have already exceeded the send attempt limit - // from a previous SendPendingData cycle. - while (session_->HasPendingDatagrams()) { - auto& front = session_->PeekPendingDatagram(); - if (front.send_attempts < max_attempts) break; - Debug(session_, - "Datagram %" PRIu64 " abandoned after %u attempts", - front.id, - front.send_attempts); - session_->DatagramStatus(front.id, DatagramStatus::ABANDONED); - session_->PopPendingDatagram(); - } - - if (!session_->HasPendingDatagrams()) return 0; - auto& dg = session_->PeekPendingDatagram(); - ngtcp2_vec dgvec = dg.data; - int accepted = 0; - int dg_flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; - - // PacketInfo for the datagram path. When libuv gains per-socket ECN - // marking, the value from ngtcp2 should be forwarded to the send path. - PacketInfo dg_pi; - ssize_t dg_nwrite = ngtcp2_conn_writev_datagram(*session_, - &path->path, - dg_pi, - dest, - destlen, - &accepted, - dg_flags, - dg.id, - &dgvec, - 1, - ts); - - if (accepted) { - // Nice, the datagram was accepted! - Debug(session_, "Datagram %" PRIu64 " accepted into packet", dg.id); - session_->DatagramSent(dg.id); - session_->PopPendingDatagram(); - } else { - Debug(session_, "Datagram %" PRIu64 " not accepted into packet", dg.id); - } - - switch (dg_nwrite) { - case 0: { - // If dg_nwrite is 0, we are either congestion controlled or - // there wasn't enough room in the packet for the datagram or - // we aren't in a state where we can send. - // We'll skip this attempt and return 0. - CHECK(!accepted); - dg.send_attempts++; - return 0; - } - case NGTCP2_ERR_WRITE_MORE: { - // There's still room left in the packet! - return NGTCP2_ERR_WRITE_MORE; - } - case NGTCP2_ERR_INVALID_STATE: - case NGTCP2_ERR_INVALID_ARGUMENT: { - // Non-fatal error cases. Peer either does not support datagrams - // or the datagram is too large for the peer's max. - // Abandon the datagram and signal skip by returning std::nullopt. - session_->DatagramStatus(dg.id, DatagramStatus::ABANDONED); - session_->PopPendingDatagram(); - return 0; - } - default: { - // Fatal errors: PKT_NUM_EXHAUSTED, CALLBACK_FAILURE, NOMEM, etc. - Debug(session_, "Fatal datagram error: %zd", dg_nwrite); - session_->SetLastError(QuicError::ForNgtcp2Error(dg_nwrite)); - session_->Close(CloseMethod::SILENT); - return dg_nwrite; - } - } - UNREACHABLE(); -} - -// the SendPendingData method is the primary driver for sending data from the -// application layer. It loops through available stream data and pending -// datagrams and generates packets to send until there is either no more -// data to send or we hit the maximum number of packets to send in one go. -// This method is extremely delicate. A bug in this method can break the -// entire QUIC implementation; so be very careful when making changes here -// and make sure to test thoroughly. When in doubt... don't change it. -void Session::Application::SendPendingData() { - DCHECK(!session().is_destroyed()); - if (!session().can_send_packets()) [[unlikely]] { - return; - } - // Upper bound on packets per SendPendingData call. ngtcp2's send quantum - // is typically 64 KB, which at 1200-byte minimum packet size is ~53 - // packets. 64 covers the worst case with headroom. The actual count per - // call is dynamically capped by ngtcp2_conn_get_send_quantum(). - static constexpr size_t kMaxPackets = 64; - Debug(session_, "Application sending pending data"); - // Cache the timestamp once for the entire send loop. ngtcp2 does not - // require nanosecond-accurate monotonicity within a single burst — - // a single timestamp per SendPendingData call is what other QUIC - // implementations use (e.g., quiche, msquic). When kernel-level - // packet pacing becomes available via libuv, this timestamp becomes - // the base for computing per-packet transmit timestamps. - const uint64_t ts = uv_hrtime(); - PathStorage path; - StreamData stream_data; - - bool closed = false; - - // Batch accumulation: packets are collected here and flushed via - // Session::SendBatch when the loop exits, the batch is full, or - // on early return. This enables synchronous batched delivery via - // uv_udp_try_send2 (sendmmsg) from the deferred flush path. - Packet::Ptr batch[kMaxPackets]; - PathStorage batch_paths[kMaxPackets]; - size_t batch_count = 0; - - auto flush_batch = [&] { - if (batch_count == 0) return; - session_->SendBatch(batch, batch_paths, batch_count); - batch_count = 0; - }; - - auto update_stats = OnScopeLeave([&] { - if (closed) return; - // Flush any remaining accumulated packets before updating stats. - flush_batch(); - if (session().is_destroyed()) [[unlikely]] - return; - - // Get a strong pointer to protect against potential destruction during - // updating the time and data stats. - BaseObjectPtr s(session_); - s->UpdatePacketTxTime(); - s->UpdateTimer(); - s->UpdateDataStats(); - }); - - // The maximum size of packet to create. - const size_t max_packet_size = session_->max_packet_size(); - - // The maximum number of packets to send in this call to SendPendingData. - const size_t max_packet_count = std::min( - kMaxPackets, ngtcp2_conn_get_send_quantum(*session_) / max_packet_size); - if (max_packet_count == 0) return; - - // The number of packets that have been prepared in this call. - size_t packet_send_count = 0; - - Packet::Ptr packet; - - auto ensure_packet = [&] { - if (!packet) { - packet = CreateStreamDataPacket(); - if (!packet) [[unlikely]] - return false; - } - DCHECK(packet); - return true; - }; - - // Accumulate a completed packet into the batch. - auto enqueue_packet = - [&](Packet::Ptr& pkt, size_t len, const PacketInfo& pi) { - Debug(session_, "Enqueuing packet with %zu bytes into batch", len); - pkt->Truncate(len); - pkt->set_pkt_info(pi); - path.CopyTo(&batch_paths[batch_count]); - batch[batch_count++] = std::move(pkt); - }; - - // We're going to enter a loop here to prepare and send no more than - // max_packet_count packets. - for (;;) { - // ndatalen is the amount of stream data that was accepted into the packet. - ssize_t ndatalen = 0; - - // Make sure we have a packet to write data into. - if (!ensure_packet()) [[unlikely]] { - Debug(session_, "Failed to create packet for stream data"); - // Doh! Could not create a packet. Time to bail. - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - - // The stream_data is the next block of data from the application stream. - if (GetStreamData(&stream_data) < 0) { - Debug(session_, "Application failed to get stream data"); - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - - // If we got here, we were at least successful in checking for stream data. - // There might not be any stream data to send. If there is no stream data, - // that's perfectly fine, we still need to serialize any frames we do have - // (pings, acks, datagrams, etc) so we'll just keep going. - if (stream_data.id >= 0) { - Debug(session_, "Application using stream data: %s", stream_data); - } else { - Debug(session_, "No stream data to send"); - } - if (session_->HasPendingDatagrams()) { - Debug(session_, "There are pending datagrams to send"); - } - - // Awesome, let's write our packet! - PacketInfo pi; - ssize_t nwrite = WriteVStream(&path, - &pi, - packet->data(), - &ndatalen, - packet->length(), - stream_data, - ts); - - // When ndatalen is > 0, that's our indication that stream data was accepted - // in to the packet. Yay! - if (ndatalen > 0) { - Debug(session_, - "Application accepted %zu bytes from stream %" PRIi64 - " into packet", - ndatalen, - stream_data.id); - if (!StreamCommit(&stream_data, ndatalen)) { - // Data was accepted into the packet, but for some reason adjusting - // the stream's committed data failed. Treat as fatal. - Debug(session_, "Failed to commit accepted bytes in stream"); - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - } else if (stream_data.id >= 0) { - Debug(session_, - "Application did not accept any bytes from stream %" PRIi64 - " into packet", - stream_data.id); - } - - // When nwrite is zero, it means we are congestion limited or it is - // just not our turn to send something. Re-schedule the stream if it - // had unsent data (payload or FIN) so the next timer-triggered - // SendPendingData retries it. Without this, a FIN-only send that - // hits nwrite=0 is lost forever — the stream already returned EOS - // from Pull and won't be re-scheduled by anyone else. - // We call Application::ResumeStream directly (not Session::ResumeStream) - // to avoid creating a SendPendingDataScope — we're already inside - // SendPendingData and re-entering would just hit nwrite=0 again. - if (nwrite == 0) { - Debug(session_, "Congestion or not our turn to send"); - if (stream_data.id >= 0 && (stream_data.count > 0 || stream_data.fin)) { - ResumeStream(stream_data.id); - } - return; - } - - // A negative nwrite value indicates either an error or that there is more - // data to write into the packet. - if (nwrite < 0) { - switch (nwrite) { - case NGTCP2_ERR_STREAM_DATA_BLOCKED: { - // We could not write any data for this stream into the packet because - // the flow control for the stream itself indicates that the stream - // is blocked. We'll skip and move on to the next stream. - // ndatalen = -1 means that no stream data was accepted into the - // packet, which is what we want here. - DCHECK_EQ(ndatalen, -1); - // We should only have received this error if there was an actual - // stream identified in the stream data, but let's double check. - DCHECK_GE(stream_data.id, 0); - session_->StreamDataBlocked(stream_data.id); - continue; - } - case NGTCP2_ERR_STREAM_SHUT_WR: { - // Indicates that the writable side of the stream should be closed - // locally or the stream is being reset. In either case, we can't send - // data for this stream! - Debug(session_, - "Closing stream %" PRIi64 " for writing", - stream_data.id); - // ndatalen = -1 means that no stream data was accepted into the - // packet, which is what we want here. - DCHECK_EQ(ndatalen, -1); - // We should only have received this error if there was an actual - // stream identified in the stream data, but let's double check. - DCHECK_GE(stream_data.id, 0); - if (stream_data.stream) [[likely]] { - stream_data.stream->EndWritable(); - } - // Notify the application that the stream's write side is shut - // so it stops queuing data. Without this, GetStreamData would - // keep returning the same stream and we'd loop forever. - StreamWriteShut(stream_data.id); - continue; - } - case NGTCP2_ERR_WRITE_MORE: { - Debug(session_, "Packet buffer not full, coalesce more data into it"); - // Room for more in this packet. Try to pack a pending datagram - // if there is one. Otherwise just loop around and keep going. - if (session_->HasPendingDatagrams()) { - auto result = TryWritePendingDatagram( - &path, packet->data(), packet->length(), ts); - // When result is 0, either the datagram was congestion controlled, - // didn't fit in the packet, or was abandoned. Skip and continue. - - // When result is > 0, the packet is done and the result is the - // completed size of the packet we're sending. - if (result > 0) { - size_t len = result; - Debug(session_, "Sending packet with %zu bytes", len); - enqueue_packet(packet, len, pi); - if (++packet_send_count == max_packet_count) return; - } else if (result < 0) { - // Any negative result other than NGTCP2_ERR_WRITE_MORE - // at this point is fatal. The session will have been - // closed. - if (result != NGTCP2_ERR_WRITE_MORE) return; - } - } - continue; - } - case NGTCP2_ERR_CALLBACK_FAILURE: { - // This case really should not happen. It indicates that the - // ngtcp2 callback failed for some reason. This would be a - // bug in our code. - Debug(session_, "Internal failure with ngtcp2 callback"); - session_->SetLastError( - QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - } - - // Some other type of error happened. - DCHECK_EQ(ndatalen, -1); - Debug(session_, - "Application encountered error while writing packet: %s", - ngtcp2_strerror(nwrite)); - session_->SetLastError(QuicError::ForNgtcp2Error(nwrite)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - - // At this point we have a packet prepared to send. The nwrite - // is the size of the packet we are sending. - size_t len = nwrite; - Debug(session_, "Sending packet with %zu bytes", len); - enqueue_packet(packet, len, pi); - if (++packet_send_count == max_packet_count) return; - - // If there are pending datagrams, try sending them in a fresh packet. - // This is necessary because ngtcp2_conn_writev_stream only returns - // NGTCP2_ERR_WRITE_MORE when there is actual stream data — when no - // streams are active, the coalescing path above is never reached and - // datagrams would never be sent. - if (session_->HasPendingDatagrams()) { - if (!ensure_packet()) [[unlikely]] { - Debug(session_, "Failed to create packet for datagram"); - session_->SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); - closed = true; - return session_->Close(CloseMethod::SILENT); - } - auto result = - TryWritePendingDatagram(&path, packet->data(), packet->length(), ts); - if (result > 0) { - Debug(session_, "Sending datagram packet with %zd bytes", result); - enqueue_packet(packet, static_cast(result), PacketInfo()); - if (++packet_send_count == max_packet_count) return; - } else if (result < 0 && result != NGTCP2_ERR_WRITE_MORE) { - // Fatal error — session already closed by TryWritePendingDatagram. - return; - } - // If result == 0 (congestion) or NGTCP2_ERR_WRITE_MORE (datagram - // packed but room for more), the loop continues normally. - } - } -} - -ssize_t Session::Application::WriteVStream(PathStorage* path, - PacketInfo* pi, - uint8_t* dest, - ssize_t* ndatalen, - size_t max_packet_size, - const StreamData& stream_data, - uint64_t ts) { - DCHECK_LE(stream_data.count, kMaxVectorCount); - uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; - if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; - // The PacketInfo out-param is populated by ngtcp2 with the ECN codepoint - // to apply when sending this packet. When libuv gains per-socket ECN - // marking, the value should be forwarded to the send path. - return ngtcp2_conn_writev_stream(*session_, - &path->path, - *pi, - dest, - max_packet_size, - ndatalen, - flags, - stream_data.id, - stream_data, - stream_data.count, - ts); -} - -// ============================================================================ -// The DefaultApplication is the default implementation of Session::Application -// that is used for all unrecognized ALPN identifiers. -class DefaultApplication final : public Session::Application { - public: - // Marked NOLINT because the cpp linter gets confused about this using - // statement not being sorted with the using v8 statements at the top - // of the namespace. - DefaultApplication(Session* session, const Options& options) - : Session::Application(session, options), options_(options) {} - - const Options& options() const override { return options_; } - - Session::Application::Type type() const override { - return Session::Application::Type::DEFAULT; - } - - error_code GetNoErrorCode() const override { return 0; } - - // Raw QUIC has no application-defined "general failure" code, so - // fall back to the QUIC transport-level INTERNAL_ERROR (0x1) used - // by ngtcp2 for unspecified failures. - error_code GetInternalErrorCode() const override { - return NGTCP2_INTERNAL_ERROR; - } - - void EarlyDataRejected() override { - // Destroy all open streams — ngtcp2 has already discarded their - // internal state when it rejected the early data. Use the - // application's internal error code since this is an error - // condition (code 0 would be treated as a clean close). - session().DestroyAllStreams( - QuicError::ForApplication(GetInternalErrorCode())); - if (!session().is_destroyed()) { - session().EmitEarlyDataRejected(); - } - } - - bool ApplySessionTicketData(const PendingTicketAppData& data) override { - return std::holds_alternative(data); - } - - bool ReceiveStreamOpen(stream_id id) override { - auto stream = session().CreateStream(id); - if (!stream || session().is_destroyed()) [[unlikely]] { - return !session().is_destroyed(); - } - return true; - } - - bool ReceiveStreamData(stream_id id, - const uint8_t* data, - size_t datalen, - const Stream::ReceiveDataFlags& flags, - void* stream_user_data) override { - BaseObjectPtr stream; - if (stream_user_data == nullptr) { - // This is the first time we're seeing this stream. Implicitly create it. - stream = session().CreateStream(id); - if (!stream || session().is_destroyed()) [[unlikely]] { - // We couldn't create the stream, or the session was destroyed - // during the onstream callback (via MakeCallback re-entrancy). - return false; - } - } else { - stream = BaseObjectPtr(Stream::From(stream_user_data)); - if (!stream) { - Debug(&session(), - "Default application failed to get existing stream " - "from user data"); - return false; - } - } - - CHECK(stream); - - // Now we can actually receive the data! Woo! - stream->ReceiveData(data, datalen, flags); - return true; - } - - int GetStreamData(StreamData* stream_data) override { - // Reset the state of stream_data before proceeding... - stream_data->id = -1; - stream_data->count = 0; - stream_data->fin = 0; - stream_data->stream.reset(); - Debug(&session(), "Default application getting stream data"); - DCHECK_NOT_NULL(stream_data); - // If the queue is empty, there aren't any streams with data yet - - // If the connection-level flow control window is exhausted, - // there is no point in pulling stream data. - if (!session().max_data_left()) return 0; - if (stream_queue_.IsEmpty()) return 0; - - Stream* stream = stream_queue_.PopFront(); - CHECK_NOT_NULL(stream); - stream_data->stream.reset(stream); - stream_data->id = stream->id(); - auto next = - [&](int status, const ngtcp2_vec* data, size_t count, bob::Done done) { - switch (status) { - case bob::Status::STATUS_BLOCK: - // Fall through - case bob::Status::STATUS_WAIT: - return; - case bob::Status::STATUS_EOS: - stream_data->fin = 1; - } - - // It is possible that the data pointers returned are not actually - // the data pointers in the stream_data. If that's the case, we need - // to copy over the pointers. - count = std::min(count, kMaxVectorCount); - ngtcp2_vec* dest = *stream_data; - if (dest != data) { - for (size_t n = 0; n < count; n++) { - dest[n] = data[n]; - } - } - - stream_data->count = count; - - if (count > 0) { - stream->Schedule(&stream_queue_); - } - - // Not calling done here because we defer committing - // the data until after we're sure it's written. - }; - - if (!stream->is_eos()) [[likely]] { - int ret = stream->Pull(std::move(next), - bob::Options::OPTIONS_SYNC, - stream_data->data, - arraysize(stream_data->data), - kMaxVectorCount); - if (ret == bob::Status::STATUS_EOS) { - stream_data->fin = 1; - } - } else { - stream_data->fin = 1; - } - - return 0; - } - - void ResumeStream(stream_id id) override { ScheduleStream(id); } - - void BlockStream(stream_id id) override { - if (auto stream = session().FindStream(id)) [[likely]] { - // Remove the stream from the send queue. It will be re-scheduled - // via ExtendMaxStreamData when the peer grants more flow control. - // Without this, SendPendingData would repeatedly pop and retry - // the same blocked stream in an infinite loop. - stream->Unschedule(); - stream->EmitBlocked(); - } - } - - void ExtendMaxStreamData(Stream* stream, uint64_t max_data) override { - // The peer granted more flow control for this stream. Re-schedule - // it so SendPendingData will resume writing. - DCHECK_NOT_NULL(stream); - stream->Schedule(&stream_queue_); - } - - bool StreamCommit(StreamData* stream_data, size_t datalen) override { - DCHECK_NOT_NULL(stream_data); - CHECK(stream_data->stream); - stream_data->stream->Commit(datalen, stream_data->fin); - return true; - } - - SET_SELF_SIZE(DefaultApplication) - SET_MEMORY_INFO_NAME(DefaultApplication) - SET_NO_MEMORY_INFO() - - private: - void ScheduleStream(stream_id id) { - if (auto stream = session().FindStream(id)) [[likely]] { - stream->Schedule(&stream_queue_); - } - } - - Options options_; - - Stream::Queue stream_queue_; -}; - -std::unique_ptr CreateDefaultApplication( - Session* session, const Session::Application_Options& options) { - return std::make_unique(session, options); -} - } // namespace quic } // namespace node diff --git a/src/quic/application.h b/src/quic/application.h index 0df9b9f0a0e68d..c6f3a62ded7823 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -2,8 +2,8 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include -#include +#include +#include #include "base_object.h" #include "bindingdata.h" @@ -14,44 +14,20 @@ namespace node::quic { -// Parsed session ticket application data, produced by -// Application::ParseTicketData() before ALPN negotiation and consumed -// by Application::ApplySessionTicketData() after. -struct DefaultTicketData {}; -struct Http3TicketData { - uint64_t max_field_section_size; - uint64_t qpack_max_dtable_capacity; - uint64_t qpack_encoder_max_dtable_capacity; - uint64_t qpack_blocked_streams; - bool enable_connect_protocol; - bool enable_datagrams; -}; -using PendingTicketAppData = - std::variant; +// Opaque application-typed session-ticket data, produced by a registered +// application factory's ticket hook at decrypt time and consumed by +// Application::ApplySessionTicketData() once the application is +// installed. The QUIC core never inspects the contents; only the owning +// application knows the concrete type. +using PendingTicketAppData = std::shared_ptr; -// 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: - using Options = Session::Application_Options; - - Application(Session* session, const Options& options); + explicit Application(Session* session); DISALLOW_COPY_AND_MOVE(Application) - // Get the active options for this application. These may differ from the - // options passed at construction time since some options can be negotiated. - virtual const Options& options() const = 0; - - // The type of Application, exposed via the session state so JS - // can observe which Application was selected after ALPN negotiation. - // This is used primarily for testing/debugging. - enum class Type : uint8_t { - NONE = 0, // Not yet selected (server pre-negotiation) - DEFAULT = 1, // DefaultApplication (non-h3 ALPN) - HTTP3 = 2, // Http3ApplicationImpl (h3 / h3-XX ALPN) - }; - virtual Type type() const = 0; - virtual bool Start(); // Returns true if Start() has been called successfully. @@ -103,14 +79,6 @@ class Session::Application : public MemoryRetainer { // Application. virtual bool AcknowledgeStreamData(stream_id id, size_t datalen); - // Called to determine if a Header can be added to this application. - // Applications that do not support headers will always return false. - virtual bool CanAddHeader(size_t current_count, - size_t current_headers_length, - size_t this_header_length) { - return false; - } - // Called when ngtcp2 reports NGTCP2_ERR_STREAM_SHUT_WR for a stream. // Applications that manage their own framing (e.g., HTTP/3) must inform // their protocol layer that the stream's write side is shut so it stops @@ -126,14 +94,6 @@ class Session::Application : public MemoryRetainer { // to send for the given stream. virtual void ResumeStream(stream_id id) {} - // Called when the Session determines that the maximum number of - // remotely-initiated unidirectional streams has been extended. Not all - // Application types will require this notification so the default is to do - // nothing. - virtual void ExtendMaxStreams(EndpointLabel label, - Direction direction, - uint64_t max_streams) {} - // Returns true if the application manages stream FIN internally (e.g., // HTTP/3 uses nghttp3 which sends FIN via the fin flag in writev_stream). // When true, the stream infrastructure must NOT call @@ -149,43 +109,33 @@ class Session::Application : public MemoryRetainer { // By default do nothing. } - // Different Applications may wish to set some application data in the - // session ticket (e.g. http/3 would set server settings in the application - // data). The first byte written MUST be the Application::Type enum value. - // By default, writes just the type byte. + // Sets application data for the session ticket (e.g. http/3 settings). + // The format is private to the application: we route the data back to it by + // name at resumption via its registered ticket hook. virtual void CollectSessionTicketAppData( - SessionTicket::AppData* app_data) const; - - // Different Applications may set some application data in the session - // ticket (e.g. http/3 would set server settings in the application data). - // By default, there's nothing to get. - virtual SessionTicket::AppData::Status ExtractSessionTicketAppData( - const SessionTicket::AppData& app_data, - SessionTicket::AppData::Source::Flag flag); - - // Validates parsed ticket data against current application options. - // Returns false if the stored settings are more permissive than the - // current config (e.g., a feature was enabled when the ticket was - // issued but is now disabled). - static bool ValidateTicketData(const PendingTicketAppData& data, - const Application_Options& options); - - // Parse session ticket app data before ALPN negotiation. Reads the - // type byte and dispatches to the appropriate application-specific - // parser. Returns std::nullopt if parsing fails. - static std::optional ParseTicketData( - const uv_buf_t& data); - - // Called after ALPN negotiation to validate and apply previously - // parsed session ticket app data. Returns false if the data is - // incompatible (e.g., type mismatch or settings downgrade), which + SessionTicket::AppData* app_data) const = 0; + + // Called at install time to apply session ticket app data previously + // parsed (and validated) by this application's registered ticket hook + // at decrypt time. Returns false if the data is incompatible, which // causes the handshake to fail. virtual bool ApplySessionTicketData(const PendingTicketAppData& data) = 0; + // Returns a JS object describing the application's effective protocol + // settings (some values may have been updated by negotiation with the + // peer), or an empty result when the application exposes no settings. + // The shape is application-defined; the QUIC core never interprets it. + virtual v8::MaybeLocal GetSettingsObject(Environment* env) { + return {}; + } + // Notifies the Application that the identified stream has been closed. virtual void ReceiveStreamClose(Stream* stream, QuicError&& error = QuicError()); + // Notify the Application that this stream is about to be removed + virtual void StreamRemoved(stream_id id) {} + // Notifies the Application that the identified stream has been reset. virtual void ReceiveStreamReset(Stream* stream, uint64_t final_size, @@ -195,25 +145,6 @@ class Session::Application : public MemoryRetainer { virtual void ReceiveStreamStopSending(Stream* stream, QuicError&& error = QuicError()); - // Submits an outbound block of headers for the given stream. Not all - // Application types will support headers, in which case this function - // should return false. - virtual bool SendHeaders(const Stream& stream, - HeadersKind kind, - const v8::Local& headers, - HeadersFlags flags = HeadersFlags::NONE) { - return false; - } - - // Signals to the Application that it should serialize and transmit any - // pending session and stream packets it has accumulated. - void SendPendingData(); - - // Returns true if the application protocol supports sending and - // receiving headers on streams (e.g. HTTP/3). Applications that - // do not support headers should return false (the default). - virtual bool SupportsHeaders() const { return false; } - // Initiates application-level graceful shutdown signaling (e.g., // HTTP/3 GOAWAY). Called when Session::Close(GRACEFUL) is invoked. virtual void BeginShutdown() {} @@ -223,30 +154,6 @@ class Session::Application : public MemoryRetainer { // sends the final GOAWAY with the actual last accepted stream ID. virtual void CompleteShutdown() {} - // Set the priority level of the stream if supported by the application. Not - // all applications support priorities, in which case this function is a - // non-op. - virtual void SetStreamPriority( - const Stream& stream, - StreamPriority priority = StreamPriority::DEFAULT, - StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL) {} - - struct StreamPriorityResult { - StreamPriority priority; - StreamPriorityFlags flags; - }; - - // Get the priority level of the stream if supported by the application. Not - // all applications support priorities, in which case this function returns - // the default stream priority. - virtual StreamPriorityResult GetStreamPriority(const Stream& stream) { - return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; - } - - // The StreamData struct is used by the application to pass pending stream - // data to the session for transmission. - struct StreamData; - virtual int GetStreamData(StreamData* data) = 0; virtual bool StreamCommit(StreamData* data, size_t datalen) = 0; @@ -262,60 +169,43 @@ class Session::Application : public MemoryRetainer { } private: - Packet::Ptr CreateStreamDataPacket(); - - // Tries to pack a pending datagram into the current packet buffer. - // If < 0 is returned, either NGTCP2_ERR_WRITE_MORE or a fatal error is - // returned; the caller must check. If > 0 is returned, the packet is done - // and the value is the size of the finalized packet. If 0 is returned, - // the datagram is either congestion limited or was abandoned - ssize_t TryWritePendingDatagram(PathStorage* path, - uint8_t* dest, - size_t destlen, - uint64_t ts); - - // Write the given stream_data into the buffer. The PacketInfo out-param - // is populated by ngtcp2 with per-packet metadata (e.g., ECN codepoint) - // that should be applied when sending the packet. - ssize_t WriteVStream(PathStorage* path, - PacketInfo* pi, - uint8_t* buf, - ssize_t* ndatalen, - size_t max_packet_size, - const StreamData& stream_data, - uint64_t ts); - Session* session_ = nullptr; }; -struct Session::Application::StreamData final { - // The actual number of vectors in the struct, up to kMaxVectorCount. - size_t count = 0; - // The stream identifier. If this is a negative value then no stream is - // identified. - stream_id id = -1; - int fin = 0; - ngtcp2_vec data[kMaxVectorCount]{}; - BaseObjectPtr stream; - - static_assert(sizeof(ngtcp2_vec) == sizeof(nghttp3_vec) && - alignof(ngtcp2_vec) == alignof(nghttp3_vec) && - offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && - offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), - "ngtcp2_vec and nghttp3_vec must have identical layout"); - inline operator nghttp3_vec*() { - return reinterpret_cast(data); - } - - inline operator const ngtcp2_vec*() const { return data; } - inline operator ngtcp2_vec*() { return data; } - - std::string ToString() const; +// The registration record for a protocol-specific Session::Application +// implementation. Protocols register themselves under a name at binding +// initialization (e.g. "http3"); a session installs one only when its +// options request that name explicitly. The settings produced and +// consumed by these hooks are opaque to the QUIC core: only the +// registering protocol knows their shape or field names. +struct ApplicationFactory { + // Creates the Application for the given session. Any parsed settings + // holder produced by parse_settings is carried on the session's + // options (application_settings); implementations resolve it from + // there (using their defaults when it is nullptr). + std::unique_ptr (*create)(Session* session) = nullptr; + + // Parses the application-specific settings value, supplied through an + // internal symbol by the application's consumer layer (e.g. + // node:http3), into an opaque holder carried on Session::Options. + // Called while session options are processed; invalid user-supplied + // values should throw and return Nothing. + v8::Maybe> (*parse_settings)( + Environment* env, v8::Local value) = nullptr; + + // Parses and validates application-typed session-ticket data (the + // full payload, including the leading type byte) against the session + // options at ticket-decrypt time, before the Application instance + // exists. Returns the parsed data for the ApplySessionTicketData() + // call at install time, or nullptr to reject the ticket (0-RTT is + // abandoned and the handshake falls back to a full 1-RTT exchange). + PendingTicketAppData (*parse_ticket)( + const uv_buf_t& data, const Session::Options& options) = nullptr; }; - -// Create a DefaultApplication for the given session. -std::unique_ptr CreateDefaultApplication( - Session* session, const Session::Application_Options& options); +void RegisterApplicationFactory(std::string_view name, + const ApplicationFactory& factory); +// Returns the factory registered under name, or nullptr. +const ApplicationFactory* FindApplicationFactory(std::string_view name); } // namespace node::quic diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index 31467a8477a792..ef373540aa3fbd 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -14,6 +14,7 @@ #include #include #include "bindingdata.h" +#include "http3.h" #include "session.h" #include "session_manager.h" @@ -288,6 +289,8 @@ void nghttp3_debug_log(const char* fmt, va_list args) { void BindingData::InitPerContext(Realm* realm, Local target) { nghttp3_set_debug_vprintf_callback(nghttp3_debug_log); SetMethod(realm->context(), target, "setCallbacks", SetCallbacks); + SetMethod(realm->context(), target, "setHttp3Callbacks", SetHttp3Callbacks); + SetMethod(realm->context(), target, "createHttp3Handle", CreateHttp3Handle); Realm::GetCurrent(realm->context())->AddBindingData(target); } @@ -295,6 +298,8 @@ void BindingData::RegisterExternalReferences( ExternalReferenceRegistry* registry) { registry->Register(IllegalConstructor); registry->Register(SetCallbacks); + registry->Register(SetHttp3Callbacks); + RegisterHttp3ExternalReferences(registry); } BindingData::BindingData(Realm* realm, Local object) @@ -303,6 +308,7 @@ BindingData::BindingData(Realm* realm, Local object) MakeWeak(); // Unref so the check handle doesn't keep the event loop alive on its own. flush_check_.Unref(); + RegisterHttp3Application(); } SessionManager& BindingData::session_manager() { @@ -353,7 +359,7 @@ void BindingData::OnFlushCheck() { void BindingData::MemoryInfo(MemoryTracker* tracker) const { #define V(name, _) tracker->TrackField(#name, name##_callback()); - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V @@ -388,14 +394,12 @@ Local BindingData::transport_params_template() const { transport_params_template_); } -void BindingData::set_application_options_template( - Local tmpl) { - application_options_template_.Reset(env()->isolate(), tmpl); +void BindingData::set_http3_settings_template(Local tmpl) { + http3_settings_template_.Reset(env()->isolate(), tmpl); } -Local BindingData::application_options_template() const { - return PersistentToLocal::Default(env()->isolate(), - application_options_template_); +Local BindingData::http3_settings_template() const { + return PersistentToLocal::Default(env()->isolate(), http3_settings_template_); } #define V(name, _) \ @@ -406,7 +410,7 @@ Local BindingData::application_options_template() const { return PersistentToLocal::Default(env()->isolate(), name##_callback_); \ } -QUIC_JS_CALLBACKS(V) +QUIC_ALL_JS_CALLBACKS(V) #undef V @@ -431,7 +435,7 @@ QUIC_STRINGS(V) return on_##name##_string_.Get(env()->isolate()); \ } -QUIC_JS_CALLBACKS(V) +QUIC_ALL_JS_CALLBACKS(V) #undef V @@ -465,6 +469,28 @@ JS_METHOD_IMPL(BindingData::SetCallbacks) { #undef V } +JS_METHOD_IMPL(BindingData::SetHttp3Callbacks) { + auto env = Environment::GetCurrent(args); + auto isolate = env->isolate(); + auto& state = Get(env); + CHECK(args[0]->IsObject()); + Local obj = args[0].As(); + +#define V(name, key) \ + do { \ + Local val; \ + if (!obj->Get(env->context(), state.on_##name##_string()).ToLocal(&val) || \ + !val->IsFunction()) { \ + return THROW_ERR_MISSING_ARGS(isolate, "Missing Callback: on" #key); \ + } \ + state.set_##name##_callback(val.As()); \ + } while (0); + + QUIC_HTTP3_JS_CALLBACKS(V) + +#undef V +} + NgTcp2CallbackScope::NgTcp2CallbackScope(Session* session) : session(session) { CHECK(!session->flags_.in_ngtcp2_callback_scope); session->flags_.in_ngtcp2_callback_scope = true; diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 7879220e02b482..fb70e602cff013 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -30,20 +30,21 @@ class SessionManager; // The FunctionTemplates the BindingData will store for us. #define QUIC_CONSTRUCTORS(V) \ V(endpoint) \ + V(http3binding) \ V(session) \ V(stream) \ V(udp) // The callbacks are persistent v8::Function references that are set in the // quic::BindingState used to communicate data and events back out to the JS -// environment. They are set once from the JavaScript side when the -// internalBinding('quic') is first loaded. +// environment. The protocol-neutral set is registered once from the +// JavaScript side (via setCallbacks) when the internalBinding('quic') is +// first loaded; the HTTP/3 application-event set is registered separately +// (via setHttp3Callbacks) when the HTTP/3 consumer layer is first loaded. #define QUIC_JS_CALLBACKS(V) \ V(endpoint_close, EndpointClose) \ V(session_close, SessionClose) \ - V(session_application, SessionApplication) \ V(session_early_data_rejected, SessionEarlyDataRejected) \ - V(session_goaway, SessionGoaway) \ V(session_datagram, SessionDatagram) \ V(session_datagram_status, SessionDatagramStatus) \ V(session_handshake, SessionHandshake) \ @@ -51,7 +52,6 @@ class SessionManager; V(session_qlog, SessionQlog) \ V(session_new, SessionNew) \ V(session_new_token, SessionNewToken) \ - V(session_origin, SessionOrigin) \ V(session_path_validation, SessionPathValidation) \ V(session_ticket, SessionTicket) \ V(session_version_negotiation, SessionVersionNegotiation) \ @@ -59,10 +59,22 @@ class SessionManager; V(stream_close, StreamClose) \ V(stream_created, StreamCreated) \ V(stream_drain, StreamDrain) \ + V(stream_reset, StreamReset) + +// The HTTP/3 application-event callbacks, registered separately by the +// HTTP/3 consumer layer (lib/internal/quic/http3.js) via setHttp3Callbacks. +#define QUIC_HTTP3_JS_CALLBACKS(V) \ + V(session_application, SessionApplication) \ + V(session_goaway, SessionGoaway) \ + V(session_origin, SessionOrigin) \ V(stream_headers, StreamHeaders) \ - V(stream_reset, StreamReset) \ V(stream_trailers, StreamTrailers) +// All of the callback slots, regardless of which JS module registers them. +#define QUIC_ALL_JS_CALLBACKS(V) \ + QUIC_JS_CALLBACKS(V) \ + QUIC_HTTP3_JS_CALLBACKS(V) + // The various JS strings the implementation uses. #define QUIC_STRINGS(V) \ V(abandoned, "abandoned") \ @@ -72,7 +84,9 @@ class SessionManager; V(active_connection_id_limit, "activeConnectionIDLimit") \ V(address_lru_size, "addressLRUSize") \ V(allow, "allow") \ + V(app_ticket_data, "appTicketData") \ V(application, "application") \ + V(application_settings, "applicationSettings") \ V(authoritative, "authoritative") \ V(bbr, "bbr") \ V(ca, "ca") \ @@ -95,7 +109,7 @@ class SessionManager; V(failure, "failure") \ V(groups, "groups") \ V(handshake_timeout, "handshakeTimeout") \ - V(http3_alpn, &NGHTTP3_ALPN_H3[1]) \ + V(http3binding, "Http3Binding") \ V(initial_rtt, "initialRtt") \ V(keep_alive_timeout, "keepAlive") \ V(initial_max_data, "initialMaxData") \ @@ -293,6 +307,11 @@ class BindingData final // bridge out to the JS API. JS_METHOD(SetCallbacks); + // Installs the HTTP/3 application-event callback functions. Registered + // separately from SetCallbacks by the HTTP/3 consumer layer when it + // is first loaded in a realm. + JS_METHOD(SetHttp3Callbacks); + // Lazily-created per-Realm SessionManager. Centralizes CID -> Session // routing so that any endpoint can route packets to any session. SessionManager& session_manager(); @@ -324,13 +343,13 @@ class BindingData final void set_transport_params_template(v8::Local tmpl); v8::Local transport_params_template() const; - void set_application_options_template(v8::Local tmpl); - v8::Local application_options_template() const; + void set_http3_settings_template(v8::Local tmpl); + v8::Local http3_settings_template() const; #define V(name, _) \ void set_##name##_callback(v8::Local fn); \ v8::Local name##_callback() const; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V #define V(name, _) v8::Local name##_string() const; @@ -338,7 +357,7 @@ class BindingData final #undef V #define V(name, _) v8::Local on_##name##_string() const; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V #define V(name) v8::Global name##_constructor_template_; @@ -346,10 +365,10 @@ class BindingData final #undef V v8::Global transport_params_template_; - v8::Global application_options_template_; + v8::Global http3_settings_template_; #define V(name, _) v8::Global name##_callback_; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V #define V(name, _) mutable v8::Eternal name##_string_; @@ -357,7 +376,7 @@ class BindingData final #undef V #define V(name, _) mutable v8::Eternal on_##name##_string_; - QUIC_JS_CALLBACKS(V) + QUIC_ALL_JS_CALLBACKS(V) #undef V // Lazy cache backing error_name_string() diff --git a/src/quic/data.cc b/src/quic/data.cc index 9599adec62f805..26c68f2d8c4b56 100644 --- a/src/quic/data.cc +++ b/src/quic/data.cc @@ -426,8 +426,8 @@ MaybeLocal QuicError::ToV8Value(Environment* env) const { (type() == Type::APPLICATION && (code() == 0 || code() == NGHTTP3_H3_NO_ERROR)) || type() == Type::IDLE_CLOSE) { - // Application code 0 is the default no-error code for raw QUIC - // applications (DefaultApplication::GetNoErrorCode() returns 0). + // Application code 0 is the native no-error code for raw QUIC + // sessions (no application installed). // NGHTTP3_H3_NO_ERROR (0x100) is the HTTP/3 no-error code. // Idle close is always clean — the session timed out normally. return Undefined(env->isolate()); diff --git a/src/quic/defs.h b/src/quic/defs.h index 75ae915335be93..9a76c34ce45c07 100644 --- a/src/quic/defs.h +++ b/src/quic/defs.h @@ -2,7 +2,6 @@ #include #include -#include #include #include #include @@ -290,44 +289,11 @@ enum class Side : uint8_t { SERVER, }; -enum class EndpointLabel : uint8_t { - LOCAL, - REMOTE, -}; - enum class Direction : uint8_t { BIDIRECTIONAL, UNIDIRECTIONAL, }; -enum class HeadersKind : uint8_t { - HINTS, - INITIAL, - TRAILING, -}; - -enum class HeadersFlags : uint8_t { - NONE, - TERMINAL, -}; - -enum class StreamPriority : uint8_t { - DEFAULT = NGHTTP3_DEFAULT_URGENCY, - LOW = NGHTTP3_URGENCY_LOW, - HIGH = NGHTTP3_URGENCY_HIGH, -}; - -enum class StreamPriorityFlags : uint8_t { - NON_INCREMENTAL, - INCREMENTAL, -}; - -enum class HeadersSupportState : uint8_t { - UNKNOWN, - SUPPORTED, - UNSUPPORTED, -}; - enum class PathValidationResult : uint8_t { SUCCESS = NGTCP2_PATH_VALIDATION_RESULT_SUCCESS, FAILURE = NGTCP2_PATH_VALIDATION_RESULT_FAILURE, diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index 5a728a0a2a147e..3bc3575b977acc 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -442,8 +442,10 @@ class Endpoint::UDP::Impl final : public HandleWrap { // UV_UDP_MMSG_FREE signals the end of a recvmmsg batch — the // buffer can be reused. Since our buffer is pre-allocated and - // persistent, there is nothing to free. + // persistent, there is nothing to free. The cached batch timestamp + // is reset so the next batch takes a fresh one. if (flags & UV_UDP_MMSG_FREE) { + impl->recv_batch_ts_ = 0; return; } @@ -464,14 +466,24 @@ class Endpoint::UDP::Impl final : public HandleWrap { // UV_UDP_MMSG_CHUNK is set for each packet in a recvmmsg batch. // Processing is the same as for a single-message receive — ngtcp2 // copies what it needs synchronously from the buf slice. + uint64_t now; + if (flags & UV_UDP_MMSG_CHUNK) { + if (impl->recv_batch_ts_ == 0) impl->recv_batch_ts_ = uv_hrtime(); + now = impl->recv_batch_ts_; + } else { + now = uv_hrtime(); + } impl->endpoint_->Receive(reinterpret_cast(buf->base), static_cast(nread), - SocketAddress(addr)); + SocketAddress(addr), + now); } uv_udp_t handle_; Endpoint* endpoint_; + uint64_t recv_batch_ts_ = 0; + friend class UDP; }; @@ -814,8 +826,8 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr session) { // For server sessions, associate the client's original DCID (ocid) so // that 0-RTT packets arriving in a separate UDP datagram can be routed // to this session. This must happen after the session is added (so - // FindSession can resolve the mapping) but before EmitNewSession (which - // runs JS and may yield to libuv, allowing the 0-RTT packet to arrive). + // FindSession can resolve the mapping) and before any JS runs (which + // may yield to libuv, allowing the 0-RTT packet to arrive). if (session->is_server() && session->config().ocid) { AssociateCID(session->config().ocid, session->config().scid); } @@ -825,22 +837,21 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr session) { if (session->is_server() && session->config().retry_scid) { AssociateCID(session->config().retry_scid, session->config().scid); } - // Increment the primary session count and ref the handle BEFORE - // EmitNewSession. EmitNewSession calls into JS, which may close/destroy - // the session synchronously. The session's ~Impl calls RemoveSession - // which decrements the count. If we increment after EmitNewSession, - // RemoveSession would see count=0 and the count would be permanently - // off by one. + // Increment the primary session count and ref the handle BEFORE any + // JS can run for this session (the deferred EmitNewSession, or packet + // processing callbacks). JS may close/destroy the session + // synchronously; the session's ~Impl calls RemoveSession which + // decrements the count. If we incremented after, RemoveSession would + // see count=0 and the count would be permanently off by one. if (primary_session_count_++ == 0) { idle_timer_.Stop(); udp_.Ref(); } if (session->is_server()) { STAT_INCREMENT(Stats, server_sessions); - // We only emit the new session event for server sessions. - EmitNewSession(session); - // It is important to note that the session may be closed/destroyed - // when it is emitted here. + // Note that we don't emit new sessions here - that's deferred until + // the first datagram is processed (see accept path) to expose only + // sessions that successfully complete their TLS handshake. } else { STAT_INCREMENT(Stats, client_sessions); } @@ -1352,9 +1363,8 @@ void Endpoint::CloseGracefully() { void Endpoint::Receive(const uint8_t* data, size_t len, - const SocketAddress& remote_address) { - const uint64_t now = uv_hrtime(); - + const SocketAddress& remote_address, + uint64_t now) { // Block list filtering — applied before any packet processing to // minimize resource expenditure on blocked sources. if (options_.block_list) { @@ -1980,6 +1990,12 @@ void Endpoint::EmitNewSession(const BaseObjectPtr& session) { // the call to MakeCallback. If that's the case, the session object still // exists but it is in a destroyed state. Care should be taken accessing // session after this point. + + // Deliver any stream events that were held until the stream was setup, + // e.g. 0-RTT streams from the first flight. + if (!session->is_destroyed()) { + session->ReplayDeferredEmits(); + } } void Endpoint::EmitClose(CloseContext context, int status) { diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index f54e028ead4554..cac12c0956343a 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -435,7 +435,10 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // Ref() causes a listening Endpoint to keep the event loop active. JS_METHOD(Ref); - void Receive(const uint8_t* data, size_t len, const SocketAddress& from); + void Receive(const uint8_t* data, + size_t len, + const SocketAddress& from, + uint64_t now); AliasedStruct stats_; AliasedStruct state_; diff --git a/src/quic/http3.cc b/src/quic/http3.cc index bc479f96990577..89ac7f2a3fae48 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -8,10 +8,15 @@ #include #include #include +#include +#include #include #include #include #include +#include +#include +#include #include "application.h" #include "bindingdata.h" #include "defs.h" @@ -22,15 +27,52 @@ namespace node { using v8::Array; +using v8::BigInt; +using v8::Boolean; +using v8::DictionaryTemplate; +using v8::FunctionCallbackInfo; +using v8::Global; +using v8::HandleScope; +using v8::Integer; +using v8::Just; using v8::Local; +using v8::LocalVector; +using v8::Maybe; +using v8::MaybeLocal; +using v8::Nothing; +using v8::Object; +using v8::Uint32; +using v8::Value; namespace quic { +enum class HeadersKind : uint8_t { + HINTS, + INITIAL, + TRAILING, +}; + +enum class HeadersFlags : uint8_t { + NONE, + TERMINAL, +}; + +enum class StreamPriority : uint8_t { + DEFAULT = NGHTTP3_DEFAULT_URGENCY, + LOW = NGHTTP3_URGENCY_LOW, + HIGH = NGHTTP3_URGENCY_HIGH, +}; + +enum class StreamPriorityFlags : uint8_t { + NON_INCREMENTAL, + INCREMENTAL, +}; + namespace { constexpr uint8_t kSessionTicketAppDataVersion = 1; -// Layout: [type(1)][version(1)][crc(4)][payload(34)] = 40 bytes -constexpr size_t kSessionTicketAppDataSize = 40; -constexpr size_t kSessionTicketAppDataHeaderSize = 6; // type + version + crc +// Layout: [version(1)][crc(4)][payload(34)] = 39 bytes. +constexpr size_t kSessionTicketAppDataSize = 39; +constexpr size_t kSessionTicketAppDataHeaderSize = 5; // version + crc constexpr size_t kSessionTicketAppDataPayloadSize = kSessionTicketAppDataSize - kSessionTicketAppDataHeaderSize; @@ -83,6 +125,172 @@ inline size_t FormatPriority(char* buf, size_t buflen, const nghttp3_pri& pri) { } } // namespace +// ============================================================================ +// The HTTP/3 application settings: the RFC 9114/9204 SETTINGS values +// advertised to the peer plus the local header-processing limits. The +// values are supplied by node:http3 (the `settings` option of +// http3.connect()/listen()), carried opaquely through Session::Options, +// and parsed here by the registered parse hook. The QUIC core never +// knows these field names. +struct Http3Settings final { + // The maximum number of header pairs permitted for a Stream + // (local enforcement limit, not a wire setting). + uint64_t max_header_pairs = DEFAULT_MAX_HEADER_LIST_PAIRS; + + // The maximum total number of header bytes (including header + // name and value) permitted for a Stream (local enforcement limit, + // not a wire setting). + uint64_t max_header_length = DEFAULT_MAX_HEADER_LENGTH; + + // The maximum header section size advertised to the peer in SETTINGS. + // Defaults to match max_header_length so the SETTINGS frame accurately + // reflects the enforcement limit. A value of 0 would incorrectly tell + // the peer not to send any headers at all. + uint64_t max_field_section_size = DEFAULT_MAX_HEADER_LENGTH; + uint64_t qpack_max_dtable_capacity = 4096; + uint64_t qpack_encoder_max_dtable_capacity = 4096; + uint64_t qpack_blocked_streams = 100; + + bool enable_connect_protocol = true; + + // SETTINGS_H3_DATAGRAM (RFC 9297). HTTP/3 datagrams are not yet + // supported, so this is always false and the setting is never + // advertised. We reserve the setting logic to enable later. + bool enable_datagrams = false; + + operator const nghttp3_settings() const; + + static v8::Maybe From(Environment* env, + v8::Local value); + + std::string ToString() const; + + v8::MaybeLocal ToObject(Environment* env) const; +}; + +Http3Settings::operator const nghttp3_settings() const { + return nghttp3_settings{ + .max_field_section_size = max_field_section_size, + .qpack_max_dtable_capacity = + static_cast(qpack_max_dtable_capacity), + .qpack_encoder_max_dtable_capacity = + static_cast(qpack_encoder_max_dtable_capacity), + .qpack_blocked_streams = static_cast(qpack_blocked_streams), + .enable_connect_protocol = enable_connect_protocol, + .h3_datagram = enable_datagrams, + // origin_list is nullptr here because it is set directly on the + // nghttp3_settings in Http3Application::InitializeConnection() + // from the SNI configuration. + .origin_list = nullptr, + .glitch_ratelim_burst = 1000, + .glitch_ratelim_rate = 33, + .qpack_indexing_strat = NGHTTP3_QPACK_INDEXING_STRAT_EAGER, + }; +} + +std::string Http3Settings::ToString() const { + DebugIndentScope indent; + auto prefix = indent.Prefix(); + std::string res("{"); + res += prefix + "max header pairs: " + std::to_string(max_header_pairs); + res += prefix + "max header length: " + std::to_string(max_header_length); + res += prefix + + "max field section size: " + std::to_string(max_field_section_size); + res += prefix + "qpack max dtable capacity: " + + std::to_string(qpack_max_dtable_capacity); + res += prefix + "qpack encoder max dtable capacity: " + + std::to_string(qpack_encoder_max_dtable_capacity); + res += prefix + + "qpack blocked streams: " + std::to_string(qpack_blocked_streams); + res += prefix + "enable connect protocol: " + + (enable_connect_protocol ? std::string("yes") : std::string("no")); + res += prefix + "enable datagrams: " + + (enable_datagrams ? std::string("yes") : std::string("no")); + res += indent.Close(); + return res; +} + +Maybe Http3Settings::From(Environment* env, Local value) { + if (value.IsEmpty() || !value->IsObject()) [[unlikely]] { + THROW_ERR_INVALID_ARG_TYPE(env, "settings must be an object"); + return Nothing(); + } + + Http3Settings settings; + auto& state = BindingData::Get(env); + auto params = value.As(); + +#define SET(name) \ + SetOption( \ + env, &settings, params, state.name##_string()) + + if (!SET(max_header_pairs) || !SET(max_header_length) || + !SET(max_field_section_size) || !SET(qpack_max_dtable_capacity) || + !SET(qpack_encoder_max_dtable_capacity) || !SET(qpack_blocked_streams) || + !SET(enable_connect_protocol)) { + // The call to SetOption should have scheduled an exception to be thrown. + return Nothing(); + } + +#undef SET + + // Ensure the advertised max_field_section_size in SETTINGS is at least + // as large as max_header_length. Otherwise the peer would be told to + // restrict headers to a smaller size than the inbound header limits + // (enforced in AddHeader) accept. + if (settings.max_field_section_size < settings.max_header_length) { + settings.max_field_section_size = settings.max_header_length; + } + + return Just(settings); +} + +MaybeLocal Http3Settings::ToObject(Environment* env) const { + auto& binding_data = BindingData::Get(env); + auto tmpl = binding_data.http3_settings_template(); + static constexpr std::string_view names[] = {"maxHeaderPairs", + "maxHeaderLength", + "maxFieldSectionSize", + "qpackMaxDtableCapacity", + "qpackEncoderMaxDtableCapacity", + "qpackBlockedStreams", + "enableConnectProtocol"}; + if (tmpl.IsEmpty()) { + tmpl = DictionaryTemplate::New(env->isolate(), names); + binding_data.set_http3_settings_template(tmpl); + } + MaybeLocal values[] = { + BigInt::NewFromUnsigned(env->isolate(), max_header_pairs), + BigInt::NewFromUnsigned(env->isolate(), max_header_length), + BigInt::NewFromUnsigned(env->isolate(), max_field_section_size), + BigInt::NewFromUnsigned(env->isolate(), qpack_max_dtable_capacity), + BigInt::NewFromUnsigned(env->isolate(), + qpack_encoder_max_dtable_capacity), + BigInt::NewFromUnsigned(env->isolate(), qpack_blocked_streams), + Boolean::New(env->isolate(), enable_connect_protocol), + }; + static_assert(std::size(values) == std::size(names)); + + auto obj = tmpl->NewInstance(env->context(), values); + if (obj->SetPrototypeV2(env->context(), Null(env->isolate())).IsNothing()) { + return {}; + } + return obj; +} + +// The typed HTTP/3 session-ticket app data: the settings in effect when +// the ticket was issued, validated relax-tolerantly against the current +// settings on resume (0-RTT is blocked only when settings were +// tightened, not relaxed). +struct Http3TicketData final { + uint64_t max_field_section_size; + uint64_t qpack_max_dtable_capacity; + uint64_t qpack_encoder_max_dtable_capacity; + uint64_t qpack_blocked_streams; + bool enable_connect_protocol; + bool enable_datagrams; +}; + struct Http3HeadersTraits { using nv_t = nghttp3_nv; }; @@ -138,13 +346,14 @@ struct Http3HeaderTraits { using Http3Header = NgHeader; -// Implements the low-level HTTP/3 Application semantics. -class Http3ApplicationImpl final : public Session::Application { +// The Session::Application implementation for HTTP/3, which owns the nghttp3 +// connection and all HTTP/3 protocol logic and state. +class Http3Application final : public Session::Application { public: - Http3ApplicationImpl(Session* session, const Options& options) - : Application(session, options), - allocator_(BindingData::Get(env()).nghttp3_allocator()), - options_(options), + Http3Application(Session* session, const Http3Settings& settings) + : Application(session), + allocator_(BindingData::Get(session->env()).nghttp3_allocator()), + options_(settings), conn_(nullptr) { // Build the ORIGIN frame payload from the SNI configuration before // creating the nghttp3 connection, since InitializeConnection needs @@ -153,15 +362,63 @@ class Http3ApplicationImpl final : public Session::Application { BuildOriginPayload(); } conn_ = InitializeConnection(); - session->set_priority_supported(); } - const Options& options() const override { return options_; } + // ========================================================================== + // HTTP/3 events reported up to the session/JS layer. Each routes through + // Session::DeferOrRun, so an event raised before the session is surfaced to + // JS (in 0-RTT first-flight frames) is held and replayed after attach. + + void EmitGoaway(stream_id last_stream_id) { + session().DeferOrRun([this, last_stream_id]() { + Session& s = session(); + if (s.is_destroyed() || !s.env()->can_call_into_js()) return; + CallbackScope cb_scope(&s); + Local argv[] = {BigInt::New(s.env()->isolate(), last_stream_id)}; + s.MakeCallback(BindingData::Get(s.env()).session_goaway_callback(), + arraysize(argv), + argv); + }); + } + + void EmitOrigins(std::vector&& origins) { + auto held = std::make_shared>(std::move(origins)); + session().DeferOrRun([this, held]() { + Session& s = session(); + if (s.is_destroyed() || !s.env()->can_call_into_js()) return; + if (!s.has_origin_listener()) return; + CallbackScope cb_scope(&s); + auto* isolate = s.env()->isolate(); + LocalVector elements(isolate, held->size()); + for (size_t i = 0; i < held->size(); i++) { + Local str; + if (!ToV8Value(s.env()->context(), (*held)[i]).ToLocal(&str)) + [[unlikely]] { + return; + } + elements[i] = str; + } + Local argv[] = { + Array::New(isolate, elements.data(), elements.size())}; + s.MakeCallback(BindingData::Get(s.env()).session_origin_callback(), + arraysize(argv), + argv); + }); + } - Session::Application::Type type() const override { - return Session::Application::Type::HTTP3; + void EmitApplicationSettings() { + session().DeferOrRun([this]() { + Session& s = session(); + if (s.is_destroyed() || !s.env()->can_call_into_js()) return; + CallbackScope cb_scope(&s); + s.MakeCallback( + BindingData::Get(s.env()).session_application_callback(), 0, nullptr); + }); } + // ========================================================================== + // Session::Application + error_code GetNoErrorCode() const override { return NGHTTP3_H3_NO_ERROR; } // HTTP/3 defines H3_INTERNAL_ERROR (0x102) for non-specific failures @@ -181,6 +438,7 @@ class Http3ApplicationImpl final : public Session::Application { // condition (code 0 would be treated as a clean close). conn_.reset(); started_ = false; + header_state_.clear(); session().DestroyAllStreams( QuicError::ForApplication(GetInternalErrorCode())); if (!session().is_destroyed()) { @@ -200,24 +458,21 @@ class Http3ApplicationImpl final : public Session::Application { return true; } - bool SupportsHeaders() const override { return true; } - bool is_started() const override { return started_; } bool Start() override { if (started_) return true; - started_ = true; Debug(&session(), "Starting HTTP/3 application."); - auto params = ngtcp2_conn_get_remote_transport_params(session()); - if (params == nullptr) [[unlikely]] { + const auto params = session().remote_transport_params(); + if (!params) [[unlikely]] { // The params are not available yet. Cannot start. Debug(&session(), "Cannot start HTTP/3 application yet. No remote transport params"); return false; } - if (params->initial_max_streams_uni < 3) { + if (params.initial_max_streams_uni() < 3) { // HTTP3 requires 3 unidirectional control streams to be opened in each // direction in additional to the bidirectional streams that are used to // actually carry request and response payload back and forth. @@ -225,8 +480,9 @@ class Http3ApplicationImpl final : public Session::Application { // https://nghttp2.org/nghttp3/programmers-guide.html#binding-control-streams Debug(&session(), "Cannot start HTTP/3 application. Initial max " - "unidirectional streams [%zu] is too low. Must be at least 3", - params->initial_max_streams_uni); + "unidirectional streams [%" PRIu64 + "] is too low. Must be at least 3", + params.initial_max_streams_uni()); return false; } @@ -235,17 +491,14 @@ class Http3ApplicationImpl final : public Session::Application { // of requests that the client can actually created. if (session().is_server()) { nghttp3_conn_set_max_client_streams_bidi( - *this, params->initial_max_streams_bidi); + *this, params.initial_max_streams_bidi()); } Debug(&session(), "Creating and binding HTTP/3 control streams"); bool ret = - ngtcp2_conn_open_uni_stream(session(), &control_stream_id_, nullptr) == - 0 && - ngtcp2_conn_open_uni_stream( - session(), &qpack_enc_stream_id_, nullptr) == 0 && - ngtcp2_conn_open_uni_stream( - session(), &qpack_dec_stream_id_, nullptr) == 0 && + session().OpenUni(&control_stream_id_) && + session().OpenUni(&qpack_enc_stream_id_) && + session().OpenUni(&qpack_dec_stream_id_) && nghttp3_conn_bind_control_stream(*this, control_stream_id_) == 0 && nghttp3_conn_bind_qpack_streams( *this, qpack_enc_stream_id_, qpack_dec_stream_id_) == 0; @@ -262,6 +515,7 @@ class Http3ApplicationImpl final : public Session::Application { qpack_dec_stream_id_); } + started_ = ret; return ret; } @@ -279,11 +533,51 @@ class Http3ApplicationImpl final : public Session::Application { if (conn_ && started_) nghttp3_conn_shutdown(*this); } + void PostReceive() override { + if (pending_goaway_id_ < 0) return; + stream_id goaway_id = pending_goaway_id_; + pending_goaway_id_ = -1; + + bool is_notice = + static_cast(goaway_id) >= NGHTTP3_SHUTDOWN_NOTICE_STREAM_ID; + + // For the shutdown notice, replace the sentinel stream ID with -1 + // so JS sees a clean marker instead of a huge implementation detail. + stream_id emit_id = is_notice ? -1 : goaway_id; + + if (!is_notice) { + // Final GOAWAY: destroy client-initiated bidi streams with IDs > + // goaway_id. These were not processed by the peer and can be retried. + // Copy the map because Destroy() modifies it. + auto streams = session().streams(); + for (auto& [id, stream] : streams) { + if (session().is_destroyed()) return; + if (ngtcp2_is_bidi_stream(id) && id > goaway_id) { + stream->Destroy( + QuicError::ForApplication(NGHTTP3_H3_REQUEST_REJECTED)); + } + } + if (session().is_destroyed()) return; + } + + // Notify JS for both notice and final GOAWAY. The notice uses + // -1 to signal "server is shutting down, stop new requests" without + // implying any specific stream boundary. The final GOAWAY (if it + // arrives separately) provides the exact stream ID for retry decisions. + // + // We do NOT call Close(GRACEFUL) here. The JS ongoaway handler sets + // isPendingClose (preventing new streams). The session closes naturally + // when the peer sends CONNECTION_CLOSE after all streams finish. + // Calling Close(GRACEFUL) would send a GOAWAY back and trigger + // BeginShutdown, which can interfere with in-progress streams. + EmitGoaway(emit_id); + } + bool ReceiveStreamData(stream_id id, const uint8_t* data, size_t datalen, const Stream::ReceiveDataFlags& flags, - void* unused) override { + void* stream_user_data) override { Debug(&session(), "HTTP/3 application received %zu bytes of data " "on stream %" PRIi64 ". Is final? %d. Is early? %d", @@ -292,8 +586,12 @@ class Http3ApplicationImpl final : public Session::Application { flags.fin, flags.early); + uint64_t ts = session().rx_packet_ts(); + if (ts == 0) [[unlikely]] { + ts = uv_hrtime(); + } auto nread = nghttp3_conn_read_stream2( - *this, id, data, datalen, flags.fin ? 1 : 0, uv_hrtime()); + *this, id, data, datalen, flags.fin ? 1 : 0, ts); if (nread < 0) { Debug(&session(), @@ -306,8 +604,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "Extending stream and connection offset by %zd bytes", nread); - session().ExtendStreamOffset(id, nread); - session().ExtendOffset(nread); + session().Consume(id, nread); } // If this data arrived as 0-RTT, mark the stream. We set it after @@ -331,17 +628,6 @@ class Http3ApplicationImpl final : public Session::Application { return nghttp3_conn_add_ack_offset(*this, id, datalen) == 0; } - bool CanAddHeader(size_t current_count, - size_t current_headers_length, - size_t this_header_length) override { - // We cannot add the header if we've either reached - // * the max number of header pairs or - // * the max number of header bytes (name + value combined) - return (current_count < options_.max_header_pairs) && - (current_headers_length + this_header_length) <= - options_.max_header_length; - } - bool stream_fin_managed_by_application() const override { return true; } void StreamWriteShut(stream_id id) override { @@ -350,41 +636,18 @@ class Http3ApplicationImpl final : public Session::Application { void BlockStream(stream_id id) override { nghttp3_conn_block_stream(*this, id); - Application::BlockStream(id); } void ResumeStream(stream_id id) override { nghttp3_conn_resume_stream(*this, id); - Application::ResumeStream(id); } - void ExtendMaxStreams(EndpointLabel label, - Direction direction, - uint64_t max_streams) override { - switch (label) { - case EndpointLabel::LOCAL: - return; - case EndpointLabel::REMOTE: { - switch (direction) { - case Direction::BIDIRECTIONAL: { - Debug(&session(), - "HTTP/3 application extending max bidi streams by %" PRIu64, - max_streams); - ngtcp2_conn_extend_max_streams_bidi( - session(), static_cast(max_streams)); - break; - } - case Direction::UNIDIRECTIONAL: { - Debug(&session(), - "HTTP/3 application extending max uni streams by %" PRIu64, - max_streams); - ngtcp2_conn_extend_max_streams_uni( - session(), static_cast(max_streams)); - break; - } - } - } - } + void ExtendMaxStreams(Direction direction, uint64_t max_streams) { + Debug(&session(), + "HTTP/3 application extending max %s streams by %" PRIu64, + direction == Direction::BIDIRECTIONAL ? "bidi" : "uni", + max_streams); + session().ExtendMaxStreams(direction, max_streams); } void ExtendMaxStreamData(Stream* stream, uint64_t max_data) override { @@ -397,8 +660,7 @@ class Http3ApplicationImpl final : public Session::Application { void CollectSessionTicketAppData( SessionTicket::AppData* app_data) const override { uint8_t buf[kSessionTicketAppDataSize]; - buf[0] = static_cast(Type::HTTP3); - buf[1] = kSessionTicketAppDataVersion; + buf[0] = kSessionTicketAppDataVersion; uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; WriteBE64(payload, options_.max_field_section_size); @@ -410,95 +672,19 @@ class Http3ApplicationImpl final : public Session::Application { uLong crc = crc32(0L, Z_NULL, 0); crc = crc32(crc, payload, kSessionTicketAppDataPayloadSize); - WriteBE32(buf + 2, static_cast(crc)); + WriteBE32(buf + 1, static_cast(crc)); app_data->Set( uv_buf_init(reinterpret_cast(buf), kSessionTicketAppDataSize)); } - SessionTicket::AppData::Status ExtractSessionTicketAppData( - const SessionTicket::AppData& app_data, - SessionTicket::AppData::Source::Flag flag) override { - auto data = app_data.Get(); - if (!data || data->len != kSessionTicketAppDataSize) { - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - - const uint8_t* buf = reinterpret_cast(data->base); - - // buf[0] is the application type byte, buf[1] is the version. - if (buf[0] != static_cast(Type::HTTP3) || - buf[1] != kSessionTicketAppDataVersion) { - Debug(&session(), - "Ticket app data rejected: type=%d version=%d " - "(expected type=%d version=%d)", - buf[0], - buf[1], - static_cast(Type::HTTP3), - kSessionTicketAppDataVersion); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - - const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; - uint32_t stored_crc = ReadBE32(buf + 2); - uLong computed_crc = crc32(0L, Z_NULL, 0); - computed_crc = - crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); - if (stored_crc != static_cast(computed_crc)) { - Debug(&session(), - "Ticket app data rejected: CRC mismatch " - "(stored=%u computed=%u)", - stored_crc, - static_cast(computed_crc)); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - - uint64_t stored_max_field_section_size = ReadBE64(payload); - uint64_t stored_qpack_max_dtable_capacity = ReadBE64(payload + 8); - uint64_t stored_qpack_encoder_max_dtable_capacity = ReadBE64(payload + 16); - uint64_t stored_qpack_blocked_streams = ReadBE64(payload + 24); - bool stored_enable_connect_protocol = payload[32] != 0; - bool stored_enable_datagrams = payload[33] != 0; - - Debug(&session(), - "Ticket app data: stored mfss=%" PRIu64 " qmdc=%" PRIu64 - " qemdc=%" PRIu64 " qbs=%" PRIu64 " ecp=%d ed=%d", - stored_max_field_section_size, - stored_qpack_max_dtable_capacity, - stored_qpack_encoder_max_dtable_capacity, - stored_qpack_blocked_streams, - stored_enable_connect_protocol, - stored_enable_datagrams); - Debug(&session(), - "Current opts: mfss=%" PRIu64 " qmdc=%" PRIu64 " qemdc=%" PRIu64 - " qbs=%" PRIu64 " ecp=%d ed=%d", - options_.max_field_section_size, - options_.qpack_max_dtable_capacity, - options_.qpack_encoder_max_dtable_capacity, - options_.qpack_blocked_streams, - options_.enable_connect_protocol, - options_.enable_datagrams); - if (options_.max_field_section_size < stored_max_field_section_size || - options_.qpack_max_dtable_capacity < stored_qpack_max_dtable_capacity || - options_.qpack_encoder_max_dtable_capacity < - stored_qpack_encoder_max_dtable_capacity || - options_.qpack_blocked_streams < stored_qpack_blocked_streams || - (stored_enable_connect_protocol && !options_.enable_connect_protocol) || - (stored_enable_datagrams && !options_.enable_datagrams)) { - Debug(&session(), "Ticket app data REJECTED"); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - Debug(&session(), "Ticket app data ACCEPTED"); - - return flag == SessionTicket::AppData::Source::Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; - } - bool ApplySessionTicketData(const PendingTicketAppData& data) override { - if (!std::holds_alternative(data)) return false; - const auto& ticket = std::get(data); - // Validate that current settings are >= stored settings. + // The pending data was produced by this application's own ticket + // hook (ParseHttp3Ticket below), which already validated it against + // the configured settings at decrypt time. + CHECK(data); + const auto& ticket = *std::static_pointer_cast(data); + // Re-validate that current settings are >= stored settings. return options_.max_field_section_size >= ticket.max_field_section_size && options_.qpack_max_dtable_capacity >= ticket.qpack_max_dtable_capacity && @@ -510,8 +696,11 @@ class Http3ApplicationImpl final : public Session::Application { (!ticket.enable_datagrams || options_.enable_datagrams); } - void ReceiveStreamClose(Stream* stream, - QuicError&& error = QuicError()) override { + MaybeLocal GetSettingsObject(Environment* env) override { + return options_.ToObject(env); + } + + void ReceiveStreamClose(Stream* stream, QuicError&& error) override { Debug( &session(), "HTTP/3 application closing stream %" PRIi64, stream->id()); error_code code = NGHTTP3_H3_NO_ERROR; @@ -520,24 +709,23 @@ class Http3ApplicationImpl final : public Session::Application { } int rv = nghttp3_conn_close_stream(*this, stream->id(), code); - // If the call is successful, Http3Application::OnStreamClose callback will - // be invoked when the stream is ready to be closed. We'll handle destroying - // the actual Stream object there. + // If the call is successful, the Http3Application::OnStreamClose + // callback will be invoked when the stream is ready to be closed. We'll + // handle destroying the actual Stream object there. if (rv == 0) return; if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) { - ExtendMaxStreams(EndpointLabel::REMOTE, stream->direction(), 1); + ExtendMaxStreams(stream->direction(), 1); return; } - session().SetLastError( - QuicError::ForApplication(nghttp3_err_infer_quic_app_error_code(rv))); + session().SetError(nghttp3_err_infer_quic_app_error_code(rv)); session().Close(); } void ReceiveStreamReset(Stream* stream, uint64_t final_size, - QuicError&& error = QuicError()) override { + QuicError&& error) override { // We are shutting down the readable side of the local stream here. Debug(&session(), "HTTP/3 application resetting stream %" PRIi64, @@ -548,90 +736,75 @@ class Http3ApplicationImpl final : public Session::Application { return; } - session().SetLastError( - QuicError::ForApplication(nghttp3_err_infer_quic_app_error_code(rv))); + session().SetError(nghttp3_err_infer_quic_app_error_code(rv)); session().Close(); } - void ReceiveStreamStopSending(Stream* stream, - QuicError&& error = QuicError()) override { - Application::ReceiveStreamStopSending(stream, std::move(error)); + void StreamRemoved(stream_id id) override { + if (conn_) nghttp3_conn_set_stream_user_data(*this, id, nullptr); + header_state_.erase(id); } - bool SendHeaders(const Stream& stream, - HeadersKind kind, - const Local& headers, - HeadersFlags flags = HeadersFlags::NONE) override { + struct StreamPriorityResult { + StreamPriority priority; + StreamPriorityFlags flags; + }; + + bool SubmitHeaders(const Stream& stream, + const Local& headers, + HeadersFlags flags) { Session::SendPendingDataScope send_scope(&session()); Http3Headers nva(env(), headers); + static constexpr nghttp3_data_reader reader = {on_read_data_callback}; + const nghttp3_data_reader* reader_ptr = + flags != HeadersFlags::TERMINAL ? &reader : nullptr; - switch (kind) { - case HeadersKind::HINTS: { - if (!session().is_server()) { - // Client side cannot send hints - return false; - } - Debug(&session(), - "Submitting %" PRIu64 " early hints for stream %" PRIu64, - stream.id()); - return nghttp3_conn_submit_info( - *this, stream.id(), nva.data(), nva.length()) == 0; - break; - } - case HeadersKind::INITIAL: { - static constexpr nghttp3_data_reader reader = {on_read_data_callback}; - const nghttp3_data_reader* reader_ptr = nullptr; - - // If the terminal flag is set, that means that we know we're only - // sending headers and no body and the stream writable side should be - // closed immediately because there is no nghttp3_data_reader provided. - if (flags != HeadersFlags::TERMINAL) { - reader_ptr = &reader; - } - - if (session().is_server()) { - // If this is a server, we're submitting a response... - Debug(&session(), - "Submitting %" PRIu64 " response headers for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_response(*this, - stream.id(), - nva.data(), - nva.length(), - reader_ptr) == 0; - } else { - // Otherwise we're submitting a request... - Debug(&session(), - "Submitting %" PRIu64 " request headers for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_request(*this, - stream.id(), - nva.data(), - nva.length(), - reader_ptr, - const_cast(&stream)) == 0; - } - break; - } - case HeadersKind::TRAILING: { - Debug(&session(), - "Submitting %" PRIu64 " trailing headers for stream %" PRIu64, - nva.length(), - stream.id()); - return nghttp3_conn_submit_trailers( - *this, stream.id(), nva.data(), nva.length()) == 0; - break; - } + if (session().is_server()) { + Debug(&session(), + "Submitting %" PRIu64 " response headers for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_response( + *this, stream.id(), nva.data(), nva.length(), reader_ptr) == 0; } + Debug(&session(), + "Submitting %" PRIu64 " request headers for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_request(*this, + stream.id(), + nva.data(), + nva.length(), + reader_ptr, + const_cast(&stream)) == 0; + } + + bool SubmitInfo(const Stream& stream, const Local& headers) { + if (!session().is_server()) return false; + Session::SendPendingDataScope send_scope(&session()); + Http3Headers nva(env(), headers); + Debug(&session(), + "Submitting %" PRIu64 " early hints for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_info( + *this, stream.id(), nva.data(), nva.length()) == 0; + } - return false; + bool SubmitTrailers(const Stream& stream, const Local& headers) { + Session::SendPendingDataScope send_scope(&session()); + Http3Headers nva(env(), headers); + Debug(&session(), + "Submitting %" PRIu64 " trailing headers for stream %" PRIu64, + nva.length(), + stream.id()); + return nghttp3_conn_submit_trailers( + *this, stream.id(), nva.data(), nva.length()) == 0; } void SetStreamPriority(const Stream& stream, StreamPriority priority, - StreamPriorityFlags flags) override { + StreamPriorityFlags flags) { nghttp3_pri pri; pri.inc = (flags == StreamPriorityFlags::INCREMENTAL) ? 1 : 0; switch (priority) { @@ -657,14 +830,13 @@ class Http3ApplicationImpl final : public Session::Application { } } - StreamPriorityResult GetStreamPriority(const Stream& stream) override { + StreamPriorityResult GetStreamPriority(const Stream& stream) { // nghttp3_conn_get_stream_priority is only available on the server // side, where it reflects the peer's requested priority (e.g., from - // PRIORITY_UPDATE frames). Client-side priority is tracked by the - // Stream itself and returned directly from GetPriority in streams.cc. + // PRIORITY_UPDATE frames). The client tracks its own requested priority + // in node:http3, and doesn't use this. if (!session().is_server()) { - auto& stored = stream.stored_priority(); - return {stored.priority, stored.flags}; + return {StreamPriority::DEFAULT, StreamPriorityFlags::NON_INCREMENTAL}; } nghttp3_pri pri; if (nghttp3_conn_get_stream_priority(*this, &pri, stream.id()) == 0) { @@ -688,12 +860,22 @@ class Http3ApplicationImpl final : public Session::Application { } int GetStreamData(StreamData* data) override { + static_assert( + sizeof(ngtcp2_vec) == sizeof(nghttp3_vec) && + alignof(ngtcp2_vec) == alignof(nghttp3_vec) && + offsetof(ngtcp2_vec, base) == offsetof(nghttp3_vec, base) && + offsetof(ngtcp2_vec, len) == offsetof(nghttp3_vec, len), + "ngtcp2_vec and nghttp3_vec must have identical layout"); data->count = kMaxVectorCount; ssize_t ret = 0; Debug(&session(), "HTTP/3 application getting stream data"); if (conn_ && session().max_data_left()) { - ret = nghttp3_conn_writev_stream( - *this, &data->id, &data->fin, *data, data->count); + ret = + nghttp3_conn_writev_stream(*this, + &data->id, + &data->fin, + reinterpret_cast(data->data), + data->count); // A negative return value indicates an error. if (ret < 0) { return static_cast(ret); @@ -705,6 +887,15 @@ class Http3ApplicationImpl final : public Session::Application { data->id != qpack_enc_stream_id_) { data->stream = session().FindStream(data->id); } + } else { + // Nothing can be pulled right now (connection flow control is + // exhausted). Present an empty result: the caller reuses a single + // StreamData across send-loop iterations, so the fields must not + // be left holding the previous iteration's stream. + data->id = -1; + data->count = 0; + data->fin = 0; + data->stream.reset(); } return 0; @@ -720,8 +911,7 @@ class Http3ApplicationImpl final : public Session::Application { // nghttp3 tracks its own offset via add_write_offset. int err = nghttp3_conn_add_write_offset(*this, data->id, datalen); if (err != 0) { - session().SetLastError(QuicError::ForApplication( - nghttp3_err_infer_quic_app_error_code(err))); + session().SetError(nghttp3_err_infer_quic_app_error_code(err)); return false; } // Raw application bytes are committed to the stream's outbound @@ -739,14 +929,14 @@ class Http3ApplicationImpl final : public Session::Application { // for the next writev_stream in the send loop. if (pending_trailers_stream_ == data->id) { pending_trailers_stream_ = -1; - if (data->stream) data->stream->EmitWantTrailers(); + if (data->stream) EmitWantTrailers(data->stream.get()); } return true; } SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(Http3ApplicationImpl) - SET_SELF_SIZE(Http3ApplicationImpl) + SET_MEMORY_INFO_NAME(Http3Application) + SET_SELF_SIZE(Http3Application) private: inline operator nghttp3_conn*() const { @@ -804,6 +994,7 @@ class Http3ApplicationImpl final : public Session::Application { } void OnStreamClose(Stream* stream, error_code app_error_code) { + header_state_.erase(stream->id()); if (app_error_code != NGHTTP3_H3_NO_ERROR) { Debug(&session(), "HTTP/3 application received stream close for stream %" PRIi64 @@ -813,18 +1004,21 @@ class Http3ApplicationImpl final : public Session::Application { } auto direction = stream->direction(); stream->Destroy(QuicError::ForApplication(app_error_code)); - ExtendMaxStreams(EndpointLabel::REMOTE, direction, 1); + ExtendMaxStreams(direction, 1); } void OnBeginHeaders(stream_id id) { - auto stream = FindOrCreateStream(conn_.get(), &session(), id); + auto stream = FindOrCreateStream(id); if (!stream) [[unlikely]] return; Debug(&session(), "HTTP/3 application beginning initial block of headers for stream " "%" PRIi64, id); - stream->BeginHeaders(HeadersKind::INITIAL); + auto& hs = header_state_[id]; + hs.headers.clear(); + hs.headers_length = 0; + hs.kind = HeadersKind::INITIAL; } void OnReceiveHeader(stream_id id, std::unique_ptr header) { @@ -836,7 +1030,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application switching to hints headers for stream %" PRIi64, stream->id()); - stream->set_headers_kind(HeadersKind::HINTS); + header_state_[id].kind = HeadersKind::HINTS; } IF_QUIC_DEBUG(env()) { Debug(&session(), @@ -844,7 +1038,7 @@ class Http3ApplicationImpl final : public Session::Application { header->name(), header->value()); } - stream->AddHeader(std::move(header)); + AddHeader(id, std::move(header)); } void OnEndHeaders(stream_id id, int fin) { @@ -854,7 +1048,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application received end of headers for stream %" PRIi64, id); - stream->EmitHeaders(); + EmitHeaders(stream.get()); if (fin) { // The stream is done. There's no more data to receive! Debug(&session(), "Headers are final for stream %" PRIi64, id); @@ -867,13 +1061,16 @@ class Http3ApplicationImpl final : public Session::Application { } void OnBeginTrailers(stream_id id) { - auto stream = FindOrCreateStream(conn_.get(), &session(), id); + auto stream = FindOrCreateStream(id); if (!stream) [[unlikely]] return; Debug(&session(), "HTTP/3 application beginning block of trailers for stream %" PRIi64, id); - stream->BeginHeaders(HeadersKind::TRAILING); + auto& hs = header_state_[id]; + hs.headers.clear(); + hs.headers_length = 0; + hs.kind = HeadersKind::TRAILING; } void OnReceiveTrailer(stream_id id, std::unique_ptr header) { @@ -886,7 +1083,7 @@ class Http3ApplicationImpl final : public Session::Application { header->name(), header->value()); } - stream->AddHeader(std::move(header)); + AddHeader(id, std::move(header)); } void OnEndTrailers(stream_id id, int fin) { @@ -896,7 +1093,7 @@ class Http3ApplicationImpl final : public Session::Application { Debug(&session(), "HTTP/3 application received end of trailers for stream %" PRIi64, id); - stream->EmitHeaders(); + EmitHeaders(stream.get()); if (fin) { Debug(&session(), "Trailers are final for stream %" PRIi64, id); Stream::ReceiveDataFlags flags{ @@ -907,6 +1104,66 @@ class Http3ApplicationImpl final : public Session::Application { } } + void AddHeader(stream_id id, std::unique_ptr header) { + auto& hs = header_state_[id]; + size_t len = header->length(); + if (hs.headers.size() >= options_.max_header_pairs || + hs.headers_length + len > options_.max_header_length) { + return; + } + hs.headers_length += len; + hs.headers.push_back(std::move(header)); + } + + void EmitHeaders(Stream* stream) { + auto it = header_state_.find(stream->id()); + if (it == header_state_.end()) return; + auto& hs = it->second; + if (!env()->can_call_into_js() || !stream->wants_headers()) { + hs.headers.clear(); + hs.headers_length = 0; + return; + } + CallbackScope cb_scope(stream); + + auto& binding = BindingData::Get(env()); + size_t count = hs.headers.size() * 2; + LocalVector values(env()->isolate(), count); + + for (size_t i = 0; i < hs.headers.size(); i++) { + Local name; + Local value; + if (!hs.headers[i]->GetName(&binding).ToLocal(&name) || + !hs.headers[i]->GetValue(&binding).ToLocal(&value)) [[unlikely]] { + hs.headers.clear(); + hs.headers_length = 0; + return; + } + values[i * 2] = name; + values[i * 2 + 1] = value; + } + + auto kind = hs.kind; + hs.headers.clear(); + hs.headers_length = 0; + + Local argv[] = {Array::New(env()->isolate(), values.data(), count), + Integer::NewFromUnsigned( + env()->isolate(), static_cast(kind))}; + + stream->MakeCallback( + binding.stream_headers_callback(), arraysize(argv), argv); + } + + void EmitWantTrailers(Stream* stream) { + if (!env()->can_call_into_js() || !stream->wants_trailers()) { + return; + } + CallbackScope cb_scope(stream); + stream->MakeCallback( + BindingData::Get(env()).stream_trailers_callback(), 0, nullptr); + } + void OnEndStream(stream_id id) { auto stream = session().FindStream(id); if (!stream) [[unlikely]] @@ -956,71 +1213,31 @@ class Http3ApplicationImpl final : public Session::Application { pending_goaway_id_ = id; } - void PostReceive() override { - if (pending_goaway_id_ < 0) return; - stream_id goaway_id = pending_goaway_id_; - pending_goaway_id_ = -1; - - bool is_notice = - static_cast(goaway_id) >= NGHTTP3_SHUTDOWN_NOTICE_STREAM_ID; - - // For the shutdown notice, replace the sentinel stream ID with -1 - // so JS sees a clean marker instead of a huge implementation detail. - stream_id emit_id = is_notice ? -1 : goaway_id; - - if (!is_notice) { - // Final GOAWAY: destroy client-initiated bidi streams with - // IDs > goaway_id. These were not processed by the peer and - // can be retried. Copy the map because Destroy modifies it. - auto streams = session().streams(); - for (auto& [id, stream] : streams) { - if (session().is_destroyed()) return; - if (ngtcp2_is_bidi_stream(id) && id > goaway_id) { - stream->Destroy( - QuicError::ForApplication(NGHTTP3_H3_REQUEST_REJECTED)); - } - } - if (session().is_destroyed()) return; - } - - // Notify JS for both notice and final GOAWAY. The notice uses - // -1 to signal "server is shutting down, stop new requests" without - // implying any specific stream boundary. The final GOAWAY (if it - // arrives separately) provides the exact stream ID for retry decisions. - // - // We do NOT call Close(GRACEFUL) here. The JS ongoaway handler sets - // isPendingClose (preventing new streams). The session closes naturally - // when the peer sends CONNECTION_CLOSE after all streams finish. - // Calling Close(GRACEFUL) would send a GOAWAY back and trigger - // BeginShutdown, which can interfere with in-progress streams. - session().EmitGoaway(emit_id); - } - void OnReceiveSettings(const nghttp3_proto_settings* settings) { options_.enable_connect_protocol = settings->enable_connect_protocol; - options_.enable_datagrams = settings->h3_datagram; options_.max_field_section_size = settings->max_field_section_size; options_.qpack_blocked_streams = settings->qpack_blocked_streams; options_.qpack_max_dtable_capacity = settings->qpack_max_dtable_capacity; - // Per RFC 9297 §3, an H3 endpoint MUST NOT send HTTP Datagrams - // unless the peer indicated support via SETTINGS_H3_DATAGRAM=1. - // If the peer disabled it, set the session's max datagram size to 0 - // which blocks sends at the existing JS/C++ check. - if (!settings->h3_datagram) { - session().set_max_datagram_size(0); - } - Debug(&session(), "HTTP/3 application received updated settings: %s", options_); - // The settings are part of the application - session().EmitApplication(); + // Report the negotiated settings up to the session/JS layer. + EmitApplicationSettings(); } + // Inbound header-block accumulation, keyed by stream id. Entries are + // created by the header callbacks and erased on stream close (or + // wholesale on 0-RTT rejection). + struct StreamHeaderState { + std::vector> headers; + size_t headers_length = 0; + HeadersKind kind = HeadersKind::INITIAL; + }; + std::unordered_map header_state_; bool started_ = false; nghttp3_mem* allocator_; - Options options_; + Http3Settings options_; Http3ConnectionPointer conn_; stream_id control_stream_id_ = -1; stream_id qpack_dec_stream_id_ = -1; @@ -1047,20 +1264,26 @@ class Http3ApplicationImpl final : public Session::Application { // ========================================================================== // Static callbacks - static Http3ApplicationImpl* From(nghttp3_conn* conn, void* user_data) { + static Http3Application* From(nghttp3_conn* conn, void* user_data) { DCHECK_NOT_NULL(user_data); - auto app = static_cast(user_data); + auto app = static_cast(user_data); DCHECK_EQ(conn, app->conn_.get()); return app; } - static BaseObjectWeakPtr FindOrCreateStream(nghttp3_conn* conn, - Session* session, - stream_id id) { - if (auto stream = session->FindStream(id)) { + // Persist the Stream* in the stream user data, so we can look it + // up directly without a FindStream map lookup every time. + void BindStreamUserData(stream_id id, Stream* stream) { + if (conn_) nghttp3_conn_set_stream_user_data(*this, id, stream); + } + + BaseObjectWeakPtr FindOrCreateStream(stream_id id) { + if (auto stream = session().FindStream(id)) { + BindStreamUserData(id, stream.get()); return stream; } - if (auto stream = session->CreateStream(id)) { + if (auto stream = session().CreateStream(id)) { + BindStreamUserData(id, stream.get()); return stream; } return {}; @@ -1084,8 +1307,11 @@ class Http3ApplicationImpl final : public Session::Application { auto& app = *ptr; NgHttp3CallbackScope scope(&app.session()); - auto stream = app.session().FindStream(id); - if (!stream) return NGHTTP3_ERR_CALLBACK_FAILURE; + BaseObjectPtr stream(static_cast(stream_user_data)); + if (!stream) [[unlikely]] { + stream = app.session().FindStream(id); + if (!stream) return NGHTTP3_ERR_CALLBACK_FAILURE; + } if (stream->is_eos()) { *pflags |= NGHTTP3_DATA_FLAG_EOF; @@ -1157,7 +1383,7 @@ class Http3ApplicationImpl final : public Session::Application { void* conn_user_data, void* stream_user_data) { // This callback is invoked by nghttp3_conn_add_ack_offset() (called - // from Http3ApplicationImpl::AcknowledgeStreamData). We must NOT call + // from Http3Application::AcknowledgeStreamData). We must NOT call // AcknowledgeStreamData here — that would re-enter nghttp3 via // nghttp3_conn_add_ack_offset, triggering the NgHttp3CallbackScope // re-entrancy assertion. Instead, directly notify the stream that data @@ -1166,7 +1392,11 @@ class Http3ApplicationImpl final : public Session::Application { auto ptr = From(conn, conn_user_data); CHECK_NOT_NULL(ptr); auto& app = *ptr; - if (auto stream = app.session().FindStream(id)) { + BaseObjectPtr stream(static_cast(stream_user_data)); + if (!stream) [[unlikely]] { + stream = app.session().FindStream(id); + } + if (stream) { stream->Acknowledge(static_cast(datalen)); } return NGTCP2_SUCCESS; @@ -1197,12 +1427,16 @@ class Http3ApplicationImpl final : public Session::Application { if (app.is_control_stream(id)) [[unlikely]] { return NGHTTP3_ERR_CALLBACK_FAILURE; } - auto& session = app.session(); - if (auto stream = FindOrCreateStream(conn, &session, id)) [[likely]] { - stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{}); - return NGTCP2_SUCCESS; + BaseObjectPtr stream(static_cast(stream_user_data)); + if (!stream) [[unlikely]] { + if (auto created = app.FindOrCreateStream(id)) { + created->ReceiveData(data, datalen, Stream::ReceiveDataFlags{}); + return NGTCP2_SUCCESS; + } + return NGHTTP3_ERR_CALLBACK_FAILURE; } - return NGHTTP3_ERR_CALLBACK_FAILURE; + stream->ReceiveData(data, datalen, Stream::ReceiveDataFlags{}); + return NGTCP2_SUCCESS; } static int on_deferred_consume(nghttp3_conn* conn, @@ -1211,10 +1445,10 @@ class Http3ApplicationImpl final : public Session::Application { void* conn_user_data, void* stream_user_data) { NGHTTP3_CALLBACK_SCOPE(app); - auto& session = app.session(); - Debug(&session, "HTTP/3 application deferred consume %zu bytes", consumed); - session.ExtendStreamOffset(id, consumed); - session.ExtendOffset(consumed); + Debug(&app.session(), + "HTTP/3 application deferred consume %zu bytes", + consumed); + app.session().Consume(id, consumed); return NGTCP2_SUCCESS; } @@ -1373,7 +1607,7 @@ class Http3ApplicationImpl final : public Session::Application { static int on_end_origin(nghttp3_conn* conn, void* conn_user_data) { NGHTTP3_CALLBACK_SCOPE(app); if (!app.received_origins_.empty()) { - app.session().EmitOrigins(std::move(app.received_origins_)); + app.EmitOrigins(std::move(app.received_origins_)); app.received_origins_.clear(); } return NGTCP2_SUCCESS; @@ -1405,21 +1639,282 @@ class Http3ApplicationImpl final : public Session::Application { on_receive_settings}; }; -std::optional ParseHttp3TicketData(const uv_buf_t& data) { - if (data.len != kSessionTicketAppDataSize) return std::nullopt; +// The per-session JS-facing handle for HTTP/3 stream operations +// (kHttp3Handle). One per session, with a weak reference to the Session, +// operating on the Application reached from the passed stream handle. +class Http3Binding final : public BaseObject { + public: + static BaseObjectPtr Create(Session* session); + + Http3Binding(Environment* env, Local object) + : BaseObject(env, object) { + MakeWeak(); + } + + JS_CONSTRUCTOR(Http3Binding); + + JS_METHOD(SendHeaders); + JS_METHOD(SendInformationalHeaders); + JS_METHOD(SendTrailers); + JS_METHOD(SetPriority); + JS_METHOD(GetPriority); + + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(Http3Binding) + SET_SELF_SIZE(Http3Binding) +}; + +JS_CONSTRUCTOR_IMPL(Http3Binding, http3binding_constructor_template, { + JS_ILLEGAL_CONSTRUCTOR(); + JS_CLASS(http3binding); + SetProtoMethod(env->isolate(), tmpl, "sendHeaders", SendHeaders); + SetProtoMethod(env->isolate(), + tmpl, + "sendInformationalHeaders", + SendInformationalHeaders); + SetProtoMethod(env->isolate(), tmpl, "sendTrailers", SendTrailers); + SetProtoMethod(env->isolate(), tmpl, "setPriority", SetPriority); + SetProtoMethodNoSideEffect(env->isolate(), tmpl, "getPriority", GetPriority); +}) + +BaseObjectPtr Http3Binding::Create(Session* session) { + JS_NEW_INSTANCE_OR_RETURN(session->env(), obj, BaseObjectPtr()); + return MakeBaseObject(session->env(), obj); +} + +void Http3Binding::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(SendHeaders); + registry->Register(SendInformationalHeaders); + registry->Register(SendTrailers); + registry->Register(SetPriority); + registry->Register(GetPriority); +} + +// The binding is only ever attached to an HTTP/3 session, so the session's +// installed application is always an Http3Application. +inline Http3Application& Http3App(Session& session) { + return static_cast(session.application()); +} + +JS_METHOD_IMPL(Http3Binding::SendHeaders) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsArray()); // headers + CHECK(args[2]->IsUint32()); // flags + + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr) return args.GetReturnValue().Set(false); + + Session& session = stream->session(); + if (!session.has_application()) return args.GetReturnValue().Set(false); + + Local headers = args[1].As(); + auto flags = static_cast(args[2].As()->Value()); + + // A pending stream has no id yet; defer the submission until the transport + // opens it (the priority header, if any, rides along in this header block). + if (stream->is_pending()) { + auto held = + std::make_shared>(binding->env()->isolate(), headers); + stream->RunWhenOpen([stream, flags, held]() { + Session& session = stream->session(); + if (!session.has_application()) return; + Http3Application& app = Http3App(session); + Environment* env = stream->env(); + HandleScope scope(env->isolate()); + if (!app.SubmitHeaders(*stream, held->Get(env->isolate()), flags)) { + stream->Destroy(QuicError::ForApplication(app.GetInternalErrorCode())); + } + }); + return args.GetReturnValue().Set(true); + } + + args.GetReturnValue().Set( + Http3App(session).SubmitHeaders(*stream, headers, flags)); +} + +// Informational/trailing headers are only sent on open streams so they need +// no pending-stream deferral. +JS_METHOD_IMPL(Http3Binding::SendInformationalHeaders) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsArray()); // headers + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr || stream->is_pending()) + return args.GetReturnValue().Set(false); + Session& session = stream->session(); + if (!session.has_application()) return args.GetReturnValue().Set(false); + args.GetReturnValue().Set( + Http3App(session).SubmitInfo(*stream, args[1].As())); +} + +JS_METHOD_IMPL(Http3Binding::SendTrailers) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsArray()); // headers + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr || stream->is_pending()) + return args.GetReturnValue().Set(false); + Session& session = stream->session(); + if (!session.has_application()) return args.GetReturnValue().Set(false); + args.GetReturnValue().Set( + Http3App(session).SubmitTrailers(*stream, args[1].As())); +} + +JS_METHOD_IMPL(Http3Binding::SetPriority) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + CHECK(args[1]->IsUint32()); // packed: (urgency << 1) | incremental + + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr) return; + Session& session = stream->session(); + if (!session.has_application()) return; + + uint32_t packed = args[1].As()->Value(); + auto priority = static_cast(packed >> 1); + StreamPriorityFlags flags = (packed & 1) + ? StreamPriorityFlags::INCREMENTAL + : StreamPriorityFlags::NON_INCREMENTAL; + + // A PRIORITY_UPDATE needs the stream to exist in nghttp3, which only happens + // once the deferred header submission runs; defer until the stream opens. + if (stream->is_pending()) { + stream->RunWhenOpen([stream, priority, flags]() { + if (stream->session().has_application()) { + Http3App(stream->session()).SetStreamPriority(*stream, priority, flags); + } + }); + return; + } + Http3App(session).SetStreamPriority(*stream, priority, flags); +} + +JS_METHOD_IMPL(Http3Binding::GetPriority) { + Http3Binding* binding; + ASSIGN_OR_RETURN_UNWRAP(&binding, args.This()); + Stream* stream = BaseObject::Unwrap(args[0]); + if (stream == nullptr) return; + Session& session = stream->session(); + if (!session.has_application() || stream->is_pending()) return; + + auto result = Http3App(session).GetStreamPriority(*stream); + uint32_t packed = (static_cast(result.priority) << 1) | + (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); + args.GetReturnValue().Set(packed); +} + +namespace { +std::unique_ptr CreateHttp3Application(Session* session); +} // namespace + +void CreateHttp3Handle(const FunctionCallbackInfo& args) { + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args[0]); + + if (!session->has_application()) { + if (session->is_active()) { + THROW_ERR_INVALID_STATE( + session->env(), + "An application can only be attached to a QUIC session before it " + "becomes active (begins emitting events)"); + return; + } + if (!session->AttachApplication(CreateHttp3Application(session))) { + return; + } + + if (session->is_server() && !session->application().Start()) { + // Start() failed (e.g. the peer's initial_max_streams_uni is < 3), so + // the application cannot run HTTP/3. + THROW_ERR_INVALID_STATE(session->env(), + "The HTTP/3 application could not be started"); + return; + } + } + + BaseObjectPtr handle = Http3Binding::Create(session); + if (handle) args.GetReturnValue().Set(handle->object()); +} + +void RegisterHttp3ExternalReferences(ExternalReferenceRegistry* registry) { + registry->Register(CreateHttp3Handle); + Http3Binding::RegisterExternalReferences(registry); +} + +void InitHttp3PerContext(Local target) { + // The HTTP/3 header kind/flags values consumed by node:http3 when calling + // the kHttp3Handle methods. These are http3-owned constants exposed on the + // quic binding object. + constexpr int QUIC_STREAM_HEADERS_KIND_HINTS = + static_cast(HeadersKind::HINTS); + constexpr int QUIC_STREAM_HEADERS_KIND_INITIAL = + static_cast(HeadersKind::INITIAL); + constexpr int QUIC_STREAM_HEADERS_KIND_TRAILING = + static_cast(HeadersKind::TRAILING); + constexpr int QUIC_STREAM_HEADERS_FLAGS_NONE = + static_cast(HeadersFlags::NONE); + constexpr int QUIC_STREAM_HEADERS_FLAGS_TERMINAL = + static_cast(HeadersFlags::TERMINAL); + + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_HINTS); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_INITIAL); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_TRAILING); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_NONE); + NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_TERMINAL); +} + +namespace { +// Resolves the effective HTTP/3 settings for a session's options: the +// consumer-supplied settings (or defaults). SETTINGS_H3_DATAGRAM stays +// disabled (HTTP/3 datagrams not yet supported). +Http3Settings ResolveHttp3Settings(const Session::Options& options) { + return options.application_settings + ? *std::static_pointer_cast( + options.application_settings) + : Http3Settings(); +} + +std::unique_ptr CreateHttp3Application(Session* session) { + Debug(session, "Installing HTTP/3 application"); + return std::make_unique( + session, ResolveHttp3Settings(std::as_const(*session).config().options)); +} + +Maybe> ParseHttp3Settings(Environment* env, + Local value) { + Http3Settings settings; + if (!Http3Settings::From(env, value).To(&settings)) { + return Nothing>(); + } + return Just(std::static_pointer_cast( + std::make_shared(settings))); +} + +// Parses and validates the typed HTTP/3 session-ticket app data against +// the session's configured settings at decrypt time (relax-tolerant: +// 0-RTT is rejected only when settings were tightened relative to +// ticket issuance). Returns nullptr to reject the ticket. +PendingTicketAppData ParseHttp3Ticket(const uv_buf_t& data, + const Session::Options& options) { + if (data.len != kSessionTicketAppDataSize) return nullptr; const uint8_t* buf = reinterpret_cast(data.base); - // buf[0] is the type byte (already checked by caller), buf[1] is version. - if (buf[1] != kSessionTicketAppDataVersion) return std::nullopt; + if (buf[0] != kSessionTicketAppDataVersion) { + return nullptr; + } const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; - uint32_t stored_crc = ReadBE32(buf + 2); + uint32_t stored_crc = ReadBE32(buf + 1); uLong computed_crc = crc32(0L, Z_NULL, 0); computed_crc = crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); - if (stored_crc != static_cast(computed_crc)) return std::nullopt; + if (stored_crc != static_cast(computed_crc)) return nullptr; - return Http3TicketData{ + Http3TicketData ticket{ ReadBE64(payload), ReadBE64(payload + 8), ReadBE64(payload + 16), @@ -1427,12 +1922,30 @@ std::optional ParseHttp3TicketData(const uv_buf_t& data) { payload[32] != 0, payload[33] != 0, }; + + Http3Settings current = ResolveHttp3Settings(options); + if (current.max_field_section_size < ticket.max_field_section_size || + current.qpack_max_dtable_capacity < ticket.qpack_max_dtable_capacity || + current.qpack_encoder_max_dtable_capacity < + ticket.qpack_encoder_max_dtable_capacity || + current.qpack_blocked_streams < ticket.qpack_blocked_streams || + (ticket.enable_connect_protocol && !current.enable_connect_protocol) || + (ticket.enable_datagrams && !current.enable_datagrams)) { + return nullptr; + } + + return std::static_pointer_cast( + std::make_shared(ticket)); } +} // namespace -std::unique_ptr CreateHttp3Application( - Session* session, const Session::Application_Options& options) { - Debug(session, "Selecting HTTP/3 application"); - return std::make_unique(session, options); +void RegisterHttp3Application() { + RegisterApplicationFactory("http3", + { + .create = CreateHttp3Application, + .parse_settings = ParseHttp3Settings, + .parse_ticket = ParseHttp3Ticket, + }); } } // namespace quic diff --git a/src/quic/http3.h b/src/quic/http3.h index f1a1b674d96903..94e0f50c46b29c 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -2,24 +2,25 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include -#include -#include "application.h" -#include "session.h" +#include -namespace node::quic { +namespace node { +class ExternalReferenceRegistry; +namespace quic { -// Create an HTTP/3 Application implementation for the given session. -// Uses the Application_Options from the session's config for HTTP/3 -// specific settings (qpack, max header length, etc.). -std::unique_ptr CreateHttp3Application( - Session* session, const Session::Application_Options& options); +// Registers the HTTP/3 application factory (creation, settings parsing, +// and session-ticket hooks) under the name "http3". Called once at +// binding initialization; a session installs the application only when +// its options request that name explicitly (set by node:http3). +void RegisterHttp3Application(); -// Parse HTTP/3 specific session ticket app data. Called from -// Application::ParseTicketData() when the type byte is HTTP3. -// The data includes the type byte prefix. -std::optional ParseHttp3TicketData(const uv_buf_t& data); +void CreateHttp3Handle(const v8::FunctionCallbackInfo& args); -} // namespace node::quic +void RegisterHttp3ExternalReferences(ExternalReferenceRegistry* registry); + +void InitHttp3PerContext(v8::Local target); + +} // namespace quic +} // namespace node #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/quic.cc b/src/quic/quic.cc index 36e781f1d700b7..f8e814ca006b02 100644 --- a/src/quic/quic.cc +++ b/src/quic/quic.cc @@ -10,6 +10,7 @@ #include #include "bindingdata.h" #include "endpoint.h" +#include "http3.h" #include "node_external_reference.h" #include @@ -49,6 +50,7 @@ void CreatePerContextProperties(Local target, Endpoint::InitPerContext(realm, target); Session::InitPerContext(realm, target); Stream::InitPerContext(realm, target); + InitHttp3PerContext(target); } void RegisterExternalReferences(ExternalReferenceRegistry* registry) { diff --git a/src/quic/session.cc b/src/quic/session.cc index 8380e477c01e80..05b2f05abd3ac1 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -10,20 +10,19 @@ #include #include #include -#include #include #include #include #include #include #include +#include #include "application.h" #include "bindingdata.h" #include "cid.h" #include "data.h" #include "defs.h" #include "endpoint.h" -#include "http3.h" #include "ncrypto.h" #include "packet.h" #include "preferredaddress.h" @@ -71,8 +70,7 @@ enum class SessionListenerFlags : uint32_t { DATAGRAM_STATUS = 1 << 2, SESSION_TICKET = 1 << 3, NEW_TOKEN = 1 << 4, - ORIGIN = 1 << 5, - APPLICATION = 1 << 6 + ORIGIN = 1 << 5 }; inline SessionListenerFlags operator|(SessionListenerFlags a, @@ -134,10 +132,9 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { V(HANDSHAKE_COMPLETED, handshake_completed, uint8_t) \ V(HANDSHAKE_CONFIRMED, handshake_confirmed, uint8_t) \ V(STREAM_OPEN_ALLOWED, stream_open_allowed, uint8_t) \ - V(PRIORITY_SUPPORTED, priority_supported, uint8_t) \ - V(HEADERS_SUPPORTED, headers_supported, uint8_t) \ V(WRAPPED, wrapped, uint8_t) \ - V(APPLICATION_TYPE, application_type, uint8_t) \ + V(IS_SERVER, is_server, uint8_t) \ + V(HAS_APPLICATION, has_application, uint8_t) \ V(NO_ERROR_CODE, no_error_code, error_code) \ V(INTERNAL_ERROR_CODE, internal_error_code, error_code) \ V(MAX_DATAGRAM_SIZE, max_datagram_size, uint16_t) \ @@ -184,6 +181,8 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) { #define SESSION_JS_METHODS(V) \ V(Destroy, destroy, SIDE_EFFECT) \ V(GetRemoteAddress, getRemoteAddress, NO_SIDE_EFFECT) \ + V(GetServername, getServername, NO_SIDE_EFFECT) \ + V(GetAlpnProtocol, getAlpnProtocol, NO_SIDE_EFFECT) \ V(GetLocalAddress, getLocalAddress, NO_SIDE_EFFECT) \ V(GetCertificate, getCertificate, NO_SIDE_EFFECT) \ V(GetEphemeralKeyInfo, getEphemeralKey, NO_SIDE_EFFECT) \ @@ -195,7 +194,7 @@ 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(ApplicationSettings, applicationSettings, NO_SIDE_EFFECT) struct Session::State final { #define V(_, name, type) type name; @@ -247,8 +246,6 @@ SessionStatsArena& GetSessionStatsArena(BindingData& binding) { // ============================================================================ -class Http3Application; - namespace { constexpr std::string to_string(PreferredAddress::Policy policy) { switch (policy) { @@ -615,7 +612,7 @@ Maybe Session::Options::From(Environment* env, if (!SET(version) || !SET(min_version) || !SET(preferred_address_strategy) || !SET(transport_params) || !SET(tls_options) || !SET(qlog) || - !SET(handshake_timeout) || !SET(initial_rtt) || + !SET(application) || !SET(handshake_timeout) || !SET(initial_rtt) || !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) || @@ -648,15 +645,24 @@ Maybe Session::Options::From(Environment* env, } } - // Parse the application-specific options (HTTP/3 qpack settings, etc.). - // These are used if the negotiated ALPN selects Http3ApplicationImpl. - { - Local app_val; - if (params->Get(env->context(), state.application_string()) - .ToLocal(&app_val) && - !app_val->IsUndefined()) { - if (!Application_Options::From(env, app_val) - .To(&options.application_options)) { + // When an application is requested, parse its settings (supplied by + // the application's consumer layer alongside the name) through the + // registered factory's parse hook. The result is carried opaquely on + // the options; the QUIC core never interprets it. + if (!options.application.empty()) { + // The application option is internal-only: a missing registration + // is a bug in the consumer layer, not a user error. + const auto* factory = FindApplicationFactory(options.application); + CHECK_NOT_NULL(factory); + CHECK_NOT_NULL(factory->parse_settings); + Local settings_val; + if (!params->Get(env->context(), state.application_settings_string()) + .ToLocal(&settings_val)) { + return Nothing(); + } + if (!settings_val->IsUndefined()) { + if (!factory->parse_settings(env, settings_val) + .To(&options.application_settings)) { return Nothing(); } } @@ -710,6 +716,18 @@ Maybe Session::Options::From(Environment* env, } } + // Parse the optional opaque application data to embed in session tickets. + Local app_ticket_data_val; + if (params->Get(env->context(), state.app_ticket_data_string()) + .ToLocal(&app_ticket_data_val) && + app_ticket_data_val->IsArrayBufferView()) { + Store app_ticket_data_store; + if (Store::From(app_ticket_data_val.As()) + .To(&app_ticket_data_store)) { + options.app_ticket_data = std::move(app_ticket_data_store); + } + } + return Just(options); } @@ -781,6 +799,8 @@ struct Session::Impl final : public MemoryRetainer { SocketAddress remote_address_; std::unique_ptr application_; StreamsMap streams_; + // Emits deferred until after session setup is completed + std::vector> deferred_emits_; TimerWrapHandle timer_; size_t send_scope_depth_ = 0; QuicError last_error_; @@ -791,10 +811,16 @@ struct Session::Impl final : public MemoryRetainer { PendingStream::PendingStreamQueue pending_bidi_stream_queue_; PendingStream::PendingStreamQueue pending_uni_stream_queue_; - // Session ticket app data parsed before ALPN negotiation. - // Validated and applied in SetApplication() after ALPN selects - // the application type. - std::optional pending_ticket_data_; + // Application-typed session ticket app data parsed (and validated) by + // the registered application factory's ticket hook early in the + // handshake (at ticket decryption). Applied in SetApplication() once + // the application is installed. Opaque to the session. + PendingTicketAppData pending_ticket_data_; + + // The native send queue used when no application is installed: streams + // with pending outbound data are scheduled here and the send pump + // pulls from them directly. + Stream::Queue stream_queue_; // When true, the handshake is deferred until the first stream or // datagram is sent. This is set for client sessions with a session @@ -1001,6 +1027,36 @@ struct Session::Impl final : public MemoryRetainer { session->Destroy(); } + // The SNI servername from the TLS handshake; empty (-> undefined) only if + // none was sent. + JS_METHOD(GetServername) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + if (session->is_destroyed()) return; + auto sn = session->tls_session().servername(); + if (sn.empty()) return; + Local ret; + if (ToV8Value(env->context(), sn).ToLocal(&ret)) { + args.GetReturnValue().Set(ret); + } + } + + // The negotiated ALPN protocol. Undefined only for clients before the + // handshake is completed, as ALPN is mandatory for QUIC. + JS_METHOD(GetAlpnProtocol) { + auto env = Environment::GetCurrent(args); + Session* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + if (session->is_destroyed()) return; + auto proto = session->tls_session().protocol(); + if (proto.empty()) return; + Local ret; + if (ToV8Value(env->context(), proto).ToLocal(&ret)) { + args.GetReturnValue().Set(ret); + } + } + JS_METHOD(GetRemoteAddress) { auto env = Environment::GetCurrent(args); Session* session; @@ -1199,20 +1255,19 @@ struct Session::Impl final : public MemoryRetainer { } } - JS_METHOD(ApplicationOptions) { + JS_METHOD(ApplicationSettings) { auto env = Environment::GetCurrent(args); Session* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); Local obj; if (!session->has_application()) { - // The application has not yet been selected (ALPN negotiation is not - // yet complete on the server) or the session has been destroyed. In - // either case, the application options are not available. + // No application is installed (none was requested, the server has + // not completed ALPN negotiation yet, or the session has been + // destroyed). In all of these cases there are no settings. return args.GetReturnValue().SetUndefined(); } - auto& options = session->application().options(); - if (options.ToObject(env).ToLocal(&obj)) { + if (session->application().GetSettingsObject(env).ToLocal(&obj)) { args.GetReturnValue().Set(obj); } } @@ -1233,7 +1288,7 @@ struct Session::Impl final : public MemoryRetainer { // the datalen as our accounting does not track the offset and // acknowledges should never come out of order here. if (datalen == 0) return NGTCP2_SUCCESS; - return session->application().AcknowledgeStreamData(stream_id, datalen) + return session->AcknowledgeStreamData(stream_id, datalen) ? NGTCP2_SUCCESS : NGTCP2_ERR_CALLBACK_FAILURE; } @@ -1314,7 +1369,13 @@ struct Session::Impl final : public MemoryRetainer { void* stream_user_data) { NGTCP2_CALLBACK_SCOPE(session) if (auto* stream = Stream::From(stream_user_data)) { - session->application().ExtendMaxStreamData(stream, max_data); + if (session->impl_->application_) { + session->application().ExtendMaxStreamData(stream, max_data); + } else { + // Native path: the peer granted more flow control for this + // stream. Re-schedule it so SendPendingData resumes writing. + stream->Schedule(&session->impl_->stream_queue_); + } } return NGTCP2_SUCCESS; } @@ -1408,6 +1469,10 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) return NGTCP2_SUCCESS; + // With no application installed there is nothing to start; the + // native raw-stream path needs no startup. + 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; @@ -1456,18 +1521,17 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_STREAM_DATA_FLAG_0RTT, }; - // We received data for a stream! What we don't know yet at this point - // is whether the application wants us to treat this as a control stream - // data (something the application will handle on its own) or a user stream - // data (something that we should create a Stream handle for that is passed - // out to JavaScript). HTTP3, for instance, will generally create three - // control stream in either direction and we want to make sure those are - // never exposed to users and that we don't waste time creating Stream - // handles for them. So, what we do here is pass the stream data on to the - // application for processing. If it ends up being a user stream, the - // application will handle creating the Stream handle and passing that off - // to the JavaScript side. - if (!session->application().ReceiveStreamData( + // We received data for a stream! When an application is installed, + // the data is passed to it for processing: the application decides + // whether this is control stream data (something it handles on its + // own) or user stream data (something we should create a Stream + // handle for that is passed out to JavaScript). HTTP3, for instance, + // will generally create three control streams in either direction + // and we want to make sure those are never exposed to users and that + // we don't waste time creating Stream handles for them. With no + // application installed, the native path delivers every stream's + // data straight to its Stream handle. + if (!session->ReceiveStreamData( stream_id, data, datalen, data_flags, stream_user_data)) { return NGTCP2_ERR_CALLBACK_FAILURE; } @@ -1491,10 +1555,16 @@ struct Session::Impl final : public MemoryRetainer { if (level != NGTCP2_ENCRYPTION_LEVEL_0RTT) return NGTCP2_SUCCESS; } - // application_ may be null if ALPN selection hasn't happened yet - // (e.g., ALPN mismatch causes the handshake to fail during key - // installation). Without an application, we can't start. - if (!session->impl_->application_) return NGTCP2_ERR_CALLBACK_FAILURE; + // With no application installed there are two cases. If none was + // requested, the native raw-stream path needs no startup. If one + // was requested but is absent here, installation failed (e.g. ALPN + // mismatch, or the session ticket data was rejected) and the + // handshake must fail. + if (!session->impl_->application_) { + return session->config().options.application.empty() + ? NGTCP2_SUCCESS + : NGTCP2_ERR_CALLBACK_FAILURE; + } Debug(session, "Receiving TX key for level %s for dcid %s", @@ -1541,18 +1611,21 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - if (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) { - session->application().ReceiveStreamClose( - stream, QuicError::ForApplication(app_error_code)); + QuicError error = (flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET) + ? QuicError::ForApplication(app_error_code) + : QuicError(); + if (session->impl_->application_) { + session->application().ReceiveStreamClose(stream, std::move(error)); } else { - session->application().ReceiveStreamClose(stream); + // Native path: just destroy the stream. + stream->Destroy(std::move(error)); } return NGTCP2_SUCCESS; } static int on_stream_open(ngtcp2_conn* conn, stream_id id, void* user_data) { NGTCP2_CALLBACK_SCOPE(session) - if (!session->application().ReceiveStreamOpen(id)) { + if (!session->ReceiveStreamOpen(id)) { return NGTCP2_ERR_CALLBACK_FAILURE; } return NGTCP2_SUCCESS; @@ -1567,8 +1640,13 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - session->application().ReceiveStreamReset( - stream, final_size, QuicError::ForApplication(app_error_code)); + if (session->impl_->application_) { + session->application().ReceiveStreamReset( + stream, final_size, QuicError::ForApplication(app_error_code)); + } else { + stream->ReceiveStreamReset(final_size, + QuicError::ForApplication(app_error_code)); + } return NGTCP2_SUCCESS; } @@ -1580,8 +1658,12 @@ struct Session::Impl final : public MemoryRetainer { NGTCP2_CALLBACK_SCOPE(session) auto* stream = Stream::From(stream_user_data); if (stream == nullptr) return NGTCP2_SUCCESS; - session->application().ReceiveStreamStopSending( - stream, QuicError::ForApplication(app_error_code)); + if (session->impl_->application_) { + session->application().ReceiveStreamStopSending( + stream, QuicError::ForApplication(app_error_code)); + } else { + stream->ReceiveStopSending(QuicError::ForApplication(app_error_code)); + } return NGTCP2_SUCCESS; } @@ -1597,6 +1679,16 @@ struct Session::Impl final : public MemoryRetainer { Debug(session, "Early data was rejected"); if (session->impl_->application_) { session->application().EarlyDataRejected(); + } else { + // Native path: destroy all streams opened during the 0-RTT phase + // (ngtcp2 has already discarded their internal state) and notify + // JS. Use the internal error code since this is an error condition + // (code 0 would be treated as a clean close). + session->DestroyAllStreams( + QuicError::ForApplication(NGTCP2_INTERNAL_ERROR)); + if (!session->is_destroyed()) { + session->EmitEarlyDataRejected(); + } } return NGTCP2_SUCCESS; } @@ -1729,9 +1821,633 @@ 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->application().SendPendingData(); + session->can_send_pending_data() && + !session->impl_->handshake_deferred_) { + session->SendPendingData(); + } +} + +// ============================================================================ + +std::string StreamData::ToString() const { + DebugIndentScope indent; + + size_t total_bytes = 0; + for (size_t n = 0; n < count; n++) { + total_bytes += data[n].len; + } + + auto prefix = indent.Prefix(); + std::string res("{"); + res += prefix + "count: " + std::to_string(count); + res += prefix + "id: " + std::to_string(id); + res += prefix + "fin: " + std::to_string(fin); + res += prefix + "total: " + std::to_string(total_bytes); + res += indent.Close(); + return res; +} + +Packet::Ptr Session::CreateStreamDataPacket() { + return endpoint().CreatePacket( + remote_address(), max_packet_size(), "stream data"); +} + +// Attempts to pack a pending datagram into the current packet. +// Returns the nwrite value from ngtcp2_conn_writev_datagram. +// On fatal error, closes the session and returns the error code. +// The caller should check: +// > 0: packet is complete, send it (pos was NOT advanced — caller +// must add nwrite to pos and send) +// NGTCP2_ERR_WRITE_MORE: datagram packed, room for more +// 0: congestion controlled or doesn't fit, datagram stays in queue +// < 0 (other): fatal error, session already closed +ssize_t Session::TryWritePendingDatagram(PathStorage* path, + uint8_t* dest, + size_t destlen, + uint64_t ts) { + CHECK(HasPendingDatagrams()); + auto max_attempts = config().options.max_datagram_send_attempts; + + // Skip datagrams that have already exceeded the send attempt limit + // from a previous SendPendingData cycle. + while (HasPendingDatagrams()) { + auto& front = PeekPendingDatagram(); + if (front.send_attempts < max_attempts) break; + Debug(this, + "Datagram %" PRIu64 " abandoned after %u attempts", + front.id, + front.send_attempts); + DatagramStatus(front.id, DatagramStatus::ABANDONED); + PopPendingDatagram(); + } + + if (!HasPendingDatagrams()) return 0; + auto& dg = PeekPendingDatagram(); + ngtcp2_vec dgvec = dg.data; + int accepted = 0; + int dg_flags = NGTCP2_WRITE_DATAGRAM_FLAG_MORE; + + // PacketInfo for the datagram path. When libuv gains per-socket ECN + // marking, the value from ngtcp2 should be forwarded to the send path. + PacketInfo dg_pi; + ssize_t dg_nwrite = ngtcp2_conn_writev_datagram(*this, + &path->path, + dg_pi, + dest, + destlen, + &accepted, + dg_flags, + dg.id, + &dgvec, + 1, + ts); + + if (accepted) { + // Nice, the datagram was accepted! + Debug(this, "Datagram %" PRIu64 " accepted into packet", dg.id); + DatagramSent(dg.id); + PopPendingDatagram(); + } else { + Debug(this, "Datagram %" PRIu64 " not accepted into packet", dg.id); + } + + switch (dg_nwrite) { + case 0: { + // If dg_nwrite is 0, we are either congestion controlled or + // there wasn't enough room in the packet for the datagram or + // we aren't in a state where we can send. + // We'll skip this attempt and return 0. + CHECK(!accepted); + dg.send_attempts++; + return 0; + } + case NGTCP2_ERR_WRITE_MORE: { + // There's still room left in the packet! + return NGTCP2_ERR_WRITE_MORE; + } + case NGTCP2_ERR_INVALID_STATE: + case NGTCP2_ERR_INVALID_ARGUMENT: { + // Non-fatal error cases. Peer either does not support datagrams + // or the datagram is too large for the peer's max. + // Abandon the datagram and signal skip by returning std::nullopt. + DatagramStatus(dg.id, DatagramStatus::ABANDONED); + PopPendingDatagram(); + return 0; + } + default: { + // Fatal errors: PKT_NUM_EXHAUSTED, CALLBACK_FAILURE, NOMEM, etc. + Debug(this, "Fatal datagram error: %zd", dg_nwrite); + SetLastError(QuicError::ForNgtcp2Error(dg_nwrite)); + Close(CloseMethod::SILENT); + return dg_nwrite; + } + } + UNREACHABLE(); +} + +// the SendPendingData method is the primary driver for sending data from the +// application layer. It loops through available stream data and pending +// datagrams and generates packets to send until there is either no more +// data to send or we hit the maximum number of packets to send in one go. +// This method is extremely delicate. A bug in this method can break the +// entire QUIC implementation; so be very careful when making changes here +// and make sure to test thoroughly. When in doubt... don't change it. +void Session::SendPendingData() { + DCHECK(!is_destroyed()); + if (!can_send_packets()) [[unlikely]] { + return; + } + // Upper bound on packets per SendPendingData call. ngtcp2's send quantum + // is typically 64 KB, which at 1200-byte minimum packet size is ~53 + // packets. 64 covers the worst case with headroom. The actual count per + // call is dynamically capped by ngtcp2_conn_get_send_quantum(). + static constexpr size_t kMaxPackets = 64; + Debug(this, "Session sending pending data"); + // Cache the timestamp once for the entire send loop. ngtcp2 does not + // require nanosecond-accurate monotonicity within a single burst — + // a single timestamp per SendPendingData call is what other QUIC + // implementations use (e.g., quiche, msquic). When kernel-level + // packet pacing becomes available via libuv, this timestamp becomes + // the base for computing per-packet transmit timestamps. + const uint64_t ts = uv_hrtime(); + PathStorage path; + StreamData stream_data; + + bool closed = false; + + // Batch accumulation: packets are collected here and flushed via + // Session::SendBatch when the loop exits, the batch is full, or + // on early return. This enables synchronous batched delivery via + // uv_udp_try_send2 (sendmmsg) from the deferred flush path. + Packet::Ptr batch[kMaxPackets]; + PathStorage batch_paths[kMaxPackets]; + size_t batch_count = 0; + + auto flush_batch = [&] { + if (batch_count == 0) return; + SendBatch(batch, batch_paths, batch_count); + batch_count = 0; + }; + + auto update_stats = OnScopeLeave([&] { + if (closed) return; + // Flush any remaining accumulated packets before updating stats. + flush_batch(); + if (is_destroyed()) [[unlikely]] + return; + + // Get a strong pointer to protect against potential destruction during + // updating the time and data stats. + BaseObjectPtr s(this); + s->UpdatePacketTxTime(); + s->UpdateTimer(); + s->UpdateDataStats(); + }); + + // The maximum size of packet to create. + const size_t max_pktlen = max_packet_size(); + + // The maximum number of packets to send in this call to SendPendingData. + const size_t max_packet_count = + std::min(kMaxPackets, ngtcp2_conn_get_send_quantum(*this) / max_pktlen); + if (max_packet_count == 0) return; + + // The number of packets that have been prepared in this call. + size_t packet_send_count = 0; + + Packet::Ptr packet; + + auto ensure_packet = [&] { + if (!packet) { + packet = CreateStreamDataPacket(); + if (!packet) [[unlikely]] + return false; + } + DCHECK(packet); + return true; + }; + + // Accumulate a completed packet into the batch. + auto enqueue_packet = + [&](Packet::Ptr& pkt, size_t len, const PacketInfo& pi) { + Debug(this, "Enqueuing packet with %zu bytes into batch", len); + pkt->Truncate(len); + pkt->set_pkt_info(pi); + path.CopyTo(&batch_paths[batch_count]); + batch[batch_count++] = std::move(pkt); + }; + + // We're going to enter a loop here to prepare and send no more than + // max_packet_count packets. + for (;;) { + // ndatalen is the amount of stream data that was accepted into the packet. + ssize_t ndatalen = 0; + + // Make sure we have a packet to write data into. + if (!ensure_packet()) [[unlikely]] { + Debug(this, "Failed to create packet for stream data"); + // Doh! Could not create a packet. Time to bail. + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + + // The stream_data is the next block of data from the application + // layer (or, with no application installed, from the session's own + // native send queue). + if (GetStreamData(&stream_data) < 0) { + Debug(this, "Application failed to get stream data"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + + // If we got here, we were at least successful in checking for stream data. + // There might not be any stream data to send. If there is no stream data, + // that's perfectly fine, we still need to serialize any frames we do have + // (pings, acks, datagrams, etc) so we'll just keep going. + if (stream_data.id >= 0) { + Debug(this, "Session using stream data: %s", stream_data); + } else { + Debug(this, "No stream data to send"); + } + if (HasPendingDatagrams()) { + Debug(this, "There are pending datagrams to send"); + } + + // Awesome, let's write our packet! + PacketInfo pi; + ssize_t nwrite = WriteVStream(&path, + &pi, + packet->data(), + &ndatalen, + packet->length(), + stream_data, + ts); + + // When ndatalen is > 0, that's our indication that stream data was + // accepted in to the packet. Yay! + if (ndatalen > 0) { + Debug(this, + "Session accepted %zu bytes from stream %" PRIi64 " into packet", + ndatalen, + stream_data.id); + if (!StreamCommit(&stream_data, ndatalen)) { + // Data was accepted into the packet, but for some reason adjusting + // the stream's committed data failed. Treat as fatal. + Debug(this, "Failed to commit accepted bytes in stream"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + // StreamCommit can call into JS, so a user callback could + // have destroyed the session: + if (is_destroyed()) [[unlikely]] { + closed = true; + return; + } + } else if (stream_data.id >= 0) { + Debug(this, + "Session did not accept any bytes from stream %" PRIi64 + " into packet", + stream_data.id); + } + + // When nwrite is zero, it means we are congestion limited or it is + // just not our turn to send something. Re-schedule the stream if it + // had unsent data (payload or FIN) so the next timer-triggered + // SendPendingData retries it. Without this, a FIN-only send that + // hits nwrite=0 is lost forever — the stream already returned EOS + // from Pull and won't be re-scheduled by anyone else. + // We resume the stream directly (not via Session::ResumeStream) to + // avoid creating a SendPendingDataScope - we're already inside + // SendPendingData and re-entering would just hit nwrite=0 again. + if (nwrite == 0) { + Debug(this, "Congestion or not our turn to send"); + if (stream_data.id >= 0 && (stream_data.count > 0 || stream_data.fin)) { + if (impl_->application_) { + application().ResumeStream(stream_data.id); + } else { + ScheduleStream(stream_data.id); + } + } + + // nwrite == 0 also occurs on an otherwise-idle connection (no + // stream data and no frames due) and writev_stream never covers + // DATAGRAM frames: a datagram queued while the connection is idle + // still needs its own packet. TryWritePendingDatagram applies + // congestion control itself (result 0 when limited), so genuine + // congestion still ends the loop below. + if (HasPendingDatagrams()) { + auto result = TryWritePendingDatagram( + &path, packet->data(), packet->length(), ts); + if (result > 0) { + Debug(this, "Sending datagram packet with %zd bytes", result); + enqueue_packet(packet, static_cast(result), PacketInfo()); + if (++packet_send_count == max_packet_count) return; + continue; + } + if (result == NGTCP2_ERR_WRITE_MORE) continue; + if (result < 0) { + // Fatal error — session already closed + return; + } + // result == 0: congestion limited; fall through and return. + } + + return; + } + + // A negative nwrite value indicates either an error or that there is more + // data to write into the packet. + if (nwrite < 0) { + switch (nwrite) { + case NGTCP2_ERR_STREAM_DATA_BLOCKED: { + // We could not write any data for this stream into the packet + // because the flow control for the stream itself indicates that + // the stream is blocked. We'll skip and move on to the next stream. + // ndatalen = -1 means that no stream data was accepted into the + // packet, which is what we want here. + DCHECK_EQ(ndatalen, -1); + // We should only have received this error if there was an actual + // stream identified in the stream data, but let's double check. + DCHECK_GE(stream_data.id, 0); + StreamDataBlocked(stream_data.id); + continue; + } + case NGTCP2_ERR_STREAM_SHUT_WR: { + // Indicates that the writable side of the stream should be closed + // locally or the stream is being reset. In either case, we can't + // send data for this stream! + Debug(this, "Closing stream %" PRIi64 " for writing", stream_data.id); + // ndatalen = -1 means that no stream data was accepted into the + // packet, which is what we want here. + DCHECK_EQ(ndatalen, -1); + // We should only have received this error if there was an actual + // stream identified in the stream data, but let's double check. + DCHECK_GE(stream_data.id, 0); + if (stream_data.stream) [[likely]] { + stream_data.stream->EndWritable(); + } + // Notify the application that the stream's write side is shut + // so it stops queuing data. Without this, GetStreamData would + // keep returning the same stream and we'd loop forever. The + // native path has no framing layer to notify. + if (impl_->application_) { + application().StreamWriteShut(stream_data.id); + } + continue; + } + case NGTCP2_ERR_WRITE_MORE: { + Debug(this, "Packet buffer not full, coalesce more data into it"); + // Room for more in this packet. Try to pack a pending datagram + // if there is one. Otherwise just loop around and keep going. + if (HasPendingDatagrams()) { + auto result = TryWritePendingDatagram( + &path, packet->data(), packet->length(), ts); + // When result is 0, either the datagram was congestion controlled, + // didn't fit in the packet, or was abandoned. Skip and continue. + + // When result is > 0, the packet is done and the result is the + // completed size of the packet we're sending. + if (result > 0) { + size_t len = result; + Debug(this, "Sending packet with %zu bytes", len); + enqueue_packet(packet, len, pi); + if (++packet_send_count == max_packet_count) return; + } else if (result < 0) { + // Any negative result other than NGTCP2_ERR_WRITE_MORE + // at this point is fatal. The session will have been + // closed. + if (result != NGTCP2_ERR_WRITE_MORE) return; + } + } + continue; + } + case NGTCP2_ERR_CALLBACK_FAILURE: { + // This case really should not happen. It indicates that the + // ngtcp2 callback failed for some reason. This would be a + // bug in our code. + Debug(this, "Internal failure with ngtcp2 callback"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + } + + // Some other type of error happened. + DCHECK_EQ(ndatalen, -1); + Debug(this, + "Session encountered error while writing packet: %s", + ngtcp2_strerror(nwrite)); + SetLastError(QuicError::ForNgtcp2Error(nwrite)); + closed = true; + return Close(CloseMethod::SILENT); + } + + // At this point we have a packet prepared to send. The nwrite + // is the size of the packet we are sending. + size_t len = nwrite; + Debug(this, "Sending packet with %zu bytes", len); + enqueue_packet(packet, len, pi); + if (++packet_send_count == max_packet_count) return; + + // If there are pending datagrams, try sending them in a fresh packet. + // This is necessary because ngtcp2_conn_writev_stream only returns + // NGTCP2_ERR_WRITE_MORE when there is actual stream data — when no + // streams are active, the coalescing path above is never reached and + // datagrams would never be sent. + if (HasPendingDatagrams()) { + if (!ensure_packet()) [[unlikely]] { + Debug(this, "Failed to create packet for datagram"); + SetLastError(QuicError::ForNgtcp2Error(NGTCP2_ERR_INTERNAL)); + closed = true; + return Close(CloseMethod::SILENT); + } + auto result = + TryWritePendingDatagram(&path, packet->data(), packet->length(), ts); + if (result > 0) { + Debug(this, "Sending datagram packet with %zd bytes", result); + enqueue_packet(packet, static_cast(result), PacketInfo()); + if (++packet_send_count == max_packet_count) return; + } else if (result < 0 && result != NGTCP2_ERR_WRITE_MORE) { + // Fatal error — session already closed by TryWritePendingDatagram. + return; + } + // If result == 0 (congestion) or NGTCP2_ERR_WRITE_MORE (datagram + // packed but room for more), the loop continues normally. + } + } +} + +ssize_t Session::WriteVStream(PathStorage* path, + PacketInfo* pi, + uint8_t* dest, + ssize_t* ndatalen, + size_t max_pktlen, + const StreamData& stream_data, + uint64_t ts) { + DCHECK_LE(stream_data.count, kMaxVectorCount); + uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; + if (stream_data.fin) flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; + // The PacketInfo out-param is populated by ngtcp2 with the ECN codepoint + // to apply when sending this packet. When libuv gains per-socket ECN + // marking, the value should be forwarded to the send path. + return ngtcp2_conn_writev_stream(*this, + &path->path, + *pi, + dest, + max_pktlen, + ndatalen, + flags, + stream_data.id, + stream_data, + stream_data.count, + ts); +} + +int Session::GetStreamData(StreamData* stream_data) { + if (impl_->application_) { + return application().GetStreamData(stream_data); + } + + // The native raw-stream path: pull the next block of data from the + // first stream scheduled on the session's own send queue. + + // Reset the state of stream_data before proceeding... + stream_data->id = -1; + stream_data->count = 0; + stream_data->fin = 0; + stream_data->stream.reset(); + Debug(this, "Session getting stream data (native path)"); + DCHECK_NOT_NULL(stream_data); + + // If the connection-level flow control window is exhausted, + // there is no point in pulling stream data. + if (!max_data_left()) return 0; + // If the queue is empty, there aren't any streams with data yet. + if (impl_->stream_queue_.IsEmpty()) return 0; + + Stream* stream = impl_->stream_queue_.PopFront(); + CHECK_NOT_NULL(stream); + stream_data->stream.reset(stream); + stream_data->id = stream->id(); + auto next = + [&](int status, const ngtcp2_vec* data, size_t count, bob::Done done) { + switch (status) { + case bob::Status::STATUS_BLOCK: + // Fall through + case bob::Status::STATUS_WAIT: + return; + case bob::Status::STATUS_EOS: + stream_data->fin = 1; + } + + // It is possible that the data pointers returned are not actually + // the data pointers in the stream_data. If that's the case, we need + // to copy over the pointers. + count = std::min(count, kMaxVectorCount); + ngtcp2_vec* dest = *stream_data; + if (dest != data) { + for (size_t n = 0; n < count; n++) { + dest[n] = data[n]; + } + } + + stream_data->count = count; + + if (count > 0) { + stream->Schedule(&impl_->stream_queue_); + } + + // Not calling done here because we defer committing + // the data until after we're sure it's written. + }; + + if (!stream->is_eos()) [[likely]] { + int ret = stream->Pull(std::move(next), + bob::Options::OPTIONS_SYNC, + stream_data->data, + arraysize(stream_data->data), + kMaxVectorCount); + if (ret == bob::Status::STATUS_EOS) { + stream_data->fin = 1; + } + } else { + stream_data->fin = 1; + } + + return 0; +} + +bool Session::StreamCommit(StreamData* stream_data, size_t datalen) { + if (impl_->application_) { + return application().StreamCommit(stream_data, datalen); + } + DCHECK_NOT_NULL(stream_data); + CHECK(stream_data->stream); + stream_data->stream->Commit(datalen, stream_data->fin); + return true; +} + +bool Session::AcknowledgeStreamData(stream_id id, size_t datalen) { + if (impl_->application_) { + return application().AcknowledgeStreamData(id, datalen); } + if (auto stream = FindStream(id)) [[likely]] { + stream->Acknowledge(datalen); + } + // Returning true even when the stream is not found is intentional. + // After a stream is destroyed, the peer can still ACK data that was + // previously sent. This is benign and should not be treated as an error. + return true; +} + +bool Session::ReceiveStreamOpen(stream_id id) { + if (impl_->application_) { + return application().ReceiveStreamOpen(id); + } + // Native path: create (and announce) a Stream for every remote stream. + auto stream = CreateStream(id); + if (!stream || is_destroyed()) [[unlikely]] { + // We couldn't create the stream, or the session was destroyed + // during the onstream callback (via MakeCallback re-entrancy). + return !is_destroyed(); + } + return true; +} + +bool Session::ReceiveStreamData(stream_id id, + const uint8_t* data, + size_t datalen, + const Stream::ReceiveDataFlags& flags, + void* stream_user_data) { + if (impl_->application_) { + return application().ReceiveStreamData( + id, data, datalen, flags, stream_user_data); + } + + // Native path: deliver the data straight to the Stream handle, + // implicitly creating it if this is the first time we see the stream. + BaseObjectPtr stream; + if (stream_user_data == nullptr) { + stream = CreateStream(id); + if (!stream || is_destroyed()) [[unlikely]] { + // We couldn't create the stream, or the session was destroyed + // during the onstream callback (via MakeCallback re-entrancy). + return false; + } + } else { + stream = BaseObjectPtr(Stream::From(stream_user_data)); + if (!stream) { + Debug(this, "Failed to get existing stream from user data"); + return false; + } + } + + CHECK(stream); + stream->ReceiveData(data, datalen, flags); + return true; } // ============================================================================ @@ -1759,12 +2475,23 @@ Session::Session(Endpoint* endpoint, DCHECK(impl_); STAT_RECORD_TIMESTAMP(Stats, created_at); - // For clients, select the Application immediately — the ALPN is + // The native (no application) error codes. With no application + // installed, "no error" is 0x0 and the non-specific failure code is + // the QUIC transport-level INTERNAL_ERROR (0x1) used by ngtcp2 for + // unspecified failures. An installed application overrides these in + // SetApplication(). + impl_->state()->no_error_code = NGTCP2_NO_ERROR; + impl_->state()->internal_error_code = NGTCP2_INTERNAL_ERROR; + + impl_->state()->is_server = config.side == Side::SERVER ? 1 : 0; + + // For clients, install the requested Application immediately - it is // known upfront from the options. For servers, application_ stays - // null until OnSelectAlpn fires during the TLS handshake. + // null until OnSelectAlpn fires during the TLS handshake. Sessions + // that request no application never install one and run the native + // raw-stream path. if (config.side == Side::CLIENT) { - auto app = - SelectApplicationFromAlpn(DecodeAlpn(config.options.tls_options.alpn)); + auto app = SelectApplication(); if (app) SetApplication(std::move(app)); } @@ -1930,11 +2657,11 @@ 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_) { + // A session that requested an application but has not installed it + // yet (a server before the TLS handshake completes ALPN) cannot do + // an application-level graceful shutdown (GOAWAY, CONNECTION_CLOSE + // etc.), so fall through to a silent close. + if (!impl_->application_ && !config().options.application.empty()) { impl_->state()->silent_close = 1; return FinishClose(); } @@ -1946,9 +2673,12 @@ void Session::Close(CloseMethod method) { // Signal application-level graceful shutdown (e.g., HTTP/3 GOAWAY). // BeginShutdown can trigger callbacks that re-enter JS and destroy - // this session, so check is_destroyed() after it returns. - application().BeginShutdown(); - if (is_destroyed()) return; + // this session, so check is_destroyed() after it returns. With no + // application installed there is no shutdown signaling to do. + if (impl_->application_) { + application().BeginShutdown(); + if (is_destroyed()) return; + } // If there are no open streams, then we can close immediately and // not worry about waiting around. @@ -1963,11 +2693,11 @@ void Session::Close(CloseMethod method) { // the graceful close hangs. Streams still actively receiving data // are left alone to complete naturally. // - // When the application manages stream FIN (HTTP/3), skip this — a + // When the application manages stream FIN (HTTP/3), skip this - a // writable stream with a closed read side is the normal request/ // response pattern (server received full request, still sending // response). The application protocol handles stream completion. - if (!application().stream_fin_managed_by_application()) { + if (!stream_fin_managed_by_application()) { Session::SendPendingDataScope send_scope(this); for (auto& [id, stream] : impl_->streams_) { if (stream->is_writable() && !stream->is_readable()) { @@ -2105,49 +2835,61 @@ Session::Application& Session::application() const { return *impl_->application_; } -std::string_view Session::DecodeAlpn(std::string_view wire) { - // ALPN wire format is length-prefixed: [len][name]. Extract the first entry. - if (wire.size() >= 2) { - uint8_t len = static_cast(wire[0]); - if (len > 0 && static_cast(len + 1) <= wire.size()) { - return wire.substr(1, len); - } - } - return {}; +bool Session::can_send_pending_data() const { + // A session that requested an application must not run the send pump + // before the application is installed: the application owns stream + // scheduling from its first flight. With no application requested the + // native path is always ready. + return impl_->application_ != nullptr || config().options.application.empty(); +} + +bool Session::stream_fin_managed_by_application() const { + return impl_->application_ != nullptr && + impl_->application_->stream_fin_managed_by_application(); } -std::unique_ptr Session::SelectApplicationFromAlpn( - std::string_view alpn) { - // h3 and h3-XX variants use Http3ApplicationImpl. - // Everything else uses DefaultApplication. - if (alpn == "h3" || (alpn.size() > 3 && alpn.substr(0, 3) == "h3-")) { - return CreateHttp3Application(this, config().options.application_options); +error_code Session::internal_error_code() const { + DCHECK(!is_destroyed()); + return impl_->state()->internal_error_code; +} + +std::unique_ptr Session::SelectApplication() { + const auto& name = config().options.application; + if (name.empty()) return nullptr; + const auto* factory = FindApplicationFactory(name); + CHECK_NOT_NULL(factory); + return factory->create(this); +} + +bool Session::AttachApplication(std::unique_ptr app) { + if (config().options.app_ticket_data.has_value()) { + THROW_ERR_INVALID_STATE( + env(), "A QUIC application cannot be combined with appTicketData"); + return false; } - return CreateDefaultApplication(this, config().options.application_options); + SetApplication(std::move(app)); + return true; } void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); + DCHECK(app); // If we have pending ticket data from a session ticket that was - // parsed before ALPN negotiation, validate it against the selected - // application now. If the type doesn't match or the application + // parsed (and validated) by the application's ticket hook before ALPN + // negotiation, apply it to the application now. If the application // rejects the data, the handshake will fail (application_ stays null // and the caller returns an error). - if (impl_->pending_ticket_data_.has_value()) { - auto data = std::move(*impl_->pending_ticket_data_); - impl_->pending_ticket_data_.reset(); + if (impl_->pending_ticket_data_) { + auto data = std::move(impl_->pending_ticket_data_); if (!app->ApplySessionTicketData(data)) { Debug(this, "Session ticket app data rejected by application"); return; } } - impl_->state()->application_type = static_cast(app->type()); - impl_->state()->headers_supported = static_cast( - app->SupportsHeaders() ? HeadersSupportState::SUPPORTED - : HeadersSupportState::UNSUPPORTED); + impl_->state()->has_application = 1; // 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(); @@ -2190,6 +2932,13 @@ const Session::Options& Session::options() const { void Session::EmitQlog(uint32_t flags, std::string_view data) { if (!env()->can_call_into_js()) return; + if (!is_destroyed() && must_defer_emits()) { + auto held = std::string(data); + impl_->deferred_emits_.emplace_back( + [this, flags, held]() { EmitQlog(flags, held); }); + return; + } + bool fin = (flags & NGTCP2_QLOG_WRITE_FLAG_FIN) != 0; // Fun fact... ngtcp2 does not emit the final qlog statement until the @@ -2298,7 +3047,9 @@ bool Session::ReadPacket(const uint8_t* data, // receive path caches a timestamp and passes it to all ReadPacket() // calls in the same I/O burst. if (ts == 0) ts = uv_hrtime(); + rx_packet_ts_ = ts; err = ngtcp2_conn_read_pkt(*this, &path, pkt_info, data, len, ts); + rx_packet_ts_ = 0; } if (is_destroyed()) return false; @@ -2311,7 +3062,17 @@ bool Session::ReadPacket(const uint8_t* data, STAT_INCREMENT_N(Stats, bytes_received, len); // Process deferred operations that couldn't run inside callback // scopes (e.g., HTTP/3 GOAWAY handling that calls into JS). - application().PostReceive(); + if (impl_->application_) application().PostReceive(); + // Surface a server session to JS once its ClientHello has been + // processed (OnSelectAlpn fired: SNI + ALPN are known and reliable). + // Held first-flight events - including 0-RTT request streams - replay + // at emit. The !wrapped guard makes this fire exactly once, on + // whichever packet completes the ClientHello (so a multi-datagram + // ClientHello is handled correctly). + if (is_server() && hello_processed_ && !impl_->state()->wrapped && + !is_destroyed()) { + endpoint().EmitNewSession(BaseObjectPtr(this)); + } } return true; } @@ -2465,11 +3226,11 @@ void Session::SendBatch(Packet::Ptr* packets, void Session::FlushPendingData() { DCHECK(!is_destroyed()); - if (impl_->application_) { + if (can_send_pending_data()) { // 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; - application().SendPendingData(); + SendPendingData(); flags_.prefer_try_send = false; } } @@ -2785,6 +3546,8 @@ void Session::RemoveStream(stream_id id) { ngtcp2_conn_set_stream_user_data(*this, id, nullptr); + if (impl_->application_) application().StreamRemoved(id); + // Note that removing the stream from the streams map likely releases // the last BaseObjectPtr holding onto the Stream instance, at which // point it will be freed. If there are other BaseObjectPtr instances @@ -2806,7 +3569,17 @@ void Session::RemoveStream(stream_id id) { void Session::ResumeStream(stream_id id) { DCHECK(!is_destroyed()); SendPendingDataScope send_scope(this); - application().ResumeStream(id); + if (impl_->application_) { + application().ResumeStream(id); + } else { + ScheduleStream(id); + } +} + +void Session::ScheduleStream(stream_id id) { + if (auto stream = FindStream(id)) [[likely]] { + stream->Schedule(&impl_->stream_queue_); + } } void Session::ShutdownStream(stream_id id, QuicError error) { @@ -2822,9 +3595,9 @@ void Session::ShutdownStream(stream_id id, QuicError error) { if (error.type() == QuicError::Type::APPLICATION) { code = error.code(); } else if (error.code() == NGTCP2_NO_ERROR) { - code = application().GetNoErrorCode(); + code = impl_->state()->no_error_code; } else { - code = application().GetInternalErrorCode(); + code = impl_->state()->internal_error_code; } ngtcp2_conn_shutdown_stream(*this, 0, id, code); } @@ -2838,9 +3611,9 @@ void Session::ShutdownStreamWrite(stream_id id, QuicError error) { if (error.type() == QuicError::Type::APPLICATION) { code = error.code(); } else if (error.code() == NGTCP2_NO_ERROR) { - code = application().GetNoErrorCode(); + code = impl_->state()->no_error_code; } else { - code = application().GetInternalErrorCode(); + code = impl_->state()->internal_error_code; } ngtcp2_conn_shutdown_stream_write(*this, 0, id, code); } @@ -2848,54 +3621,86 @@ void Session::ShutdownStreamWrite(stream_id id, QuicError error) { void Session::StreamDataBlocked(stream_id id) { DCHECK(!is_destroyed()); STAT_INCREMENT(Stats, block_count); - application().BlockStream(id); + if (impl_->application_) { + return application().BlockStream(id); + } + if (auto stream = FindStream(id)) [[likely]] { + // Native path: remove the stream from the send queue. It will be + // re-scheduled via the extend-max-stream-data callback when the peer + // grants more flow control. Without this, SendPendingData would + // repeatedly pop and retry the same blocked stream in an infinite + // loop. + stream->Unschedule(); + stream->EmitBlocked(); + } } void Session::CollectSessionTicketAppData( SessionTicket::AppData* app_data) const { DCHECK(!is_destroyed()); - application().CollectSessionTicketAppData(app_data); + if (impl_->application_) { + return application().CollectSessionTicketAppData(app_data); + } + // Native path: embed the configured opaque app_ticket_data directly so it + // can be checked at resumption. + const auto& atd = config().options.app_ticket_data; + if (atd.has_value() && atd->length() != 0) { + app_data->Set(*atd); + } } SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { DCHECK(!is_destroyed()); - // If the application is already selected (client side, or server after - // ALPN), delegate directly. if (impl_->application_) { - return application().ExtractSessionTicketAppData(app_data, flag); + // I think this is unreacahable. If it ever happens, ignoring + // the ticket to fall back to 1RTT is fine. + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } - // The application is not yet selected (server during ClientHello - // processing, before ALPN). Parse the ticket data now while the - // SSL_SESSION is still valid, and stash the result for validation - // after ALPN negotiation in SetApplication(). - auto data = app_data.Get(); - if (!data.has_value() || data->len == 0) { - // No app data in the ticket. Accept optimistically. + const auto accept = [&] { return flag == Flag::STATUS_RENEW ? SessionTicket::AppData::Status::TICKET_USE_RENEW : SessionTicket::AppData::Status::TICKET_USE; - } - auto parsed = Application::ParseTicketData(*data); - if (!parsed.has_value()) { - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - // Pre-validate the ticket data against the current application options. - // If the stored settings are more permissive than the current config - // (e.g., a feature was enabled when the ticket was issued but is now - // disabled), reject the ticket so 0-RTT is not used. This must happen - // here (during TLS ticket processing) rather than in SetApplication, - // because by SetApplication time the TLS layer has already accepted - // the ticket and told the client 0-RTT is ok. - if (!Application::ValidateTicketData(*parsed, - config().options.application_options)) { - Debug(this, "Session ticket app data incompatible with current settings"); + }; + auto data = app_data.Get(); + if (!data.has_value() || data->len == 0) { + // No app data in the ticket. Accept optimistically. + return accept(); + } + // Validation must happen here (during TLS ticket processing) rather + // than later at install time, because by then the TLS layer has + // already accepted the ticket and told the client 0-RTT is ok. + const auto& application_name = config().options.application; + if (!application_name.empty()) { + // An application is configured but not yet installed (server during + // ClientHello processing, before ALPN completes). Route the data to + // the registered application's ticket hook: it parses and validates + // against the configured settings now - while the SSL_SESSION is + // still valid - and the parsed result is applied once the + // application installs in SetApplication(). + const auto* factory = FindApplicationFactory(application_name); + CHECK_NOT_NULL(factory); + CHECK_NOT_NULL(factory->parse_ticket); + auto parsed = factory->parse_ticket(*data, config().options); + if (!parsed) { + Debug(this, "Session ticket app data rejected for application"); + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } + impl_->pending_ticket_data_ = std::move(parsed); + return accept(); + } + // Native path (no application): byte-match the opaque app data against the + // server's currently configured `app_ticket_data`, or reject back to 1RTT. + const auto* p = reinterpret_cast(data->base); + const auto& atd = config().options.app_ticket_data; + uv_buf_t cur = + atd.has_value() ? static_cast(*atd) : uv_buf_init(nullptr, 0); + if (data->len != cur.len || + (cur.len > 0 && memcmp(p, cur.base, cur.len) != 0)) { + Debug(this, "Session ticket app data does not match configured value"); return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } - impl_->pending_ticket_data_ = std::move(parsed); - return flag == Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; + return accept(); } void Session::MemoryInfo(MemoryTracker* tracker) const { @@ -2959,6 +3764,30 @@ uint64_t Session::max_data_left() const { return ngtcp2_conn_get_max_data_left(*this); } +bool Session::OpenUni(stream_id* id) { + return ngtcp2_conn_open_uni_stream(*this, id, nullptr) == 0; +} + +void Session::ExtendMaxStreams(Direction direction, uint64_t max) { + switch (direction) { + case Direction::BIDIRECTIONAL: + ngtcp2_conn_extend_max_streams_bidi(*this, static_cast(max)); + break; + case Direction::UNIDIRECTIONAL: + ngtcp2_conn_extend_max_streams_uni(*this, static_cast(max)); + break; + } +} + +void Session::Consume(stream_id id, size_t len) { + ExtendStreamOffset(id, len); + ExtendOffset(len); +} + +void Session::SetError(error_code app_error_code) { + SetLastError(QuicError::ForApplication(app_error_code)); +} + uint64_t Session::max_local_streams_uni() const { DCHECK(!is_destroyed()); return ngtcp2_conn_get_streams_uni_left(*this); @@ -2975,9 +3804,35 @@ void Session::set_wrapped() { impl_->state()->wrapped = 1; } -void Session::set_priority_supported(bool on) { - DCHECK(!is_destroyed()); - impl_->state()->priority_supported = on ? 1 : 0; +bool Session::must_defer_emits() const { + // Server sessions are surfaced to JS (via the deferred new-session + // emit) only after their first datagram has been processed; anything + // emitted before then has no JS wrapper to receive it and must be + // held for replay. + return is_server() && !impl_->state()->wrapped; +} + +void Session::ReplayDeferredEmits() { + // Once we're firing events, the server session is active: + active_ = true; + if (is_destroyed()) return; + DCHECK(impl_->state()->wrapped); + // Runs synchronously immediately after the new-session callback + // returns (still within first-datagram processing). + auto emits = std::move(impl_->deferred_emits_); + for (auto& emit : emits) { + if (is_destroyed()) return; + emit(); + } +} + +void Session::QueueDeferredEmit(std::function fn) { + impl_->deferred_emits_.emplace_back(std::move(fn)); +} + +bool Session::has_origin_listener() const { + return HasListenerFlag(impl_->state()->listener_flags, + SessionListenerFlags::ORIGIN); } void Session::ExtendStreamOffset(stream_id id, size_t amount) { @@ -3137,7 +3992,7 @@ void Session::SendConnectionClose() { void Session::OnTimeout() { if (is_destroyed()) return; - if (!impl_->application_) return; + if (!can_send_pending_data()) 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 @@ -3157,7 +4012,7 @@ void Session::OnTimeout() { // which can synchronously destroy the session. Guard before proceeding. if (is_destroyed()) return; if (NGTCP2_OK(ret) && !is_in_closing_period() && !is_in_draining_period()) { - application().SendPendingData(); + SendPendingData(); if (is_destroyed()) return; CheckStreamIdleTimeout(uv_hrtime()); return; @@ -3284,6 +4139,9 @@ bool Session::HandshakeCompleted() { Debug(this, "Session handshake completed"); impl_->state()->handshake_completed = 1; + // For a client, once the handshake is completed, we're active. The server + // is active earlier, as 0RTT etc can fire before handshake completion. + active_ = true; STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at); SetStreamOpenAllowed(); @@ -3480,24 +4338,17 @@ void Session::set_max_datagram_size(uint16_t size) { } } -void Session::EmitGoaway(stream_id last_stream_id) { - if (is_destroyed()) return; - if (!env()->can_call_into_js()) return; - - CallbackScope cb_scope(this); - - Local argv[] = { - BigInt::New(env()->isolate(), last_stream_id), - }; - - MakeCallback( - BindingData::Get(env()).session_goaway_callback(), arraysize(argv), argv); -} - void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) { DCHECK(!is_destroyed()); if (!env()->can_call_into_js()) return; + if (must_defer_emits()) { + auto held = std::make_shared(std::move(datagram)); + impl_->deferred_emits_.emplace_back( + [this, held, flag]() { EmitDatagram(std::move(*held), flag); }); + return; + } + CallbackScope cbv_scope(this); Local argv[] = {datagram.ToUint8Array(env()), @@ -3670,33 +4521,6 @@ void Session::EmitSessionTicket(Store&& ticket) { } } -void Session::EmitApplication() { - if (is_destroyed()) return; - if (!env()->can_call_into_js()) return; - - if (!has_application()) { - // The application has not yet been selected (ALPN negotiation is not - // yet complete on the server) or the session has been destroyed. In - // either case, the application options are not available. - // Should not happen, but we bail out - return; - } - - if (!HasListenerFlag(impl_->state()->listener_flags, - SessionListenerFlags::APPLICATION)) [[likely]] { - return; - } - - CallbackScope cb_scope(this); - - Local argv; - auto& options = application().options(); - if (options.ToObject(env()).ToLocal(&argv)) { - MakeCallback( - BindingData::Get(env()).session_application_callback(), 1, &argv); - } -} - void Session::DestroyAllStreams(const QuicError& error) { DCHECK(!is_destroyed()); // Copy the streams map since streams remove themselves during @@ -3742,6 +4566,14 @@ void Session::EmitNewToken(const uint8_t* token, size_t len) { void Session::EmitStream(const BaseObjectWeakPtr& stream) { DCHECK(!is_destroyed()); + if (must_defer_emits()) { + impl_->deferred_emits_.emplace_back( + [this, stream]() { EmitStream(stream); }); + return; + } + + if (!stream) return; + if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -3795,36 +4627,18 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, argv); } -void Session::EmitOrigins(std::vector&& origins) { +void Session::EmitKeylog(const char* line) { DCHECK(!is_destroyed()); - if (!HasListenerFlag(impl_->state()->listener_flags, - SessionListenerFlags::ORIGIN)) - return; if (!env()->can_call_into_js()) return; - CallbackScope cb_scope(this); - - auto isolate = env()->isolate(); + auto str = std::string(line); - LocalVector elements(env()->isolate(), origins.size()); - for (size_t i = 0; i < origins.size(); i++) { - Local str; - if (!ToV8Value(env()->context(), origins[i]).ToLocal(&str)) [[unlikely]] { - return; - } - elements[i] = str; + if (must_defer_emits()) { + impl_->deferred_emits_.emplace_back( + [this, str]() { EmitKeylog(str.c_str()); }); + return; } - Local argv[] = {Array::New(isolate, elements.data(), elements.size())}; - MakeCallback( - BindingData::Get(env()).session_origin_callback(), arraysize(argv), argv); -} - -void Session::EmitKeylog(const char* line) { - DCHECK(!is_destroyed()); - if (!env()->can_call_into_js()) return; - - auto str = std::string(line); Local argv[] = {Undefined(env()->isolate())}; if (!ToV8Value(env()->context(), str).ToLocal(&argv[0])) { Debug(this, "Failed to convert keylog line to V8 string"); @@ -3882,8 +4696,6 @@ void Session::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_BIDIRECTIONAL); NODE_DEFINE_CONSTANT(target, STREAM_DIRECTION_UNIDIRECTIONAL); - NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LIST_PAIRS); - NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_HEADER_LENGTH); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MIN); diff --git a/src/quic/session.h b/src/quic/session.h index 0caeb764ba56c8..67d8277d8d5916 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -7,11 +7,13 @@ #include #include #include -#include #include #include #include +#include +#include #include +#include #include "bindingdata.h" #include "cid.h" #include "data.h" @@ -27,6 +29,25 @@ namespace node::quic { class Endpoint; +// A block of pending outbound stream data, passed between the application +// layer (which fills it via GetStreamData) and the send pump (which hands +// it to ngtcp2_conn_writev_stream and commits the accepted length). +struct StreamData final { + // The actual number of vectors in the struct, up to kMaxVectorCount. + size_t count = 0; + // The stream identifier. If this is a negative value then no stream is + // identified. + stream_id id = -1; + int fin = 0; + ngtcp2_vec data[kMaxVectorCount]{}; + BaseObjectPtr stream; + + inline operator const ngtcp2_vec*() const { return data; } + inline operator ngtcp2_vec*() { return data; } + + std::string ToString() const; +}; + // A Session represents one half of a persistent connection between two QUIC // peers. Every Session is established first by performing a TLS handshake in // which the client sends an initial packet to the server containing a TLS @@ -58,65 +79,25 @@ class Endpoint; class Session final : public AsyncWrap, private SessionTicket::AppData::Source { public: SessionTicket::AppData::Source& ticket_app_data_source() { return *this; } - // For simplicity, we use the same Application::Options struct for all - // Application types. This may change in the future. Not all of the options - // are going to be relevant for all Application types. - struct Application_Options final : public MemoryRetainer { - // The maximum number of header pairs permitted for a Stream. - // Only relevant if the selected application supports headers. - uint64_t max_header_pairs = DEFAULT_MAX_HEADER_LIST_PAIRS; - - // The maximum total number of header bytes (including header - // name and value) permitted for a Stream. - // Only relevant if the selected application supports headers. - uint64_t max_header_length = DEFAULT_MAX_HEADER_LENGTH; - - // HTTP/3 specific options. - // The maximum header section size advertised to the peer in SETTINGS. - // Defaults to match max_header_length so the SETTINGS frame accurately - // reflects the enforcement limit. A value of 0 would incorrectly tell - // the peer not to send any headers at all. - uint64_t max_field_section_size = DEFAULT_MAX_HEADER_LENGTH; - uint64_t qpack_max_dtable_capacity = 4096; - uint64_t qpack_encoder_max_dtable_capacity = 4096; - uint64_t qpack_blocked_streams = 100; - - bool enable_connect_protocol = true; - bool enable_datagrams = true; - - operator const nghttp3_settings() const; - - SET_NO_MEMORY_INFO() - SET_MEMORY_INFO_NAME(Application::Options) - SET_SELF_SIZE(Options) - - static v8::Maybe From(Environment* env, - v8::Local value); - - std::string ToString() const; - - v8::MaybeLocal ToObject(Environment* env) const; - - 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; - // Decode the first ALPN protocol name from wire format (length-prefixed). - static std::string_view DecodeAlpn(std::string_view wire); - - // Select the Application implementation based on the negotiated ALPN. - // h3 (and h3-XX variants) map to Http3ApplicationImpl; all others map - // to DefaultApplication. Sets the application_type state field. - std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); + // Select the Application implementation: the factory registered under + // options.application when set (see application.h), otherwise nullptr. + // With no application requested the session runs the native raw-stream + // path and no Application is ever installed. + std::unique_ptr SelectApplication(); - // Install the Application on the session. Called at construction for - // clients (ALPN known upfront) or from OnSelectAlpn for servers - // (ALPN negotiated during handshake). Must be called before any - // application data is received. + // Installs the Application on the session WITHOUT validation. Called at + // construction for clients or from OnSelectAlpn for servers (later in the + // handshake, after session-ticket decryption). void SetApplication(std::unique_ptr app); + + // Validating wrapper around SetApplication() for dynamic attachment of an + // application to a live session. + bool AttachApplication(std::unique_ptr app); // Controls which datagram to drop when the pending datagram queue is full. enum class DatagramDropPolicy : uint8_t { DROP_OLDEST = 0, // Drop the oldest queued datagram (default). @@ -151,9 +132,17 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // so that it cannot be garbage collected. BaseObjectPtr cid_factory_ref; - // Application-specific options (used for HTTP/3 if the negotiated - // ALPN selects Http3ApplicationImpl). - Application_Options application_options = Application_Options::kDefault; + // The name of the protocol application to install on the session + // (see application.h). Set only by internal consumer layers (e.g. + // node:http3); empty means the session runs the native raw-stream + // path with no application installed. + std::string application; + + // The application-specific settings supplied by the consumer layer + // alongside the application name, pre-parsed by the registered + // application factory's parse hook at option-processing time. + // Opaque to the QUIC core; only the application's own hooks read it. + std::shared_ptr application_settings; // When true, QLog output will be enabled for the session. bool qlog = false; @@ -240,6 +229,13 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // to skip address validation. Client-side only. std::optional token; + // Opaque application data to embed in issued session tickets. On the + // server this is both written into new tickets and used to validate + // 0-RTT on resume (a resumed ticket whose app-data does not byte-match + // this value has its early data rejected). Protocol-agnostic; the + // built-in HTTP/3 application uses its own typed ticket data instead. + std::optional app_ticket_data; + void MemoryInfo(MemoryTracker* tracker) const override; SET_MEMORY_INFO_NAME(Session::Options) SET_SELF_SIZE(Options) @@ -340,6 +336,10 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { TLSSession& tls_session() const; bool has_application() const; Application& application() const; + + // True once the session has started delivering events to JS, which we use + // as a gate for attaching an Application - it has to happen before this. + bool is_active() const { return active_; } const Config& config() const; const Options& options() const; const SocketAddress& remote_address() const; @@ -412,11 +412,43 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { const PacketInfo& pkt_info = PacketInfo(), uint64_t ts = 0); + uint64_t rx_packet_ts() const { return rx_packet_ts_; } + // Called by BindingData's flush callback to trigger SendPendingData // on this session. Encapsulates the application() access so that // bindingdata.cc doesn't need the full Application type definition. void FlushPendingData(); + // The send pump: the primary driver for serializing outbound data. + // Loops through available stream data (pulled from the application + // layer via GetStreamData/StreamCommit) and pending datagrams, + // generating packets until there is no more data to send or the + // packet budget for this call is exhausted. + void SendPendingData(); + + Packet::Ptr CreateStreamDataPacket(); + + // Tries to pack a pending datagram into the current packet buffer. + // If < 0 is returned, either NGTCP2_ERR_WRITE_MORE or a fatal error is + // returned; the caller must check. If > 0 is returned, the packet is done + // and the value is the size of the finalized packet. If 0 is returned, + // the datagram is either congestion limited or was abandoned. + ssize_t TryWritePendingDatagram(PathStorage* path, + uint8_t* dest, + size_t destlen, + uint64_t ts); + + // Write the given stream_data into the buffer. The PacketInfo out-param + // is populated by ngtcp2 with per-packet metadata (e.g., ECN codepoint) + // that should be applied when sending the packet. + ssize_t WriteVStream(PathStorage* path, + PacketInfo* pi, + uint8_t* buf, + ssize_t* ndatalen, + size_t max_packet_size, + const StreamData& stream_data, + uint64_t ts); + // Send a batch of packets accumulated by SendPendingData. Uses // Endpoint::SendBatch (uv_udp_try_send2 / sendmmsg) for synchronous // batched delivery when called from the deferred flush path. @@ -463,6 +495,37 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void ShutdownStream(stream_id id, QuicError error = QuicError()); void ShutdownStreamWrite(stream_id id, QuicError code = QuicError()); + // The application data-plane dispatchers. Each routes to the installed + // Application when one exists; otherwise the session's native + // raw-stream implementation runs (the session schedules streams on its + // own send queue and pulls their data directly). + int GetStreamData(StreamData* data); + bool StreamCommit(StreamData* data, size_t datalen); + bool AcknowledgeStreamData(stream_id id, size_t datalen); + bool ReceiveStreamOpen(stream_id id); + bool ReceiveStreamData(stream_id id, + const uint8_t* data, + size_t datalen, + const Stream::ReceiveDataFlags& flags, + void* stream_user_data); + // Native-path scheduling: puts the stream on the session's send queue. + void ScheduleStream(stream_id id); + + // True when the send pump may run: a session that requested a protocol + // application must not send until that application has been installed + // (for servers this happens during ALPN processing of the first + // flight); a session with no application requested is always ready. + bool can_send_pending_data() const; + + // True when the installed application manages stream FIN itself (e.g. + // HTTP/3 via nghttp3); always false when no application is installed. + bool stream_fin_managed_by_application() const; + + // The application-level "internal error" wire code in effect for this + // session (application-supplied once installed, the raw QUIC default + // otherwise). Mirrors the session state slot read by the JS side. + error_code internal_error_code() const; + // Use the configured CID::Factory to generate a new CID. CID new_cid(size_t len = CID::kMaxLength) const; @@ -471,7 +534,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { bool is_destroyed_or_closing() const; size_t max_packet_size() const; - void set_priority_supported(bool on = true); // Open a new locally-initialized stream with the specified directionality. // If the session is not yet in a state where the stream can be openen -- @@ -486,6 +548,20 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void SetLastError(QuicError&& error); uint64_t max_data_left() const; + // Transport operations that protocol applications (e.g. HTTP/3) invoke on + // the session, encapsulating ngtcp2 transport details here. + + // Open a unidirectional stream, setting *id on success, or returning false + bool OpenUni(stream_id* id); + + void ExtendMaxStreams(Direction direction, uint64_t max); + + // Signal that we've consumed $len bytes on stream $id to update flow control + void Consume(stream_id id, size_t len); + + // Record an application-level error on the connection without closing it. + void SetError(error_code app_error_code); + PendingStream::PendingStreamQueue& pending_bidi_stream_queue() const; PendingStream::PendingStreamQueue& pending_uni_stream_queue() const; @@ -536,11 +612,36 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // before the handshake completes. void PopulateEarlyTransportParamsState(); + void set_hello_processed() { hello_processed_ = true; } + // It's a terrible name but "wrapped" here means that the Session has been // passed out to JavaScript and should be "wrapped" by whatever handler is // defined there to manage it. void set_wrapped(); + // True while JS emits must be held for later replay, before the handshake + // is complete and the server session event has been emitted. + bool must_defer_emits() const; + + // Replays, in order, any emits held while must_defer_emits() was true. + // Called synchronously right after the new-session emit. + void ReplayDeferredEmits(); + + // Runs fn now, or queues it for replay if emits are currently being + // deferred (must_defer_emits()) before the session is ready. + template + void DeferOrRun(F&& fn) { + if (must_defer_emits()) { + QueueDeferredEmit(std::forward(fn)); + } else { + fn(); + } + } + + void QueueDeferredEmit(std::function fn); + + bool has_origin_listener() const; + enum class CloseMethod : uint8_t { // Immediate close with a roundtrip through JavaScript, causing all // currently opened streams to be closed. An attempt will be made to @@ -586,17 +687,15 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // JavaScript callouts void EmitClose(const QuicError& error = QuicError()); - void EmitGoaway(stream_id last_stream_id); // Sets the max datagram payload size in the shared state. Used by - // Http3ApplicationImpl to block datagram sends when the peer's + // Http3Application to block datagram sends when the peer's // SETTINGS_H3_DATAGRAM=0 (RFC 9297 §3). void set_max_datagram_size(uint16_t size); void EmitDatagram(Store&& datagram, DatagramReceivedFlags flag); void EmitDatagramStatus(datagram_id id, DatagramStatus status); void EmitHandshakeComplete(); void EmitKeylog(const char* line); - void EmitOrigins(std::vector&& origins); struct ValidatedPath { std::shared_ptr local; @@ -615,7 +714,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, const uint32_t* sv, size_t nsv); - void EmitApplication(); void DatagramStatus(datagram_id datagramId, DatagramStatus status); void DatagramReceived(const uint8_t* data, size_t datalen, @@ -656,14 +754,19 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { }; Flags flags_; + uint64_t rx_packet_ts_ = 0; + + bool hello_processed_ = false; + + bool active_ = false; + QuicConnectionPointer connection_; std::unique_ptr tls_session_; friend struct NgTcp2CallbackScope; friend struct NgHttp3CallbackScope; friend class Application; friend class BindingData; - friend class DefaultApplication; - friend class Http3ApplicationImpl; + friend class Http3Application; friend class Endpoint; friend class SessionManager; friend class Stream; diff --git a/src/quic/streams.cc b/src/quic/streams.cc index 7186aed89a78e9..85d8dfa0681d13 100644 --- a/src/quic/streams.cc +++ b/src/quic/streams.cc @@ -26,19 +26,16 @@ using v8::BackingStore; using v8::BackingStoreInitializationMode; using v8::BigInt; using v8::FunctionCallbackInfo; -using v8::Global; using v8::HandleScope; using v8::Integer; using v8::Just; using v8::Local; -using v8::LocalVector; using v8::Maybe; using v8::Nothing; using v8::Object; using v8::ObjectTemplate; using v8::SharedArrayBuffer; using v8::String; -using v8::Uint32; using v8::Uint8Array; using v8::Value; @@ -96,11 +93,8 @@ namespace quic { #define STREAM_JS_METHODS(V) \ V(AttachSource, attachSource, false) \ V(Destroy, destroy, false) \ - V(SendHeaders, sendHeaders, false) \ V(StopSending, stopSending, false) \ V(ResetStream, resetStream, false) \ - V(SetPriority, setPriority, false) \ - V(GetPriority, getPriority, true) \ V(GetReader, getReader, false) \ V(InitStreamingSource, initStreamingSource, false) \ V(Write, write, false) \ @@ -225,17 +219,6 @@ void PendingStream::reject(QuicError error) { stream_->Destroy(error); } -struct Stream::PendingHeaders { - HeadersKind kind; - Global headers; - HeadersFlags flags; - PendingHeaders(HeadersKind kind_, Global headers_, HeadersFlags flags_) - : kind(kind_), headers(std::move(headers_)), flags(flags_) {} - DISALLOW_COPY_AND_MOVE(PendingHeaders) -}; - -// ============================================================================ - struct Stream::State { #define V(_, name, type) type name; STREAM_STATE(V) @@ -443,37 +426,6 @@ struct Stream::Impl { } } - // Sends a block of headers to the peer. If the stream is not yet open, - // the headers will be queued and sent immediately when the stream is - // opened. Returns false if the application does not support headers. - JS_METHOD(SendHeaders) { - Stream* stream; - ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - CHECK(args[0]->IsUint32()); // Kind - CHECK(args[1]->IsArray()); // Headers - CHECK(args[2]->IsUint32()); // Flags - - HeadersKind kind = FromV8Value(args[0]); - Local headers = args[1].As(); - HeadersFlags flags = FromV8Value(args[2]); - - // If the stream is pending, the headers will be queued until the - // stream is opened, at which time the queued header block will be - // immediately sent when the stream is opened. If we already know - // that the application does not support headers, return false - // immediately so the JS side can throw an appropriate error. - if (stream->is_pending()) { - if (!stream->session().application().SupportsHeaders()) { - return args.GetReturnValue().Set(false); - } - stream->EnqueuePendingHeaders(kind, headers, flags); - return args.GetReturnValue().Set(true); - } - - args.GetReturnValue().Set(stream->session().application().SendHeaders( - *stream, kind, headers, flags)); - } - // Tells the peer to stop sending data for this stream. This has the effect // of shutting down the readable side of the stream for this peer. Any data // that has already been received is still readable. @@ -529,53 +481,6 @@ struct Stream::Impl { } } - JS_METHOD(SetPriority) { - Stream* stream; - ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - CHECK(args[0]->IsUint32()); // Packed: (urgency << 1) | incremental - - uint32_t packed = args[0].As()->Value(); - StreamPriority priority = static_cast(packed >> 1); - StreamPriorityFlags flags = (packed & 1) - ? StreamPriorityFlags::INCREMENTAL - : StreamPriorityFlags::NON_INCREMENTAL; - - // Always update the stored priority on the stream. - stream->priority_ = StoredPriority{ - .priority = priority, - .flags = flags, - .pending = stream->is_pending(), - }; - - if (!stream->is_pending()) { - stream->session().application().SetStreamPriority( - *stream, priority, flags); - } - } - - JS_METHOD(GetPriority) { - Stream* stream; - ASSIGN_OR_RETURN_UNWRAP(&stream, args.This()); - - // On the client side, priority is always read from the stream's - // stored value since the client is the one setting it. On the - // server side, we delegate to the application which can read - // the peer's requested priority (e.g., from PRIORITY_UPDATE - // frames in HTTP/3). - if (!stream->session().is_server()) { - auto& pri = stream->priority_; - uint32_t packed = (static_cast(pri.priority) << 1) | - (pri.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); - return args.GetReturnValue().Set(packed); - } - - auto result = stream->session().application().GetStreamPriority(*stream); - uint32_t packed = - (static_cast(result.priority) << 1) | - (result.flags == StreamPriorityFlags::INCREMENTAL ? 1 : 0); - args.GetReturnValue().Set(packed); - } - // Returns a Blob::Reader that can be used to read data that has been // received on the stream. JS_METHOD(GetReader) { @@ -1077,25 +982,6 @@ void Stream::InitPerContext(Realm* realm, Local target) { #undef V NODE_DEFINE_CONSTANT(target, IDX_STATS_STREAM_COUNT); - - constexpr int QUIC_STREAM_HEADERS_KIND_HINTS = - static_cast(HeadersKind::HINTS); - constexpr int QUIC_STREAM_HEADERS_KIND_INITIAL = - static_cast(HeadersKind::INITIAL); - constexpr int QUIC_STREAM_HEADERS_KIND_TRAILING = - static_cast(HeadersKind::TRAILING); - - constexpr int QUIC_STREAM_HEADERS_FLAGS_NONE = - static_cast(HeadersFlags::NONE); - constexpr int QUIC_STREAM_HEADERS_FLAGS_TERMINAL = - static_cast(HeadersFlags::TERMINAL); - - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_HINTS); - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_INITIAL); - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_KIND_TRAILING); - - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_NONE); - NODE_DEFINE_CONSTANT(target, QUIC_STREAM_HEADERS_FLAGS_TERMINAL); } Stream* Stream::From(void* stream_user_data) { @@ -1260,28 +1146,13 @@ void Stream::NotifyStreamOpened(stream_id id) { CHECK_EQ(ngtcp2_conn_set_stream_user_data(this->session(), id, this), 0); maybe_pending_stream_.reset(); - if (priority_.pending) { - session().application().SetStreamPriority( - *this, priority_.priority, priority_.flags); - priority_.pending = false; - } - if (!pending_headers_queue_.empty()) { - if (!session().application().SupportsHeaders()) { - // Headers were enqueued while the application was not yet known - // (headers_supported == 0), and the negotiated application does - // not support headers. This is a fatal mismatch. - Destroy(QuicError::ForApplication( - session().application().GetInternalErrorCode())); - return; - } - decltype(pending_headers_queue_) queue; - pending_headers_queue_.swap(queue); - for (auto& headers : queue) { - session().application().SendHeaders( - *this, - headers->kind, - headers->headers.Get(env()->isolate()), - headers->flags); + if (!pending_open_callbacks_.empty()) { + BaseObjectPtr self(this); + decltype(pending_open_callbacks_) callbacks; + pending_open_callbacks_.swap(callbacks); + for (auto& fn : callbacks) { + fn(); + if (stats()->destroyed_at != 0) return; } } // If the stream is not a local undirectional stream and is_readable is @@ -1290,7 +1161,7 @@ void Stream::NotifyStreamOpened(stream_id id) { NotifyReadableEnded(pending_close_read_code_); } if (!is_remote_unidirectional() && !is_writable() && - !session_->application().stream_fin_managed_by_application()) { + !session_->stream_fin_managed_by_application()) { NotifyWritableEnded(pending_close_write_code_); } @@ -1313,12 +1184,12 @@ void Stream::NotifyWritableEnded(error_code code) { ngtcp2_conn_shutdown_stream_write(session(), 0, id(), code); } -void Stream::EnqueuePendingHeaders(HeadersKind kind, - Local headers, - HeadersFlags flags) { - Debug(this, "Enqueuing headers for pending stream"); - pending_headers_queue_.push_back(std::make_unique( - kind, Global(env()->isolate(), headers), flags)); +void Stream::RunWhenOpen(std::function fn) { + if (is_pending()) { + pending_open_callbacks_.push_back(std::move(fn)); + } else { + fn(); + } } bool Stream::is_pending() const { @@ -1367,6 +1238,10 @@ bool Stream::is_eos() const { return state()->fin_sent; } +bool Stream::wants_headers() const { + return state()->wants_headers == 1; +} + bool Stream::wants_trailers() const { return state()->wants_trailers; } @@ -1562,28 +1437,6 @@ int Stream::DoPull(bob::Next next, return outbound_->Pull(std::move(next), options, data, count, max_count_hint); } -void Stream::BeginHeaders(HeadersKind kind) { - headers_length_ = 0; - headers_.clear(); - set_headers_kind(kind); -} - -void Stream::set_headers_kind(HeadersKind kind) { - headers_kind_ = kind; -} - -bool Stream::AddHeader(std::unique_ptr
header) { - size_t len = header->length(); - if (!session_->application().CanAddHeader( - headers_.size(), headers_length_, len)) { - return false; - } - - headers_length_ += len; - headers_.push_back(std::move(header)); - return true; -} - void Stream::Acknowledge(size_t datalen) { if (outbound_ == nullptr) return; @@ -1890,42 +1743,6 @@ void Stream::EmitClose(const QuicError& error) { MakeCallback(BindingData::Get(env()).stream_close_callback(), 1, &err); } -void Stream::EmitHeaders() { - STAT_RECORD_TIMESTAMP(Stats, received_at); - // state()->wants_headers will be set from the javascript side if the - // stream object has a handler for the headers event. - if (!env()->can_call_into_js() || !state()->wants_headers) { - headers_.clear(); - return; - } - CallbackScope cb_scope(this); - - auto& binding = BindingData::Get(env()); - size_t count = headers_.size() * 2; - LocalVector values(env()->isolate(), count); - - for (size_t i = 0; i < headers_.size(); i++) { - Local name; - Local value; - if (!headers_[i]->GetName(&binding).ToLocal(&name) || - !headers_[i]->GetValue(&binding).ToLocal(&value)) [[unlikely]] { - headers_.clear(); - return; - } - values[i * 2] = name; - values[i * 2 + 1] = value; - } - - headers_.clear(); - - Local argv[] = { - Array::New(env()->isolate(), values.data(), count), - Integer::NewFromUnsigned(env()->isolate(), - static_cast(headers_kind_))}; - - MakeCallback(binding.stream_headers_callback(), arraysize(argv), argv); -} - void Stream::EmitReset(const QuicError& error) { // state()->wants_reset will be set from the javascript side if the // stream object has a handler for the reset event. @@ -1939,16 +1756,6 @@ void Stream::EmitReset(const QuicError& error) { MakeCallback(BindingData::Get(env()).stream_reset_callback(), 1, &err); } -void Stream::EmitWantTrailers() { - // state()->wants_trailers will be set from the javascript side if the - // stream object has a handler for the trailers event. - if (!env()->can_call_into_js() || !state()->wants_trailers) { - return; - } - CallbackScope cb_scope(this); - MakeCallback(BindingData::Get(env()).stream_trailers_callback(), 0, nullptr); -} - // ============================================================================ void Stream::Schedule(Queue* queue) { diff --git a/src/quic/streams.h b/src/quic/streams.h index 86cb36b2668985..1e8ac4c43f48c4 100644 --- a/src/quic/streams.h +++ b/src/quic/streams.h @@ -10,11 +10,11 @@ #include #include #include -#include #include #include "bindingdata.h" #include "data.h" +#include #include namespace node::quic { @@ -202,8 +202,6 @@ class Stream final : public AsyncWrap, public Ngtcp2Source, public DataQueue::BackpressureListener { public: - using Header = NgHeaderBase; - // Acquire a DataQueue from the given value if it is valid. The return // follows the typical V8 rules for Maybe types. If an error occurs, // the Maybe will be empty and an exception will be set on the isolate. @@ -275,6 +273,7 @@ class Stream final : public AsyncWrap, // True if the stream wants to send trailing headers after the body. bool wants_trailers() const; + bool wants_headers() const; // Marks this stream as having received 0-RTT early data. void set_early(); @@ -341,18 +340,8 @@ class Stream final : public AsyncWrap, void ReceiveStopSending(QuicError error); void ReceiveStreamReset(uint64_t final_size, QuicError error); - // Currently, only HTTP/3 streams support headers. These methods are here - // to support that. They are not used when using any other QUIC application. - - void BeginHeaders(HeadersKind kind); - void set_headers_kind(HeadersKind kind); - // Returns false if the header cannot be added. This will typically happen - // if the application does not support headers, a maximum number of headers - // have already been added, or the maximum total header length is reached. - bool AddHeader(std::unique_ptr
header); - // TODO(@jasnell): Implement MemoryInfo to track outbound_, inbound_, - // reader_, headers_, and pending_headers_queue_. + // and reader_. SET_NO_MEMORY_INFO() SET_MEMORY_INFO_NAME(Stream) SET_SELF_SIZE(Stream) @@ -373,7 +362,6 @@ class Stream final : public AsyncWrap, private: struct Impl; - struct PendingHeaders; class Outbound; @@ -403,11 +391,6 @@ class Stream final : public AsyncWrap, // Notifies the JavaScript side that the stream has been reset. void EmitReset(const QuicError& error); - // Notifies the JavaScript side that the application is ready to receive - // trailing headers. Any trailing headers must be sent immediately, and - // synchronously when this callback is triggered. - void EmitWantTrailers(); - // Notifies the JavaScript side that sending data on the stream has been // blocked because of flow control restriction. void EmitBlocked(); @@ -420,18 +403,16 @@ class Stream final : public AsyncWrap, // and outbound buffer state. Emits drain if transitioning from 0 to > 0. void UpdateWriteDesiredSize(); - // Delivers the set of inbound headers that have been collected. - void EmitHeaders(); - void NotifyReadableEnded(error_code code); void NotifyWritableEnded(error_code code); // When a pending stream is finally opened, the NotifyStreamOpened method // will be called and the id will be assigned. void NotifyStreamOpened(stream_id id); - void EnqueuePendingHeaders(HeadersKind kind, - v8::Local headers, - HeadersFlags flags); + + // Runs fn once the stream has a transport id: immediately if the stream is + // already open, otherwise queued and run in NotifyStreamOpened. + void RunWhenOpen(std::function fn); ArenaSlotBase stats_slot_; ArenaSlotBase state_slot_; @@ -446,49 +427,26 @@ class Stream final : public AsyncWrap, // and the stream id will be assigned. std::optional> maybe_pending_stream_ = std::nullopt; - std::vector> pending_headers_queue_; + std::vector> pending_open_callbacks_; error_code pending_close_read_code_ = 0; error_code pending_close_write_code_ = 0; - struct StoredPriority { - StreamPriority priority = StreamPriority::DEFAULT; - StreamPriorityFlags flags = StreamPriorityFlags::NON_INCREMENTAL; - bool pending = false; - }; - StoredPriority priority_; - - const StoredPriority& stored_priority() const { return priority_; } - - // The headers_ field holds a block of headers that have been received and - // are being buffered for delivery to the JavaScript side. Headers are - // stored as C++ objects during collection (AddHeader) and converted to - // V8 strings only when emitted (EmitHeaders), avoiding StrongRootAllocator - // mutex contention on the per-header hot path. - std::vector> headers_; - - // The headers_kind_ field indicates the kind of headers that are being - // buffered. - HeadersKind headers_kind_ = HeadersKind::INITIAL; - - // The headers_length_ field holds the total length of the headers that have - // been buffered. - size_t headers_length_ = 0; - friend struct Impl; friend class PendingStream; - friend class Http3ApplicationImpl; - friend class DefaultApplication; + friend class Http3Application; + friend class Http3Binding; + friend class Session; public: // The Queue/Schedule here are part of the mechanism used to // determine which streams have data to send on the session. When a stream // potentially has data available, it will be scheduled in the Queue. Then, - // when the Session::Application starts sending pending data, it will check - // the queue to see if there are streams waiting. If there are, it will grab - // one and check to see if there is data to send. When a stream does not have - // data to send (such as when it is initially created or is using an async - // source that is still waiting for data to be pushed) it will not appear in - // the queue. + // when the session (or its installed application) starts sending pending + // data, it will check the queue to see if there are streams waiting. If + // there are, it will grab one and check to see if there is data to send. + // When a stream does not have data to send (such as when it is initially + // created or is using an async source that is still waiting for data to be + // pushed) it will not appear in the queue. ListNode stream_queue_; using Queue = ListHead; diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index c0a1610540ed3a..531356afefabcb 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -357,24 +357,25 @@ int TLSContext::OnSelectAlpn(SSL* ssl, } // ALPN negotiated successfully. *out/*outlen point to the selected - // protocol name (without the length prefix). Select the Application - // implementation based on the negotiated ALPN. This must happen now - // because early data (0-RTT) may arrive in the same ngtcp2_conn_read_pkt - // call and needs the Application to be ready. + // protocol name (without the length prefix). Install the session's + // Application now if its options request one (selection comes from the + // session's application option, not the negotiated ALPN value): early + // data (0-RTT) may arrive in the same ngtcp2_conn_read_pkt call and + // needs the Application to be ready. Sessions that request no + // application run the native raw-stream path and install nothing. std::string_view negotiated(reinterpret_cast(*out), *outlen); Debug(&tls_session.session(), "ALPN negotiation succeeded: %s", std::string(negotiated).c_str()); auto& session = tls_session.session(); - auto app = session.SelectApplicationFromAlpn(negotiated); - if (!app) { - Debug(&session, - "Failed to create Application for ALPN %s", - std::string(negotiated).c_str()); - return SSL_TLSEXT_ERR_NOACK; + if (auto app = session.SelectApplication()) { + // Note: SetApplication can still reject previously parsed session + // ticket app data, leaving the application uninstalled; the + // handshake then fails when the 1-RTT keys arrive. + session.SetApplication(std::move(app)); } - session.SetApplication(std::move(app)); + session.set_hello_processed(); return SSL_TLSEXT_ERR_OK; } diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index ba502cf1f868df..ffeced88810c4a 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -184,7 +184,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; diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index 183ba973ac1823..b49e527b038069 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -506,6 +506,16 @@ TransportParams::operator bool() const { return ptr_ != nullptr; } +uint64_t TransportParams::initial_max_streams_bidi() const { + DCHECK_NOT_NULL(ptr_); + return ptr_->initial_max_streams_bidi; +} + +uint64_t TransportParams::initial_max_streams_uni() const { + DCHECK_NOT_NULL(ptr_); + return ptr_->initial_max_streams_uni; +} + void TransportParams::Initialize(Environment* env, Local target) { NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_STREAM_DATA); NODE_DEFINE_CONSTANT(target, DEFAULT_MAX_DATA); diff --git a/src/quic/transportparams.h b/src/quic/transportparams.h index 46724574611aff..bae20b9ac0d4f2 100644 --- a/src/quic/transportparams.h +++ b/src/quic/transportparams.h @@ -150,6 +150,9 @@ class TransportParams final { operator bool() const; + uint64_t initial_max_streams_bidi() const; + uint64_t initial_max_streams_uni() const; + // Returns a Store containing the encoded transport parameters. // If an error occurs during encoding, or if the parameters could // not be encoded, an empty Store will be returned. diff --git a/test/doctool/test-make-doc.mjs b/test/doctool/test-make-doc.mjs index 59e681707dd473..a7d92baa585077 100644 --- a/test/doctool/test-make-doc.mjs +++ b/test/doctool/test-make-doc.mjs @@ -46,7 +46,8 @@ const expectedJsons = linkedHtmls .map((name) => name.replace('.html', '.json')); const expectedDocs = linkedHtmls.concat(expectedJsons); const renamedDocs = ['policy.json', 'policy.html']; -const skipedDocs = ['dtls.json', 'dtls.html', 'quic.json', 'quic.html']; +const skipedDocs = ['dtls.json', 'dtls.html', 'quic.json', 'quic.html', + 'http3.json', 'http3.html']; // Test that all the relative links in the TOC match to the actual documents. for (const expectedDoc of expectedDocs) { diff --git a/test/parallel/test-process-get-builtin.mjs b/test/parallel/test-process-get-builtin.mjs index fa92dc52f96916..b714ad58d04a98 100644 --- a/test/parallel/test-process-get-builtin.mjs +++ b/test/parallel/test-process-get-builtin.mjs @@ -40,6 +40,7 @@ if (!hasIntl) { publicBuiltins.delete('node:dtls'); // TODO(@jasnell): Remove this once node:quic graduates from unflagged. publicBuiltins.delete('node:quic'); +publicBuiltins.delete('node:http3'); // Remove this once node:vfs graduates from unflagged. publicBuiltins.delete('node:vfs'); diff --git a/test/parallel/test-quic-alpn-h3.mjs b/test/parallel/test-quic-alpn-h3.mjs index e9adca59d1aeca..85d2ee81d006e5 100644 --- a/test/parallel/test-quic-alpn-h3.mjs +++ b/test/parallel/test-quic-alpn-h3.mjs @@ -1,4 +1,4 @@ -// Flags: --experimental-quic --no-warnings +// Flags: --experimental-quic --no-warnings --expose-internals import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; @@ -17,30 +17,48 @@ const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); -// Test h3 ALPN negotiation with Http3ApplicationImpl. -// Both server and client use the default ALPN (h3). +// Test that the h3 ALPN negotiates normally but doesn't auto-activate +// HTTP/3. ALPN is reported, not type-changing. HTTP/3 is used via +// the 'node:http3' module explicitly. +// Both server and client explicitly configure the h3 ALPN. + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); const serverOpened = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { + // ALPN negotiated h3, but with no application requested this is a RAW + // session: no protocol application is installed. + strictEqual(serverSession.alpnProtocol, 'h3'); + strictEqual(getQuicSessionState(serverSession).hasApplication, false); const info = await serverSession.opened; strictEqual(info.protocol, 'h3'); serverOpened.resolve(); serverSession.close(); }), { + alpn: ['h3'], sni: { '*': { keys: [key], certs: [cert] } }, }); notStrictEqual(serverEndpoint.address, undefined); const clientSession = await connect(serverEndpoint.address, { + alpn: 'h3', servername: 'localhost', verifyPeer: 'manual', + // Application config is internal-only (kApplication) - user options + // are ignored. Applications integrate natively with their consumers + // so custom options would reliably break things. + application: 'http3', }); async function checkClient() { const info = await clientSession.opened; strictEqual(info.protocol, 'h3'); + // Still a raw session: + strictEqual(getQuicSessionState(clientSession).hasApplication, false); } await Promise.all([serverOpened.promise, checkClient()]); diff --git a/test/parallel/test-quic-alpn-mismatch.mjs b/test/parallel/test-quic-alpn-mismatch.mjs index 64d8e1f0006380..f97b11b919de9f 100644 --- a/test/parallel/test-quic-alpn-mismatch.mjs +++ b/test/parallel/test-quic-alpn-mismatch.mjs @@ -8,7 +8,7 @@ // 0x100 | . For `no_application_protocol` (alert 120 / 0x78) this // is 0x178 == 376. -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; const { rejects, strictEqual, match } = assert; @@ -29,16 +29,15 @@ const onerror = mustCall((err) => { strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR'); strictEqual(err.errorCode, 376n); match(err.message, /no application protocol/); -}, 2); +}); const transportParams = { maxIdleTimeout: 1 }; -const serverEndpoint = await listen(mustCall(async (serverSession) => { - await rejects(serverSession.opened, expected); - await rejects(serverSession.closed, expected); -}), { - transportParams, - onerror, -}); +// The handshake fails so the session is never surfaced to JS +const serverEndpoint = await listen( + mustNotCall('server session must not be surfaced for a failed handshake'), + { + transportParams, + }); // Client requests an ALPN the server doesn't offer. const clientSession = await connect(serverEndpoint.address, { diff --git a/test/parallel/test-quic-alpn.mjs b/test/parallel/test-quic-alpn.mjs index 020fea3d308a86..73649c09ee87ed 100644 --- a/test/parallel/test-quic-alpn.mjs +++ b/test/parallel/test-quic-alpn.mjs @@ -1,10 +1,10 @@ // Flags: --experimental-quic --no-warnings -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; -const { notStrictEqual, strictEqual } = assert; +const { notStrictEqual, strictEqual, rejects } = assert; const { readKey } = fixtures; if (!hasQuic) { @@ -46,4 +46,15 @@ const clientSession = await connect(serverEndpoint.address, { await Promise.all([serverOpened.promise, checkSession(clientSession)]); await clientSession.close(); + +// ALPN is required: node:quic is transport-only and assumes no application +// protocol, so a session created without one is rejected with a clear error +// rather than silently defaulting. +await rejects(listen(mustNotCall(), { + sni: { '*': { keys: [key], certs: [cert] } }, +}), { code: 'ERR_INVALID_ARG_VALUE', message: /options\.alpn/ }); +await rejects(connect(serverEndpoint.address, { verifyPeer: 'manual' }), { + code: 'ERR_INVALID_ARG_VALUE', message: /options\.alpn/, +}); + await serverEndpoint.close(); diff --git a/test/parallel/test-quic-appticketdata.mjs b/test/parallel/test-quic-appticketdata.mjs new file mode 100644 index 00000000000000..1578d9e5167e31 --- /dev/null +++ b/test/parallel/test-quic-appticketdata.mjs @@ -0,0 +1,134 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: the generic `appTicketData` server option. Opaque application data +// configured on the server is embedded in issued session tickets and used to +// byte-match-validate 0-RTT on resume. Covers: +// - Accept: resuming against an unchanged `appTicketData` accepts 0-RTT (the +// embed + parse + byte-match round-trip works and does not break resumption). +// - Reject: resuming against a different `appTicketData` refuses 0-RTT (the +// byte-match fails) and falls back to a full 1-RTT handshake. Two endpoints +// sharing the cert + tokenSecret are used so the ticket still decrypts and +// the token validates; only the appTicketData differs. +// - Argument validation: `appTicketData` must be an ArrayBufferView. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { ok, strictEqual, rejects } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); +const { randomBytes } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const encoder = new TextEncoder(); +const appTicketData = new Uint8Array([1, 2, 3, 4, 5]); + +// --- Accept path: appTicketData unchanged → 0-RTT accepted --- +const serverEndpoint = await listen((serverSession) => { + serverSession.onstream = async (stream) => { + try { + await bytes(stream); + stream.writer.endSync(); + await stream.closed; + } catch { /* connection winding down */ } + }; +}, { appTicketData }); + +let savedTicket; +let savedToken; +const gotTicket = Promise.withResolvers(); +const gotToken = Promise.withResolvers(); + +const cs1 = await connect(serverEndpoint.address, { + onsessionticket: mustCall((ticket) => { + savedTicket ??= ticket; + gotTicket.resolve(); + }, 2), + onnewtoken: mustCall((token) => { + savedToken ??= token; + gotToken.resolve(); + }), +}); +await cs1.opened; +await Promise.all([gotTicket.promise, gotToken.promise]); + +const s1 = await cs1.createBidirectionalStream({ body: encoder.encode('first') }); +for await (const _ of s1) { /* drain */ } // eslint-disable-line no-unused-vars +await s1.closed; +await cs1.close(); + +// Resume against the same endpoint (same appTicketData) — 0-RTT accepted. +const cs2 = await connect(serverEndpoint.address, { + sessionTicket: savedTicket, + token: savedToken, +}); +const s2 = await cs2.createBidirectionalStream({ body: encoder.encode('early') }); +s2.closed.catch(() => {}); +const info2 = await cs2.opened; +strictEqual(info2.earlyDataAttempted, true); +strictEqual(info2.earlyDataAccepted, true); + +for await (const _ of s2) { /* drain */ } // eslint-disable-line no-unused-vars +await cs2.close(); +await serverEndpoint.close(); + +// --- Reject path: a different appTicketData → 0-RTT rejected --- +{ + const tokenSecret = randomBytes(16); + const handler = (session) => { + session.onstream = async (stream) => { + try { + await bytes(stream); + stream.writer.endSync(); + await stream.closed; + } catch { /* connection winding down */ } + }; + }; + + const ep1 = await listen(handler, { + appTicketData: new Uint8Array([1, 2, 3]), + endpoint: { tokenSecret }, + }); + let ticket; + let token; + const haveTicket = Promise.withResolvers(); + const haveToken = Promise.withResolvers(); + const c1 = await connect(ep1.address, { + onsessionticket: mustCall((t) => { ticket ??= t; haveTicket.resolve(); }, 2), + onnewtoken: mustCall((t) => { token ??= t; haveToken.resolve(); }), + }); + await c1.opened; + await Promise.all([haveTicket.promise, haveToken.promise]); + const rs = await c1.createBidirectionalStream({ body: encoder.encode('a') }); + for await (const _ of rs) { /* drain */ } // eslint-disable-line no-unused-vars + await c1.close(); + await ep1.close(); + + const ep2 = await listen(handler, { + appTicketData: new Uint8Array([9, 9, 9]), + endpoint: { tokenSecret }, + }); + const c2 = await connect(ep2.address, { sessionTicket: ticket, token }); + const es = await c2.createBidirectionalStream({ body: encoder.encode('b') }); + es.closed.catch(() => {}); + const info = await c2.opened; + strictEqual(info.earlyDataAttempted, true); + strictEqual(info.earlyDataAccepted, false); + for await (const _ of es) { /* drain */ } // eslint-disable-line no-unused-vars + await c2.close(); + await ep2.close(); +} + +// --- Argument validation: appTicketData must be an ArrayBufferView --- +await rejects(connect('127.0.0.1:1', { appTicketData: 'nope' }), { + code: 'ERR_INVALID_ARG_TYPE', +}); +await rejects(listen(() => {}, { appTicketData: 123 }), { + code: 'ERR_INVALID_ARG_TYPE', +}); + +ok(true); diff --git a/test/parallel/test-quic-h3-callback-errors.mjs b/test/parallel/test-quic-h3-callback-errors.mjs index 226a3f6b96fbd5..a7f95cdd0525ba 100644 --- a/test/parallel/test-quic-h3-callback-errors.mjs +++ b/test/parallel/test-quic-h3-callback-errors.mjs @@ -18,17 +18,21 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); const encoder = new TextEncoder(); -async function makeServer(onheadersHandler, extraOpts = {}) { +async function makeServer(onheadersHandler, extraCallbacks = {}) { const done = Promise.withResolvers(); const ep = await listen(mustCall(async (ss) => { ss.onstream = mustCall((stream) => { + stream.onheaders = (headers) => onheadersHandler(stream, headers); + if (extraCallbacks.onwanttrailers) { + stream.onwanttrailers = () => extraCallbacks.onwanttrailers(stream); + } // The server completes its response before the client's // callback throws, so the server stream always resolves. stream.closed.then(mustCall()); @@ -38,8 +42,6 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), { sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, - onheaders: onheadersHandler, - ...extraOpts, }); return { ep, done }; } @@ -47,10 +49,10 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in onheaders callback destroys the stream. { const { ep, done } = await makeServer( - mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); + mustCall((stream, headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); }), ); @@ -61,14 +63,13 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await c.opened; - const s = await c.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function() { + const s = await c.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall(() => { throw new Error('onheaders sync error'); }), }); @@ -87,10 +88,10 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Async rejection in onheaders callback destroys the stream. { const { ep, done } = await makeServer( - mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); + mustCall((stream, headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); }), ); @@ -101,14 +102,13 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await c.opened; - const s = await c.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(async function() { + const s = await c.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall(async () => { throw new Error('onheaders async error'); }), }); @@ -127,14 +127,14 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in ontrailers callback destroys the stream. { const { ep, done } = await makeServer( - mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('body')); - this.writer.endSync(); + mustCall((stream, headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('body')); + stream.writer.endSync(); }), { - onwanttrailers: mustCall(function() { - this.sendTrailers({ 'x-trailer': 'value' }); + onwanttrailers: mustCall((stream) => { + stream.sendTrailers({ 'x-trailer': 'value' }); }), }, ); @@ -146,17 +146,16 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await c.opened; - const s = await c.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const s = await c.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), - ontrailers: mustCall(function() { + ontrailers: mustCall(() => { throw new Error('ontrailers sync error'); }), }); @@ -175,6 +174,12 @@ async function makeServer(onheadersHandler, extraOpts = {}) { // Sync throw in onorigin callback destroys the session. { const serverEndpoint = await listen(mustCall(async (ss) => { + ss.onstream = (stream) => { + stream.onheaders = (headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.endSync(); + }; + }; await ss.closed; }), { sni: { @@ -182,10 +187,6 @@ async function makeServer(onheadersHandler, extraOpts = {}) { 'example.com': { keys: [key], certs: [cert] }, }, transportParams: { maxIdleTimeout: 1 }, - onheaders(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.endSync(); - }, }); const clientSession = await connect(serverEndpoint.address, { @@ -201,13 +202,11 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'example.com', - }, + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'example.com', }); // The session is destroyed by the callback error, which @@ -231,6 +230,14 @@ async function makeServer(onheadersHandler, extraOpts = {}) { const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('body')); + stream.writer.endSync(); + }); + stream.onwanttrailers = mustCall(() => { + throw new Error('onwanttrailers error'); + }); // The server stream rejects because onwanttrailers threw. await rejects(stream.closed, mustCall((err) => { strictEqual(err.message, 'onwanttrailers error'); @@ -243,14 +250,6 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }), { sni: { '*': { keys: [key], certs: [cert] } }, transportParams: { maxIdleTimeout: 1 }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('body')); - this.writer.endSync(); - }), - onwanttrailers: mustCall(function() { - throw new Error('onwanttrailers error'); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -260,14 +259,13 @@ async function makeServer(onheadersHandler, extraOpts = {}) { }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-h3-close-behavior.mjs b/test/parallel/test-quic-h3-close-behavior.mjs index 02b34945087267..93be483de55174 100644 --- a/test/parallel/test-quic-h3-close-behavior.mjs +++ b/test/parallel/test-quic-h3-close-behavior.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -32,23 +32,21 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { serverSession = ss; - ss.onstream = mustCall(2); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall((headers, stream) => { - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync(headers[':path']); - stream.writer.endSync(); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(headers[':path']); + stream.writer.endSync(); - // Close after both responses are written. The - // close is deferred to exit the nghttp3 callback scope. - if (++requestCount === 2) { - setImmediate(mustCall(() => { + // Close after both responses are written. + if (++requestCount === 2) { serverSession.close(); serverDone.resolve(); - })); - } - }, 2), + } + }); + }, 2); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, }); const clientSession = await connect(serverEndpoint.address, { @@ -57,25 +55,23 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream1 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/one', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream1 = await clientSession.request({ + ':method': 'GET', + ':path': '/one', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); - const stream2 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/two', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream2 = await clientSession.request({ + ':method': 'GET', + ':path': '/two', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), diff --git a/test/parallel/test-quic-h3-concurrent-requests.mjs b/test/parallel/test-quic-h3-concurrent-requests.mjs index 6f0aa50b7f02ae..e96aee5e091f79 100644 --- a/test/parallel/test-quic-h3-concurrent-requests.mjs +++ b/test/parallel/test-quic-h3-concurrent-requests.mjs @@ -19,7 +19,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,6 +34,16 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + const path = headers[':path']; + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + const w = stream.writer; + w.writeSync(`response for ${path}`); + w.endSync(); + }); stream.closed.then(mustCall(() => { if (++serverStreamsCompleted === REQUEST_COUNT) { serverSession.close(); @@ -43,16 +53,6 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }, REQUEST_COUNT); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - const path = headers[':path']; - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - const w = this.writer; - w.writeSync(`response for ${path}`); - w.endSync(); - }, REQUEST_COUNT), }); const clientSession = await connect(serverEndpoint.address, { @@ -67,13 +67,12 @@ const paths = Array.from({ length: REQUEST_COUNT }, (_, i) => `/path/${i}`); const requests = paths.map(mustCall(async (path) => { const headersReceived = Promise.withResolvers(); - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': path, - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'GET', + ':path': path, + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); headersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-datagram.mjs b/test/parallel/test-quic-h3-datagram.mjs deleted file mode 100644 index ea00cec42bc8f4..00000000000000 --- a/test/parallel/test-quic-h3-datagram.mjs +++ /dev/null @@ -1,175 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: HTTP/3 datagrams with SETTINGS_H3_DATAGRAM negotiation. -// Verifies that QUIC datagrams work correctly with H3 sessions, including -// the SETTINGS_H3_DATAGRAM negotiation required by RFC 9297. -// 1. Both sides enableDatagrams: true — datagrams work alongside H3 streams -// 2. Server enableDatagrams: false — client should not be able to send -// datagrams (peer's SETTINGS_H3_DATAGRAM=0) - -import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - -const { ok, strictEqual } = assert; - -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 { setTimeout: sleep } = await import('timers/promises'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); -const decoder = new TextDecoder(); - -// Test 1: H3 datagrams with enableDatagrams: true on both sides. -// Datagrams work alongside H3 request/response. -{ - const serverGotDatagram = Promise.withResolvers(); - const clientGotDatagram = Promise.withResolvers(); - const serverDone = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - await stream.closed; - }); - await serverGotDatagram.promise; - await sleep(50); - ss.close(); - serverDone.resolve(); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - application: { enableDatagrams: true }, - transportParams: { maxDatagramFrameSize: 100 }, - // Server echoes received datagram back to client. - ondatagram: mustCall(function(data) { - ok(data instanceof Uint8Array); - strictEqual(data.byteLength, 3); - strictEqual(data[0], 10); - strictEqual(data[1], 20); - strictEqual(data[2], 30); - // Echo it back. - this.sendDatagram(new Uint8Array([42, 43, 44])); - serverGotDatagram.resolve(); - }), - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - application: { enableDatagrams: true }, - transportParams: { maxDatagramFrameSize: 100 }, - // Client receives datagram from server. - ondatagram: mustCall(function(data) { - ok(data instanceof Uint8Array); - strictEqual(data.byteLength, 3); - strictEqual(data[0], 42); - strictEqual(data[1], 43); - strictEqual(data[2], 44); - clientGotDatagram.resolve(); - }), - }); - await clientSession.opened; - - // Datagrams work alongside H3 request/response. - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/with-datagram', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':status'], '200'); - }), - }); - - // Send datagram from client. - await clientSession.sendDatagram(new Uint8Array([10, 20, 30])); - - // H3 response body is received. - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'ok'); - await stream.closed; - - // Both sides received their datagram. - await Promise.all([serverGotDatagram.promise, clientGotDatagram.promise]); - - await serverDone.promise; - await clientSession.close(); - await serverEndpoint.close(); -} - -// Test 2: Server has enableDatagrams: false. The peer's H3 SETTINGS -// should indicate SETTINGS_H3_DATAGRAM=0. The client's datagram send -// should return 0 (no datagram sent) because the peer doesn't support -// H3 datagrams. -{ - const serverDone = Promise.withResolvers(); - - const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(async (stream) => { - await stream.closed; - ss.close(); - serverDone.resolve(); - }); - }), { - sni: { '*': { keys: [key], certs: [cert] } }, - // Server explicitly disables H3 datagrams. - application: { enableDatagrams: false }, - // But transport-level datagrams ARE supported. - transportParams: { maxDatagramFrameSize: 100 }, - // Server should NOT receive any datagrams. - ondatagram: mustNotCall(), - onheaders: mustCall((headers, stream) => { - stream.sendHeaders({ ':status': '200' }); - stream.writer.writeSync('no-dgram'); - stream.writer.endSync(); - }), - }); - - const clientSession = await connect(serverEndpoint.address, { - servername: 'localhost', - verifyPeer: 'manual', - application: { enableDatagrams: true }, - transportParams: { maxDatagramFrameSize: 100 }, - }); - await clientSession.opened; - - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/no-datagram', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall((headers) => { - strictEqual(headers[':status'], '200'); - }), - }); - - // The H3 request triggers SETTINGS exchange. After the server's - // SETTINGS (with h3_datagram=0) arrive, the client should know - // the peer doesn't support H3 datagrams. - const body = await bytes(stream); - strictEqual(decoder.decode(body), 'no-dgram'); - - // Attempt to send a datagram. Since the peer's H3 SETTINGS - // indicate h3_datagram=0, this should return 0 (not sent). - const dgId = await clientSession.sendDatagram(new Uint8Array([1, 2, 3])); - strictEqual(dgId, 0n); - - await Promise.all([stream.closed, serverDone.promise]); - clientSession.close(); - await serverEndpoint.close(); -} diff --git a/test/parallel/test-quic-h3-error-codes.mjs b/test/parallel/test-quic-h3-error-codes.mjs index f9aebadc85cfd2..2051ef06b7bf94 100644 --- a/test/parallel/test-quic-h3-error-codes.mjs +++ b/test/parallel/test-quic-h3-error-codes.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -30,6 +30,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; // Close with an explicit H3 application error code. ss.close({ code: 0x101, type: 'application' }); @@ -37,11 +42,6 @@ const decoder = new TextDecoder(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -50,14 +50,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -82,17 +81,17 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -101,14 +100,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); 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 e0dd89d20279a0..00000000000000 --- a/test/parallel/test-quic-h3-goaway-non-h3.mjs +++ /dev/null @@ -1,66 +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'; - -const { strictEqual } = assert; -const { readKey } = fixtures; - -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(readKey('agent1-key.pem')); -const cert = 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); - 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); -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 7542849f35eeed..628cffadc74b19 100644 --- a/test/parallel/test-quic-h3-goaway.mjs +++ b/test/parallel/test-quic-h3-goaway.mjs @@ -22,7 +22,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -47,26 +47,27 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { const serverEndpoint = await listen(mustCall(async (ss) => { serverSession = ss; - ss.onstream = mustCall(2); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + const path = headers[':path']; + stream.sendHeaders({ ':status': '200' }); + + if (path === '/first') { + // Respond immediately to the first request. + stream.writer.writeSync(encoder.encode('first')); + stream.writer.endSync(); + } else if (path === '/second') { + // Hold the second response until signaled. + pendingSecondStream = stream; + completeSecondResponse.promise.then(mustCall(() => { + pendingSecondStream.writer.writeSync(encoder.encode('second')); + pendingSecondStream.writer.endSync(); + })); + } + }); + }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - const path = headers[':path']; - this.sendHeaders({ ':status': '200' }); - - if (path === '/first') { - // Respond immediately to the first request. - this.writer.writeSync(encoder.encode('first')); - this.writer.endSync(); - } else if (path === '/second') { - // Hold the second response until signaled. - pendingSecondStream = this; - completeSecondResponse.promise.then(mustCall(() => { - pendingSecondStream.writer.writeSync(encoder.encode('second')); - pendingSecondStream.writer.endSync(); - })); - } - }, 2), }); const clientSession = await connect(serverEndpoint.address, { @@ -87,25 +88,19 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { } }, 2); - const stream1 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/first', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: onClientHeaders, - }); + const stream1 = await clientSession.request({ + ':method': 'GET', + ':path': '/first', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: onClientHeaders }); - const stream2 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/second', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: onClientHeaders, - }); + const stream2 = await clientSession.request({ + ':method': 'GET', + ':path': '/second', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: onClientHeaders }); // First stream completes immediately. const body1 = await bytes(stream1); @@ -125,13 +120,11 @@ dc.subscribe('quic.session.goaway', mustCall((msg) => { // After GOAWAY, new stream creation should fail. await rejects( - clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/new', - ':scheme': 'https', - ':authority': 'localhost', - }, + clientSession.request({ + ':method': 'GET', + ':path': '/new', + ':scheme': 'https', + ':authority': 'localhost', }), { code: 'ERR_INVALID_STATE' }, ); diff --git a/test/parallel/test-quic-h3-handshake-failure.mjs b/test/parallel/test-quic-h3-handshake-failure.mjs index 640f7e54c40209..e29a7662cb491e 100644 --- a/test/parallel/test-quic-h3-handshake-failure.mjs +++ b/test/parallel/test-quic-h3-handshake-failure.mjs @@ -22,17 +22,20 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); +const onheaders = mustNotCall(); const serverEndpoint = await listen(async (serverSession) => { + serverSession.onstream = (stream) => { + stream.onheaders = onheaders; + }; await serverSession.closed; }, { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustNotCall(), }); // Connect then immediately close the session before the handshake completes. diff --git a/test/parallel/test-quic-h3-header-validation.mjs b/test/parallel/test-quic-h3-header-validation.mjs index 43673e0cc00f1e..2de3366dc10ee7 100644 --- a/test/parallel/test-quic-h3-header-validation.mjs +++ b/test/parallel/test-quic-h3-header-validation.mjs @@ -24,7 +24,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -40,39 +40,39 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // H3V-01: All header names should be lowercase regardless + // of how the client sent them. + for (const name of Object.keys(headers)) { + strictEqual(name, name.toLowerCase(), + `Header name "${name}" should be lowercase`); + } + + // Verify specific headers arrived lowercased. + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/test'); + strictEqual(headers['x-custom-header'], 'Value1'); + strictEqual(headers['content-type'], 'text/plain'); + strictEqual(headers['x-mixed-case'], 'MixedValue'); + + // Verify values are NOT lowercased — only names are. + strictEqual(headers['x-custom-header'], 'Value1'); + + stream.sendHeaders({ + // Response with mixed-case names — should be lowercased. + ':status': '200', + 'Content-Type': 'text/html', + 'X-Response-Header': 'ResponseValue', + }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // H3V-01: All header names should be lowercase regardless - // of how the client sent them. - for (const name of Object.keys(headers)) { - strictEqual(name, name.toLowerCase(), - `Header name "${name}" should be lowercase`); - } - - // Verify specific headers arrived lowercased. - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/test'); - strictEqual(headers['x-custom-header'], 'Value1'); - strictEqual(headers['content-type'], 'text/plain'); - strictEqual(headers['x-mixed-case'], 'MixedValue'); - - // Verify values are NOT lowercased — only names are. - strictEqual(headers['x-custom-header'], 'Value1'); - - this.sendHeaders({ - // Response with mixed-case names — should be lowercased. - ':status': '200', - 'Content-Type': 'text/html', - 'X-Response-Header': 'ResponseValue', - }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -81,18 +81,17 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - // Mixed-case names — should be lowercased by buildNgHeaderString. - ':method': 'GET', - ':path': '/test', - ':scheme': 'https', - ':authority': 'localhost', - 'X-Custom-Header': 'Value1', - 'Content-Type': 'text/plain', - 'X-Mixed-Case': 'MixedValue', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + // Mixed-case names — should be lowercased by buildNgHeaderString. + ':method': 'GET', + ':path': '/test', + ':scheme': 'https', + ':authority': 'localhost', + 'X-Custom-Header': 'Value1', + 'Content-Type': 'text/plain', + 'X-Mixed-Case': 'MixedValue', + }, { + onheaders: mustCall((headers) => { // Client should also receive lowercased response header names. strictEqual(headers[':status'], '200'); strictEqual(headers['content-type'], 'text/html'); @@ -119,22 +118,22 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // All four required pseudo-headers present. + ok(headers[':method']); + ok(headers[':path']); + ok(headers[':scheme']); + ok(headers[':authority']); + + stream.sendHeaders({ ':status': '204' }); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // All four required pseudo-headers present. - ok(headers[':method']); - ok(headers[':path']); - ok(headers[':scheme']); - ok(headers[':authority']); - - this.sendHeaders({ ':status': '204' }); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -143,13 +142,12 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/api/data', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/api/data', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall((headers) => { strictEqual(headers[':status'], '204'); }), @@ -159,3 +157,76 @@ const decoder = new TextDecoder(); await clientSession.close(); await serverEndpoint.close(); } + +// Send-side validation. Invalid request headers reject before a stream +// is opened and pseudo-headers in trailers are rejected. +{ + const serverDone = Promise.withResolvers(); + const encoder = new TextEncoder(); + + const serverEndpoint = await listen(mustCall(async (ss) => { + // Exactly one stream must arrive: the invalid requests below must + // never hit the wire. + ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall(() => { + stream.sendHeaders({ ':status': '200' }); + const w = stream.writer; + w.writeSync(encoder.encode('payload')); + w.endSync(); + }); + stream.onwanttrailers = mustCall(() => { + assert.throws(() => stream.sendTrailers({ ':status': '200' }), + { code: 'ERR_HTTP2_INVALID_PSEUDOHEADER' }); + stream.sendTrailers({ 'x-checksum': 'abc' }); + }); + await stream.closed; + ss.close(); + serverDone.resolve(); + }); + }), { + sni: { '*': { keys: [key], certs: [cert] } }, + }); + + const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + }); + await clientSession.opened; + + // Connection-specific headers are forbidden in HTTP/3. The request + // rejects during validation, before any stream is opened. + await assert.rejects(clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + 'connection': 'keep-alive', + }), { code: 'ERR_HTTP2_INVALID_CONNECTION_HEADERS' }); + + // Unknown pseudo-headers are forbidden. + await assert.rejects(clientSession.request({ + ':bogus': 'nope', + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }), { code: 'ERR_HTTP2_INVALID_PSEUDOHEADER' }); + + // The failed requests left nothing behind: the session is fully + // usable and the next stream gets the first stream id (0). + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { + ontrailers: mustCall((trailers) => { + strictEqual(trailers['x-checksum'], 'abc'); + }), + }); + strictEqual(stream.id, 0n); + + await Promise.all([bytes(stream), stream.closed, serverDone.promise]); + await clientSession.close(); + await serverEndpoint.close(); +} diff --git a/test/parallel/test-quic-h3-headers-support.mjs b/test/parallel/test-quic-h3-headers-support.mjs index 8807adde6ef276..9b8ea14a13c9a4 100644 --- a/test/parallel/test-quic-h3-headers-support.mjs +++ b/test/parallel/test-quic-h3-headers-support.mjs @@ -1,53 +1,64 @@ // Flags: --experimental-quic --no-warnings -// Test: Headers support detection for non-H3 sessions. -// headersSupported is UNSUPPORTED for non-H3 sessions -// Sending headers on non-H3 session throws ERR_INVALID_STATE -// Setting header callbacks on non-H3 stream throws ERR_INVALID_STATE +// Test: QuicStream/QuicSession expose no HTTP/3 surface. +// The HTTP/3 members (sendHeaders, sendInformationalHeaders, sendTrailers, +// headers, onheaders, oninfo, ontrailers, onwanttrailers) were stripped from +// the public node:quic API; node:http3 reaches them via internal symbols. +// Regardless of the negotiated ALPN, plain QUIC streams must expose none of +// them: the methods are undefined and the callback names are plain inert +// expando properties with no prototype accessors behind them. import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; const { readKey } = fixtures; -const { throws } = assert; +const { strictEqual } = assert; if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect, QuicStream, QuicSession } = await import('node:quic'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); const cert = readKey('agent1-cert.pem'); const encoder = new TextEncoder(); +// No h3 accessors or methods exist on the prototypes at all. +for (const name of ['sendHeaders', 'sendInformationalHeaders', 'sendTrailers', + 'headers', 'pendingTrailers', 'onheaders', 'oninfo', + 'ontrailers', 'onwanttrailers']) { + strictEqual(Object.getOwnPropertyDescriptor(QuicStream.prototype, name), + undefined); +} +for (const name of ['ongoaway', 'onorigin']) { + strictEqual(Object.getOwnPropertyDescriptor(QuicSession.prototype, name), + undefined); +} + +function assertNoH3Surface(stream) { + strictEqual(typeof stream.sendHeaders, 'undefined'); + strictEqual(typeof stream.sendInformationalHeaders, 'undefined'); + strictEqual(typeof stream.sendTrailers, 'undefined'); + strictEqual(stream.headers, undefined); + strictEqual(stream.pendingTrailers, undefined); + + // Assigning the old callback names just creates inert expandos; no + // prototype setter intercepts them. + const inert = () => {}; + stream.onheaders = inert; + strictEqual(stream.onheaders, inert); + delete stream.onheaders; +} + const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { + strictEqual(typeof serverSession.ongoaway, 'undefined'); + strictEqual(typeof serverSession.onorigin, 'undefined'); serverSession.onstream = mustCall(async (stream) => { - // Sending headers on non-H3 stream throws. - throws(() => stream.sendHeaders({ ':status': '200' }), { code: 'ERR_INVALID_STATE' }); - - // Setting onheaders on non-H3 stream throws. - throws(() => stream.onheaders = () => {}, { code: 'ERR_INVALID_STATE' }); - - // Setting ontrailers on non-H3 stream throws. - throws(() => stream.ontrailers = () => {}, { code: 'ERR_INVALID_STATE' }); - - // Setting oninfo on non-H3 stream throws. - throws(() => stream.oninfo = () => {}, { code: 'ERR_INVALID_STATE' }); - - // Setting onwanttrailers on non-H3 stream throws. - throws(() => stream.onwanttrailers = () => {}, { code: 'ERR_INVALID_STATE' }); - - // sendInformationalHeaders throws on non-H3. - throws(() => stream.sendInformationalHeaders({ ':status': '103' }), { - code: 'ERR_INVALID_STATE', - }); - - // sendTrailers throws on non-H3. - throws(() => stream.sendTrailers({ 'x-trailer': 'value' }), { code: 'ERR_INVALID_STATE' }); + assertNoH3Surface(stream); stream.writer.endSync(); @@ -66,12 +77,15 @@ const clientSession = await connect(serverEndpoint.address, { }); await clientSession.opened; +strictEqual(typeof clientSession.ongoaway, 'undefined'); +strictEqual(typeof clientSession.onorigin, 'undefined'); + const stream = await clientSession.createBidirectionalStream({ body: encoder.encode('ping'), }); -// Client side — sending headers on non-H3 stream throws. -throws(() => stream.sendHeaders({ ':method': 'GET' }), { code: 'ERR_INVALID_STATE' }); +// Client side: no h3 surface either. +assertNoH3Surface(stream); await serverDone.promise; await clientSession.close(); diff --git a/test/parallel/test-quic-h3-http3session.mjs b/test/parallel/test-quic-h3-http3session.mjs new file mode 100644 index 00000000000000..20bd3ce1b8427f --- /dev/null +++ b/test/parallel/test-quic-h3-http3session.mjs @@ -0,0 +1,87 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// A request/response round-trip through http3.connect/listen, exercising +// onstream delivery (wrapped streams), request(), header events, +// and bodies in both directions. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, ok, rejects } = assert; +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { connect, listen, Http3Session, Http3Stream } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((session) => { + ok(session instanceof Http3Session); + // HTTP/3 requests are client-initiated only: a server session cannot + // open a request stream. + rejects(session.request(), { code: 'ERR_INVALID_STATE' }) + .then(mustCall()); + session.onstream = mustCall((stream) => { + ok(stream instanceof Http3Stream); + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':path'], '/hello'); + strictEqual(headers[':method'], 'GET'); + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + const w = stream.writer; + w.writeSync('hello h3'); + w.endSync(); + }); + stream.closed.then(mustCall(() => { + session.close(); + serverDone.resolve(); + })); + }); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', +}); +ok(clientSession instanceof Http3Session); +const info = await clientSession.opened; +strictEqual(info.protocol, 'h3'); + +const responseHeaders = Promise.withResolvers(); +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/hello', + ':scheme': 'https', + ':authority': 'localhost', +}, { + body: encoder.encode(''), + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + responseHeaders.resolve(); + }), +}); +ok(stream instanceof Http3Stream); + +const body = decoder.decode(await bytes(stream)); +strictEqual(body, 'hello h3'); +await responseHeaders.promise; + +await serverDone.promise; +await clientSession.closed; +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-informational-headers.mjs b/test/parallel/test-quic-h3-informational-headers.mjs index 6fa950b7bccbd6..4cf2253703d5ee 100644 --- a/test/parallel/test-quic-h3-informational-headers.mjs +++ b/test/parallel/test-quic-h3-informational-headers.mjs @@ -22,7 +22,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -50,29 +50,29 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // Send 103 Early Hints before the final response. + stream.sendInformationalHeaders({ + ':status': '103', + 'link': '; rel=preload; as=style', + }); + + // Send final response headers + body. + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + + const w = stream.writer; + w.writeSync(responseBody); + w.endSync(); + }); await stream.closed; serverSession.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // Send 103 Early Hints before the final response. - this.sendInformationalHeaders({ - ':status': '103', - 'link': '; rel=preload; as=style', - }); - - // Send final response headers + body. - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - - const w = this.writer; - w.writeSync(responseBody); - w.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -84,19 +84,18 @@ await clientSession.opened; const clientInfoReceived = Promise.withResolvers(); const clientHeadersReceived = Promise.withResolvers(); -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/page', - ':scheme': 'https', - ':authority': 'localhost', - }, - oninfo: mustCall(function(headers) { +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/page', + ':scheme': 'https', + ':authority': 'localhost', +}, { + oninfo: mustCall((headers) => { strictEqual(headers[':status'], '103'); strictEqual(headers.link, '; rel=preload; as=style'); clientInfoReceived.resolve(); }), - onheaders: mustCall(function(headers) { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-no-application.mjs b/test/parallel/test-quic-h3-no-application.mjs new file mode 100644 index 00000000000000..67f7633c192486 --- /dev/null +++ b/test/parallel/test-quic-h3-no-application.mjs @@ -0,0 +1,107 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: HTTP/3 over a QUIC session with NO kApplication preconfigured. +// Wrapping a raw node:quic session with new Http3Session(session) installs +// and starts the HTTP/3 application itself. Covers a working request/response +// with both peers raw, and the attach guards: only before the session becomes +// active (a server in its delivery frame, a client before its handshake +// completes), and no double-wrap. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, throws } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen: quicListen, connect: quicConnect } = await import('node:quic'); +const { Http3Session } = await import('node:http3'); +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 serverOpts = { sni: { '*': { keys: [key], certs: [cert] } }, alpn: 'h3' }; +const clientOpts = { servername: 'localhost', verifyPeer: 'manual', alpn: 'h3' }; +const reqHeaders = { + ':method': 'GET', ':path': '/', ':scheme': 'https', ':authority': 'localhost', +}; +const body = 'Hello from a no-preconfig H3 server'; +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +// Working request/response with BOTH peers raw (no kApplication): the server +// installs + starts at its delivery frame, the client before its handshake. +{ + const serverDone = Promise.withResolvers(); + const endpoint = await quicListen(mustCall((quicSession) => { + const session = new Http3Session(quicSession); + // The session is now owned; a second wrap of it is rejected. + throws(() => new Http3Session(quicSession), { code: 'ERR_INVALID_STATE' }); + session.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(enc.encode(body)); + stream.writer.endSync(); + }); + await stream.closed; + session.close(); + serverDone.resolve(); + }); + }), serverOpts); + + const client = new Http3Session(await quicConnect(endpoint.address, clientOpts)); + const gotHeaders = Promise.withResolvers(); + const stream = await client.request(reqHeaders, { + onheaders: mustCall((headers) => { + strictEqual(headers[':status'], '200'); + gotHeaders.resolve(); + }), + }); + await gotHeaders.promise; + strictEqual(dec.decode(await bytes(stream)), body); + await Promise.all([stream.closed, serverDone.promise]); + await client.close(); + await endpoint.close(); +} + +// An application may only be attached before the session becomes active. +const tooLate = { code: 'ERR_INVALID_STATE', message: /before it becomes active/ }; + +// Server: a wrap deferred past the delivery frame is rejected. +{ + const done = Promise.withResolvers(); + const endpoint = await quicListen(mustCall((quicSession) => { + setImmediate(mustCall(() => { + throws(() => new Http3Session(quicSession), tooLate); + done.resolve(); + })); + }), serverOpts); + const client = await quicConnect(endpoint.address, clientOpts); + await client.opened; + await done.promise; + await client.close(); + await endpoint.close(); +} + +// Client: a wrap after the handshake completes is rejected. A rejected wrap +// must not poison the session - a retry reports the same reason, not a +// spurious 'already has an application attached'. +{ + const wrapped = Promise.withResolvers(); + const endpoint = await quicListen(mustCall((quicSession) => { + new Http3Session(quicSession); + wrapped.resolve(); + }), serverOpts); + const client = await quicConnect(endpoint.address, clientOpts); + await client.opened; + await wrapped.promise; + throws(() => new Http3Session(client), tooLate); + throws(() => new Http3Session(client), tooLate); + await client.close(); + await endpoint.close(); +} diff --git a/test/parallel/test-quic-h3-origin.mjs b/test/parallel/test-quic-h3-origin.mjs index 4f1fdf50e58d6d..0ad4898ab45775 100644 --- a/test/parallel/test-quic-h3-origin.mjs +++ b/test/parallel/test-quic-h3-origin.mjs @@ -16,7 +16,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,6 +34,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -46,11 +51,6 @@ const decoder = new TextDecoder(); 'example.com': { keys: [key], certs: [cert] }, 'api.example.com': { keys: [key], certs: [cert] }, }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -72,14 +72,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'example.com', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'example.com', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -102,6 +101,11 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -124,11 +128,6 @@ const decoder = new TextDecoder(); // Authoritative defaults to true when omitted. 'default-auth.example.com': { keys: [key], certs: [cert] }, }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -168,14 +167,13 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/', - ':scheme': 'https', - ':authority': 'custom-port.example.com', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'custom-port.example.com', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-h3-pending-stream.mjs b/test/parallel/test-quic-h3-pending-stream.mjs index fd269e64e0543a..53ea9c152f0e8a 100644 --- a/test/parallel/test-quic-h3-pending-stream.mjs +++ b/test/parallel/test-quic-h3-pending-stream.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -31,22 +31,22 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // Headers were enqueued before the stream opened + // and should arrive correctly. + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/pending'); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // Headers were enqueued before the stream opened - // and should arrive correctly. - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/pending'); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -56,13 +56,12 @@ const decoder = new TextDecoder(); // Create the stream BEFORE awaiting opened. The stream is pending // until the handshake completes and the QUIC stream can be opened. - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/pending', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/pending', + ':scheme': 'https', + ':authority': 'localhost', + }, { // Priority set at creation time. priority: 'high', incremental: true, @@ -74,11 +73,15 @@ const decoder = new TextDecoder(); // Priority should reflect what was set even while pending. deepStrictEqual(stream.priority, { level: 'high', incremental: true }); + // Reprioritize while still pending - sends deferred PRIORITY_UPDATE + stream.setPriority({ level: 'low', incremental: false }); + deepStrictEqual(stream.priority, { level: 'low', incremental: false }); + // Now wait for the handshake. await clientSession.opened; - // Priority persists after stream opens. - deepStrictEqual(stream.priority, { level: 'high', incremental: true }); + // The reprioritized value persists after the stream opens. + deepStrictEqual(stream.priority, { level: 'low', incremental: false }); // Headers were sent and server responded. const body = await bytes(stream); diff --git a/test/parallel/test-quic-h3-post-filehandle.mjs b/test/parallel/test-quic-h3-post-filehandle.mjs index 46e30d8376d6cd..7afe32b99afc23 100644 --- a/test/parallel/test-quic-h3-post-filehandle.mjs +++ b/test/parallel/test-quic-h3-post-filehandle.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -40,6 +40,14 @@ writeFileSync(testFile, testContent); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'POST'); + strictEqual(headers[':path'], '/upload'); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); const body = await bytes(stream); strictEqual(decoder.decode(body), testContent); @@ -49,14 +57,6 @@ writeFileSync(testFile, testContent); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'POST'); - strictEqual(headers[':path'], '/upload'); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -70,15 +70,14 @@ writeFileSync(testFile, testContent); const clientHeadersReceived = Promise.withResolvers(); const fh = await open(testFile, 'r'); - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/upload', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/upload', + ':scheme': 'https', + ':authority': 'localhost', + }, { body: fh, - onheaders: mustCall(function(headers) { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); clientHeadersReceived.resolve(); }), diff --git a/test/parallel/test-quic-h3-post-request.mjs b/test/parallel/test-quic-h3-post-request.mjs index a12458ef10df30..eefdbc4fb6757a 100644 --- a/test/parallel/test-quic-h3-post-request.mjs +++ b/test/parallel/test-quic-h3-post-request.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -35,6 +35,25 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'POST'); + strictEqual(headers[':path'], '/submit'); + + // Echo the request body back in the response. + // At this point, request body hasn't arrived yet — we read it + // below. But we can send response headers immediately. + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + // Write echoed body after reading it below. For simplicity, + // we write a fixed response here and verify the request body + // separately below. + const w = stream.writer; + w.writeSync(encoder.encode('echo:' + requestBody)); + w.endSync(); + }); + // Read the full request body from the client. const body = await bytes(stream); const text = decoder.decode(body); @@ -46,24 +65,6 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => { }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'POST'); - strictEqual(headers[':path'], '/submit'); - - // Echo the request body back in the response. - // At this point, request body hasn't arrived yet — we use onstream - // to read it. But we can send response headers immediately. - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - // Write echoed body after reading it in onstream. For simplicity, - // we write a fixed response here and verify the request body - // separately in onstream. - const w = this.writer; - w.writeSync(encoder.encode('echo:' + requestBody)); - w.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -78,15 +79,14 @@ const clientHeadersReceived = Promise.withResolvers(); // Send a POST request with body. When body is provided, terminal is NOT // set on the HEADERS frame (body follows). -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/submit', - ':scheme': 'https', - ':authority': 'localhost', - }, +const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/submit', + ':scheme': 'https', + ':authority': 'localhost', +}, { body: encoder.encode(requestBody), - onheaders: mustCall(function(headers) { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); clientHeadersReceived.resolve(); }), diff --git a/test/parallel/test-quic-h3-priority-header.mjs b/test/parallel/test-quic-h3-priority-header.mjs new file mode 100644 index 00000000000000..3ce3c0e1529c1f --- /dev/null +++ b/test/parallel/test-quic-h3-priority-header.mjs @@ -0,0 +1,88 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: a client's initial request priority (set via request({ priority })) +// reaches the server as an RFC 9218 `priority` request header, and the +// server's stream.priority getter reflects it. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; +const { readKey } = fixtures; + +const { deepStrictEqual, strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); +const { bytes } = await import('stream/iter'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); +const encoder = new TextEncoder(); + +const expectedByPath = { + '/high': { level: 'high', incremental: false }, + '/low-inc': { level: 'low', incremental: true }, + '/default': { level: 'default', incremental: false }, + '/default-inc': { level: 'default', incremental: true }, +}; + +const serverDone = Promise.withResolvers(); +let seen = 0; + +const serverEndpoint = await listen(mustCall((ss) => { + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + deepStrictEqual(stream.priority, expectedByPath[headers[':path']]); + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + if (++seen === 4) serverDone.resolve(); + }); + }, 4); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', +}); +await clientSession.opened; + +const get = (path) => ({ + ':method': 'GET', + ':path': path, + ':scheme': 'https', + ':authority': 'localhost', +}); + +const stream1 = await clientSession.request(get('/high'), { + priority: 'high', + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); +const stream2 = await clientSession.request(get('/low-inc'), { + priority: 'low', + incremental: true, + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); +const stream3 = await clientSession.request(get('/default'), { + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); +const stream4 = await clientSession.request(get('/default-inc'), { + incremental: true, + onheaders: mustCall((headers) => strictEqual(headers[':status'], '200')), +}); + +await Promise.all([ + bytes(stream1), + bytes(stream2), + bytes(stream3), + bytes(stream4), + serverDone.promise, +]); +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-priority.mjs b/test/parallel/test-quic-h3-priority.mjs index fc7ca231f0d63a..ce4482def9124a 100644 --- a/test/parallel/test-quic-h3-priority.mjs +++ b/test/parallel/test-quic-h3-priority.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -33,24 +33,29 @@ const decoder = new TextDecoder(); let requestCount = 0; const serverDone = Promise.withResolvers(); - const serverEndpoint = await listen(mustCall(async (ss) => { + const serverEndpoint = await listen(mustCall((ss) => { ss.onstream = mustCall((stream) => { // Server sees priority on the stream. const pri = stream.priority; strictEqual(typeof pri, 'object'); strictEqual(typeof pri.level, 'string'); strictEqual(typeof pri.incremental, 'boolean'); + + // Attach onheaders synchronously in the onstream frame, before + // any await. + stream.onheaders = mustCall((headers) => { + stream.setPriority({ level: 'high', incremental: true }); + deepStrictEqual(stream.priority, { level: 'high', incremental: true }); + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + if (++requestCount === 4) { + serverDone.resolve(); + } + }); }, 4); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - if (++requestCount === 4) { - serverDone.resolve(); - } - }, 4), }); const clientSession = await connect(serverEndpoint.address, { @@ -60,13 +65,12 @@ const decoder = new TextDecoder(); await clientSession.opened; // Priority set at creation time via options. - const stream1 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/high', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream1 = await clientSession.request({ + ':method': 'GET', + ':path': '/high', + ':scheme': 'https', + ':authority': 'localhost', + }, { priority: 'high', incremental: false, onheaders: mustCall(function(headers) { @@ -78,13 +82,12 @@ const decoder = new TextDecoder(); deepStrictEqual(stream1.priority, { level: 'high', incremental: false }); // Priority 'low' + incremental at creation. - const stream2 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/low-inc', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream2 = await clientSession.request({ + ':method': 'GET', + ':path': '/low-inc', + ':scheme': 'https', + ':authority': 'localhost', + }, { priority: 'low', incremental: true, onheaders: mustCall(function(headers) { @@ -94,13 +97,12 @@ const decoder = new TextDecoder(); deepStrictEqual(stream2.priority, { level: 'low', incremental: true }); // Default priority at creation. - const stream3 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/default', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream3 = await clientSession.request({ + ':method': 'GET', + ':path': '/default', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall(function(headers) { strictEqual(headers[':status'], '200'); }), @@ -108,13 +110,12 @@ const decoder = new TextDecoder(); deepStrictEqual(stream3.priority, { level: 'default', incremental: false }); // setPriority after creation. - const stream4 = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/changed', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream4 = await clientSession.request({ + ':method': 'GET', + ':path': '/changed', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: mustCall(function(headers) { strictEqual(headers[':status'], '200'); }), @@ -167,6 +168,14 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + // Attach onheaders synchronously in the onstream frame, before + // any await. + stream.onheaders = mustCall(() => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); + // Read the request body — this acts as a signal that the // client's PRIORITY_UPDATE has been sent. The control stream // (carrying PRIORITY_UPDATE) is processed before bidi stream @@ -186,11 +195,6 @@ const decoder = new TextDecoder(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -202,20 +206,19 @@ const decoder = new TextDecoder(); // Create stream with default priority and a body. The body serves // as a signal — by the time it arrives at the server, the // PRIORITY_UPDATE (sent on the control stream) will have been - // processed. setPriority is called BEFORE createBidirectionalStream + // processed. setPriority is called BEFORE request() // so the PRIORITY_UPDATE is queued before the stream data. // - // Note: setPriority must be called after createBidirectionalStream + // Note: setPriority must be called after request() // because the stream handle is needed. But the PRIORITY_UPDATE // travels on the control stream which nghttp3 processes before // bidi stream data, so the ordering is guaranteed. - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/pri-update', - ':scheme': 'https', - ':authority': 'localhost', - }, + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/pri-update', + ':scheme': 'https', + ':authority': 'localhost', + }, { body: encoder.encode('signal'), onheaders: mustCall(function(headers) { strictEqual(headers[':status'], '200'); diff --git a/test/parallel/test-quic-h3-qpack-settings.mjs b/test/parallel/test-quic-h3-qpack-settings.mjs index 6ca5671ef5b91f..a7eba3c622428e 100644 --- a/test/parallel/test-quic-h3-qpack-settings.mjs +++ b/test/parallel/test-quic-h3-qpack-settings.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -30,14 +30,13 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); async function makeRequest(clientSession, path) { - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': path, - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': path, + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -54,26 +53,27 @@ async function makeRequest(clientSession, path) { let requestCount = 0; const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(2); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + if (++requestCount === 2) { + serverDone.resolve(); + } + }); + }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, // Server disables QPACK dynamic table. - application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - if (++requestCount === 2) { - serverDone.resolve(); - } - }, 2), + settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', // Client also disables QPACK dynamic table. - application: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, + settings: { qpackMaxDTableCapacity: 0, qpackBlockedStreams: 0 }, }); await clientSession.opened; @@ -93,24 +93,25 @@ async function makeRequest(clientSession, path) { let requestCount = 0; const serverEndpoint = await listen(mustCall(async (ss) => { - ss.onstream = mustCall(2); + ss.onstream = mustCall((stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + if (++requestCount === 2) { + serverDone.resolve(); + } + }); + }, 2); }), { sni: { '*': { keys: [key], certs: [cert] } }, - application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - if (++requestCount === 2) { - serverDone.resolve(); - } - }, 2), + settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', - application: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, + settings: { qpackMaxDTableCapacity: 8192, qpackBlockedStreams: 200 }, }); await clientSession.opened; diff --git a/test/parallel/test-quic-h3-request-response.mjs b/test/parallel/test-quic-h3-request-response.mjs index 1610f8deec1d41..f81e5f52bc5ed8 100644 --- a/test/parallel/test-quic-h3-request-response.mjs +++ b/test/parallel/test-quic-h3-request-response.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -33,52 +33,43 @@ const responseBody = 'Hello from H3 server'; const serverDone = Promise.withResolvers(); -// The onheaders callback signature is (headers, kind) with `this` bound -// to the stream. A regular function is used so `this` is accessible. -// safeCallbackInvoke(fn, owner, ...args) consumes the owner for error -// handling and forwards only ...args to fn. -const serverEndpoint = await listen(mustCall(async (serverSession) => { +const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { + // Attach onheaders synchronously in the onstream frame, before any + // await — header delivery follows stream creation. + stream.onheaders = mustCall((headers) => { + // Verify request pseudo-headers. + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/index.html'); + strictEqual(headers[':scheme'], 'https'); + strictEqual(headers[':authority'], 'localhost'); + + // After onheaders, stream.headers returns the initial headers. + strictEqual(stream.headers[':method'], 'GET'); + + // Send response headers (terminal: false is the default — body + // follows). + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + + // Write response body and close the write side. + const w = stream.writer; + w.writeSync(encoder.encode(responseBody)); + w.endSync(); + }); await stream.closed; serverSession.close(); serverDone.resolve(); }); }), { 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. - // For H3, onheaders must be set because the H3 application delivers - // headers and stream[kHeaders] asserts the callback exists. - onheaders: mustCall(function(headers) { - // Verify request pseudo-headers. - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/index.html'); - strictEqual(headers[':scheme'], 'https'); - strictEqual(headers[':authority'], 'localhost'); - - // After onheaders, stream.headers returns the initial headers. - // `this` is the stream (bound by the onheaders setter). - strictEqual(this.headers[':method'], 'GET'); - - // Send response headers (terminal: false is the default — body follows). - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - - // Write response body and close the write side. - const w = this.writer; - w.writeSync(encoder.encode(responseBody)); - w.endSync(); - }), }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', - // Default ALPN is h3. }); const info = await clientSession.opened; @@ -88,14 +79,13 @@ const clientHeadersReceived = Promise.withResolvers(); // Send a GET request. With body omitted, the terminal flag is set // automatically (END_STREAM on the HEADERS frame). -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/index.html', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/index.html', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); strictEqual(headers['content-type'], 'text/plain'); clientHeadersReceived.resolve(); diff --git a/test/parallel/test-quic-h3-session-settings.mjs b/test/parallel/test-quic-h3-session-settings.mjs new file mode 100644 index 00000000000000..76e96ad7678da6 --- /dev/null +++ b/test/parallel/test-quic-h3-session-settings.mjs @@ -0,0 +1,113 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings --expose-internals + +// Test: http3Session.settings +// Verifies that the effective HTTP/3 settings are exposed on the +// Http3Session wrapper, are a null-prototype object, reflect the values +// configured through the `settings` option of http3.connect()/listen() +// (some values are subsequently confirmed/updated by the peer's SETTINGS +// frame; both sides are configured identically here so the values are +// stable), and return null after close. Also pins that h3 sessions +// (unlike raw QUIC sessions) report an installed application. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); + +const { listen, connect } = await import('node:http3'); +const { createPrivateKey } = await import('node:crypto'); + +const key = createPrivateKey(readKey('agent1-key.pem')); +const cert = readKey('agent1-cert.pem'); + +const customSettings = { + maxHeaderPairs: 50n, + maxHeaderLength: 8192n, + maxFieldSectionSize: 16384n, + qpackMaxDTableCapacity: 2048n, + qpackEncoderMaxDTableCapacity: 2048n, + qpackBlockedStreams: 50n, + enableConnectProtocol: false, +}; + +function checkSettings(settings, what) { + ok(settings != null, `${what} settings should be available`); + strictEqual(typeof settings, 'object'); + strictEqual(Object.getPrototypeOf(settings), null); + strictEqual(settings.maxHeaderPairs, customSettings.maxHeaderPairs); + strictEqual(settings.maxHeaderLength, customSettings.maxHeaderLength); + strictEqual(settings.maxFieldSectionSize, + customSettings.maxFieldSectionSize); + strictEqual(settings.qpackMaxDtableCapacity, + customSettings.qpackMaxDTableCapacity); + strictEqual(settings.qpackEncoderMaxDtableCapacity, + customSettings.qpackEncoderMaxDTableCapacity); + strictEqual(settings.qpackBlockedStreams, + customSettings.qpackBlockedStreams); + strictEqual(settings.enableConnectProtocol, + customSettings.enableConnectProtocol); +} + +const serverDone = Promise.withResolvers(); + +const serverEndpoint = await listen(mustCall((serverSession) => { + serverSession.onstream = mustCall(async (stream) => { + // The application is installed from the first flight, so the + // settings are available as soon as the session is surfaced. + checkSettings(serverSession.settings, 'server'); + strictEqual( + getQuicSessionState(serverSession.session).hasApplication, true); + strictEqual(getQuicSessionState(serverSession.session).isServer, true); + + stream.onheaders = mustCall(() => { + stream.sendHeaders({ ':status': '200' }, { terminal: true }); + }); + await stream.closed; + serverSession.close(); + serverDone.resolve(); + }); +}), { + sni: { '*': { keys: [key], certs: [cert] } }, + settings: customSettings, +}); + +const clientSession = await connect(serverEndpoint.address, { + servername: 'localhost', + verifyPeer: 'manual', + settings: customSettings, +}); +await clientSession.opened; + +// The client installs its application at session creation, so the +// settings are available immediately after the session opens. +checkSettings(clientSession.settings, 'client'); +strictEqual(getQuicSessionState(clientSession.session).hasApplication, true); +strictEqual(getQuicSessionState(clientSession.session).isServer, false); + +// Exchange a request to let the server side run its assertions. +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', +}); + +// eslint-disable-next-line no-unused-vars +for await (const _ of stream) { /* drain */ } +await Promise.all([stream.closed, serverDone.promise]); + +// After close, settings should return null. +await clientSession.close(); +strictEqual(clientSession.settings, null); + +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-h3-settings.mjs b/test/parallel/test-quic-h3-settings.mjs index d954813c9c2564..cde0d6c4d87b85 100644 --- a/test/parallel/test-quic-h3-settings.mjs +++ b/test/parallel/test-quic-h3-settings.mjs @@ -4,7 +4,6 @@ // maxHeaderPairs enforcement - reject headers exceeding pair count // maxHeaderLength enforcement - reject headers exceeding byte length // enableConnectProtocol setting (accepted without error) -// enableDatagrams setting (accepted without error) import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; @@ -17,7 +16,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -34,6 +33,20 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/limited'); + strictEqual(headers[':scheme'], 'https'); + strictEqual(headers[':authority'], 'localhost'); + // x-first is the 5th pair — accepted. + strictEqual(headers['x-first'], 'one'); + // x-second would be the 6th pair — dropped. + strictEqual(headers['x-second'], undefined); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -41,21 +54,7 @@ const decoder = new TextDecoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, // Allow 5 header pairs: 4 pseudo-headers + 1 custom. - application: { maxHeaderPairs: 5 }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/limited'); - strictEqual(headers[':scheme'], 'https'); - strictEqual(headers[':authority'], 'localhost'); - // x-first is the 5th pair — accepted. - strictEqual(headers['x-first'], 'one'); - // x-second would be the 6th pair — dropped. - strictEqual(headers['x-second'], undefined); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), + settings: { maxHeaderPairs: 5 }, }); const clientSession = await connect(serverEndpoint.address, { @@ -64,16 +63,15 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/limited', - ':scheme': 'https', - ':authority': 'localhost', - 'x-first': 'one', - 'x-second': 'two', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/limited', + ':scheme': 'https', + ':authority': 'localhost', + 'x-first': 'one', + 'x-second': 'two', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -96,6 +94,16 @@ const decoder = new TextDecoder(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'GET'); + strictEqual(headers[':path'], '/length-limited'); + // x-long should be dropped — would push total over 100 bytes. + strictEqual(headers['x-long'], undefined); + + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); @@ -104,17 +112,7 @@ const decoder = new TextDecoder(); 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. - application: { maxHeaderLength: 100 }, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'GET'); - strictEqual(headers[':path'], '/length-limited'); - // x-long should be dropped — would push total over 100 bytes. - strictEqual(headers['x-long'], undefined); - - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('ok')); - this.writer.endSync(); - }), + settings: { maxHeaderLength: 100 }, }); const clientSession = await connect(serverEndpoint.address, { @@ -123,15 +121,14 @@ const decoder = new TextDecoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/length-limited', - ':scheme': 'https', - ':authority': 'localhost', - 'x-long': longValue, - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/length-limited', + ':scheme': 'https', + ':authority': 'localhost', + 'x-long': longValue, + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -143,51 +140,50 @@ const decoder = new TextDecoder(); await serverEndpoint.close(); } -// enableConnectProtocol and enableDatagrams settings. +// enableConnectProtocol setting plus transport-level datagrams. // Verify these options are accepted and H3 sessions work with them. +// (Datagram support is a QUIC transport parameter; HTTP/3 datagrams are +// not yet supported, so SETTINGS_H3_DATAGRAM is never advertised.) { const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode('settings-ok')); + stream.writer.endSync(); + }); await stream.closed; ss.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - application: { enableConnectProtocol: true, enableDatagrams: true }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode('settings-ok')); - this.writer.endSync(); + settings: { enableConnectProtocol: true }, + onsettings: mustCall((settings) => { + strictEqual(settings.enableConnectProtocol, false); + // Must be false, as this is only sent from the server side. }), - onapplication: mustCall((appopt) => { - strictEqual(appopt.enableDatagrams, true); - strictEqual(appopt.enableConnectProtocol, false); - // Must be false, as this is only sent from server side - }) }); const clientSession = await connect(serverEndpoint.address, { servername: 'localhost', verifyPeer: 'manual', - application: { enableConnectProtocol: true, enableDatagrams: true }, + settings: { enableConnectProtocol: true }, }); - clientSession.onapplication = mustCall((appopt) => { - strictEqual(appopt.enableConnectProtocol, true); - strictEqual(appopt.enableDatagrams, true); + clientSession.onsettings = mustCall((settings) => { + strictEqual(settings.enableConnectProtocol, true); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/settings', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/settings', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); 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 67286d9a5ad6a9..957e274cfbf2e7 100644 --- a/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs +++ b/test/parallel/test-quic-h3-stream-destroy-with-headers.mjs @@ -15,7 +15,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); @@ -39,13 +39,11 @@ const clientSession = await connect(serverEndpoint.address, { await clientSession.opened; // Create a stream with headers, then immediately destroy it. -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/destroyed', - ':scheme': 'https', - ':authority': 'localhost', - }, +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/destroyed', + ':scheme': 'https', + ':authority': 'localhost', }); // Destroy the stream before headers can be sent/processed. diff --git a/test/parallel/test-quic-h3-stream-idle-timeout.mjs b/test/parallel/test-quic-h3-stream-idle-timeout.mjs index 5c851caca42465..4d7b59ca87a5b7 100644 --- a/test/parallel/test-quic-h3-stream-idle-timeout.mjs +++ b/test/parallel/test-quic-h3-stream-idle-timeout.mjs @@ -20,7 +20,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); @@ -34,6 +34,8 @@ const encoder = new TextEncoder(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + // Receive headers but do nothing — let the stream go idle. + stream.onheaders = () => {}; // Don't read — let the stream sit idle after the initial headers. // The stream idle timeout should destroy it, rejecting stream.closed. await rejects(stream.closed, { @@ -44,9 +46,6 @@ const encoder = new TextEncoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 100, - onheaders() { - // Receive headers but do nothing — let the stream go idle. - }, }); const clientSession = await connect(serverEndpoint.address, { @@ -59,15 +58,12 @@ const encoder = new TextEncoder(); // Send a POST request with a body byte so the server creates the // stream, then stop sending (don't end the write side). - const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'POST', - ':path': '/', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders() {}, - }); + const stream = await clientSession.request({ + ':method': 'POST', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }, { onheaders: () => {} }); const writer = stream.writer; writer.writeSync(encoder.encode('x')); @@ -91,6 +87,11 @@ const encoder = new TextEncoder(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + strictEqual(headers[':method'], 'POST'); + // Send response headers so the stream is fully established. + stream.sendHeaders({ ':status': '200' }, { terminal: true }); + }); const data = await text(stream); strictEqual(data, 'xy'); serverGotData.resolve(); @@ -99,11 +100,6 @@ const encoder = new TextEncoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 500, - onheaders: mustCall(function(headers) { - strictEqual(headers[':method'], 'POST'); - // Send response headers so the stream is fully established. - this.sendHeaders({ ':status': '200' }, { terminal: true }); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -112,8 +108,10 @@ const encoder = new TextEncoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - onheaders() {}, + // This test needs to send headers separately with an explicit terminal + // flag, so open the request stream without headers and send them later. + const stream = await clientSession.request(undefined, { + onheaders: () => {}, }); stream.sendHeaders({ ':method': 'POST', @@ -141,6 +139,9 @@ const encoder = new TextEncoder(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }, { terminal: true }); + }); const data = await text(stream); strictEqual(data, 'xy'); streamSurvived.resolve(); @@ -151,9 +152,6 @@ const encoder = new TextEncoder(); }), { sni: { '*': { keys: [key], certs: [cert] } }, streamIdleTimeout: 0, // Disabled - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }, { terminal: true }); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -162,8 +160,8 @@ const encoder = new TextEncoder(); }); await clientSession.opened; - const stream = await clientSession.createBidirectionalStream({ - onheaders() {}, + const stream = await clientSession.request(undefined, { + onheaders: () => {}, }); stream.sendHeaders({ ':method': 'POST', diff --git a/test/parallel/test-quic-h3-trailing-headers.mjs b/test/parallel/test-quic-h3-trailing-headers.mjs index 436cb243b3dd99..fcd0b5d31640c8 100644 --- a/test/parallel/test-quic-h3-trailing-headers.mjs +++ b/test/parallel/test-quic-h3-trailing-headers.mjs @@ -21,7 +21,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -52,32 +52,32 @@ const serverDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall(async (serverSession) => { serverSession.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + // Send response headers. + stream.sendHeaders({ + ':status': '200', + 'content-type': 'text/plain', + }); + + // Write body and close. + const w = stream.writer; + w.writeSync(encoder.encode(responseBody)); + w.endSync(); + }); + // Fires after the body is fully sent (EOF + NO_END_STREAM). + // The server provides trailing headers here. + stream.onwanttrailers = mustCall(() => { + stream.sendTrailers({ + 'x-checksum': 'abc123', + 'x-request-id': '42', + }); + }); await stream.closed; serverSession.close(); serverDone.resolve(); }); }), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - // Send response headers. - this.sendHeaders({ - ':status': '200', - 'content-type': 'text/plain', - }); - - // Write body and close. - const w = this.writer; - w.writeSync(encoder.encode(responseBody)); - w.endSync(); - }), - // Fires after the body is fully sent (EOF + NO_END_STREAM). - // The server provides trailing headers here. - onwanttrailers: mustCall(function() { - this.sendTrailers({ - 'x-checksum': 'abc123', - 'x-request-id': '42', - }); - }), }); const clientSession = await connect(serverEndpoint.address, { @@ -89,18 +89,17 @@ await clientSession.opened; const clientHeadersReceived = Promise.withResolvers(); const clientTrailersReceived = Promise.withResolvers(); -const stream = await clientSession.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/with-trailers', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const stream = await clientSession.request({ + ':method': 'GET', + ':path': '/with-trailers', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); clientHeadersReceived.resolve(); }), - ontrailers: mustCall(function(trailers) { + ontrailers: mustCall((trailers) => { strictEqual(trailers['x-checksum'], 'abc123'); strictEqual(trailers['x-request-id'], '42'); clientTrailersReceived.resolve(); 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 e724b6b04a485b..d90da39eba471c 100644 --- a/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-bogus-ticket.mjs @@ -16,7 +16,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey, randomBytes } = await import('node:crypto'); const key = createPrivateKey(readKey('agent1-key.pem')); 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 41f77a63a1f980..3e57d5d6890f4a 100644 --- a/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs +++ b/test/parallel/test-quic-h3-zero-rtt-rejected-settings.mjs @@ -3,7 +3,6 @@ // Test: H3 0-RTT rejected when server reduces application settings. // 0-RTT rejected when max_field_section_size decreased // 0-RTT rejected when enable_connect_protocol disabled -// 0-RTT rejected when enable_datagrams disabled // Each test creates two endpoints with the same key/cert/tokenSecret. // The first endpoint issues a ticket with generous H3 settings. The // second endpoint has reduced settings, causing the H3 session ticket @@ -20,7 +19,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey, randomBytes } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -38,17 +37,17 @@ async function getTicket(endpointOptions) { const ep = await listen(mustCall(async (ss) => { ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync('ok'); + stream.writer.endSync(); + }); await stream.closed; ss.close(); }); }), { sni, ...endpointOptions, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync('ok'); - this.writer.endSync(); - }), }); const cs = await connect(ep.address, { @@ -69,14 +68,13 @@ async function getTicket(endpointOptions) { await cs.opened; await Promise.all([gotTicket.promise, gotToken.promise]); - const s = await cs.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/ticket', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { + const s = await cs.request({ + ':method': 'GET', + ':path': '/ticket', + ':scheme': 'https', + ':authority': 'localhost', + }, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -114,13 +112,11 @@ async function attemptRejected0RTT(endpointOptions, ticket, token) { // or datagram is sent. When 0-RTT is rejected, the stream is // destroyed by EarlyDataRejected — its closed promise rejects // with an application error. - const s = await cs.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/rejected', - ':scheme': 'https', - ':authority': 'localhost', - }, + const s = await cs.request({ + ':method': 'GET', + ':path': '/rejected', + ':scheme': 'https', + ':authority': 'localhost', }); await rejects(s.closed, { code: 'ERR_QUIC_APPLICATION_ERROR', @@ -140,27 +136,13 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - application: { enableConnectProtocol: true }, + settings: { enableConnectProtocol: true }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // EnableConnectProtocol reduced from true to false. - application: { enableConnectProtocol: false }, - }, ticket, token); -} - -// enable_datagrams disabled. -{ - const { ticket, token } = await getTicket({ - endpoint: { tokenSecret }, - application: { enableDatagrams: true }, - }); - - await attemptRejected0RTT({ - endpoint: { tokenSecret }, - // EnableDatagrams reduced from true to false. - application: { enableDatagrams: false }, + settings: { enableConnectProtocol: false }, }, ticket, token); } @@ -168,12 +150,12 @@ const tokenSecret = randomBytes(16); { const { ticket, token } = await getTicket({ endpoint: { tokenSecret }, - application: { maxFieldSectionSize: 10000 }, + settings: { maxFieldSectionSize: 10000 }, }); await attemptRejected0RTT({ endpoint: { tokenSecret }, // MaxFieldSectionSize reduced from 10000 to 100. - application: { maxFieldSectionSize: 100 }, + settings: { maxFieldSectionSize: 100 }, }, ticket, token); } diff --git a/test/parallel/test-quic-h3-zero-rtt.mjs b/test/parallel/test-quic-h3-zero-rtt.mjs index 4e51958d7c864a..4a927993b8961d 100644 --- a/test/parallel/test-quic-h3-zero-rtt.mjs +++ b/test/parallel/test-quic-h3-zero-rtt.mjs @@ -17,7 +17,7 @@ if (!hasQuic) { skip('QUIC is not enabled'); } -const { listen, connect } = await import('node:quic'); +const { listen, connect } = await import('node:http3'); const { createPrivateKey } = await import('node:crypto'); const { bytes } = await import('stream/iter'); @@ -37,6 +37,11 @@ const secondDone = Promise.withResolvers(); const serverEndpoint = await listen(mustCall((ss) => { const num = ++serverSessionCount; ss.onstream = mustCall(async (stream) => { + stream.onheaders = mustCall((headers) => { + stream.sendHeaders({ ':status': '200' }); + stream.writer.writeSync(encoder.encode(headers[':path'])); + stream.writer.endSync(); + }); if (num === 2) { // Resolve with the stream so we can check stream.early after // data has been received (the early flag is set after @@ -48,11 +53,6 @@ const serverEndpoint = await listen(mustCall((ss) => { }); }, 2), { sni: { '*': { keys: [key], certs: [cert] } }, - onheaders: mustCall(function(headers) { - this.sendHeaders({ ':status': '200' }); - this.writer.writeSync(encoder.encode(headers[':path'])); - this.writer.endSync(); - }, 2), }); // --- First connection: establish H3 session, receive ticket --- @@ -78,14 +78,13 @@ strictEqual(info1.earlyDataAccepted, false); await Promise.all([gotTicket.promise, gotToken.promise]); -const s1 = await cs1.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/first', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const s1 = await cs1.request({ + ':method': 'GET', + ':path': '/first', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); @@ -106,14 +105,13 @@ const cs2 = await connect(serverEndpoint.address, { }); // Send H3 request BEFORE handshake completes — true 0-RTT. -const s2 = await cs2.createBidirectionalStream({ - headers: { - ':method': 'GET', - ':path': '/early', - ':scheme': 'https', - ':authority': 'localhost', - }, - onheaders: mustCall(function(headers) { +const s2 = await cs2.request({ + ':method': 'GET', + ':path': '/early', + ':scheme': 'https', + ':authority': 'localhost', +}, { + onheaders: mustCall((headers) => { strictEqual(headers[':status'], '200'); }), }); diff --git a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs index 7dda0a6f28d865..5f496e74967dcd 100644 --- a/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs +++ b/test/parallel/test-quic-internal-endpoint-listen-defaults.mjs @@ -29,7 +29,7 @@ ok(!state.isListening); strictEqual(endpoint.address, undefined); -await rejects(listen(123, { sni, endpoint }), { +await rejects(listen(123, { alpn: ['h3'], sni, endpoint }), { code: 'ERR_INVALID_ARG_TYPE', }); // Buffer is not detached. @@ -39,11 +39,11 @@ await rejects(listen(mustNotCall(), 123), { code: 'ERR_INVALID_ARG_TYPE', }); -await listen(mustNotCall(), { sni, endpoint }); +await listen(mustNotCall(), { alpn: ['h3'], sni, endpoint }); // Buffer is not detached. strictEqual(cert.buffer.detached, false); -await rejects(listen(mustNotCall(), { sni, endpoint }), { +await rejects(listen(mustNotCall(), { alpn: ['h3'], sni, endpoint }), { code: 'ERR_INVALID_STATE', }); // Buffer is not detached. @@ -67,7 +67,7 @@ strictEqual(endpoint.closed, endpoint.close()); await endpoint.closed; ok(endpoint.destroyed); -await rejects(listen(mustNotCall(), { sni, endpoint }), { +await rejects(listen(mustNotCall(), { alpn: ['h3'], 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 2af27724eb4e18..f81561ea84b5a7 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -172,9 +172,9 @@ strictEqual(sessionState.isStatelessReset, false); strictEqual(sessionState.isHandshakeCompleted, false); strictEqual(sessionState.isHandshakeConfirmed, false); strictEqual(sessionState.isStreamOpenAllowed, false); -strictEqual(sessionState.isPrioritySupported, false); -strictEqual(sessionState.headersSupported, 0); +strictEqual(sessionState.hasApplication, false); strictEqual(sessionState.isWrapped, false); +strictEqual(sessionState.isServer, false); strictEqual(sessionState.maxDatagramSize, 0); strictEqual(sessionState.lastDatagramId, 0n); diff --git a/test/parallel/test-quic-internal-setcallbacks.mjs b/test/parallel/test-quic-internal-setcallbacks.mjs index b485b5e9b43457..848586aac275a3 100644 --- a/test/parallel/test-quic-internal-setcallbacks.mjs +++ b/test/parallel/test-quic-internal-setcallbacks.mjs @@ -15,7 +15,6 @@ const callbacks = { onEndpointClose() {}, onSessionNew() {}, onSessionClose() {}, - onSessionApplication() {}, onSessionDatagram() {}, onSessionDatagramStatus() {}, onSessionHandshake() {}, @@ -25,16 +24,12 @@ const callbacks = { onSessionKeyLog() {}, onSessionQlog() {}, onSessionEarlyDataRejected() {}, - onSessionOrigin() {}, - onSessionGoaway() {}, onSessionVersionNegotiation() {}, onStreamCreated() {}, onStreamBlocked() {}, onStreamClose() {}, onStreamDrain() {}, onStreamReset() {}, - onStreamHeaders() {}, - onStreamTrailers() {}, }; // Fail if any callback is missing for (const fn of Object.keys(callbacks)) { @@ -49,3 +44,26 @@ quic.setCallbacks(callbacks); // Multiple calls should just be ignored. quic.setCallbacks(callbacks); + +// The HTTP/3 application-event callbacks are registered separately (by the +// HTTP/3 consumer layer) and validate their own set. +const http3Callbacks = { + onSessionApplication() {}, + onSessionGoaway() {}, + onSessionOrigin() {}, + onStreamHeaders() {}, + onStreamTrailers() {}, +}; +// Fail if any callback is missing +for (const fn of Object.keys(http3Callbacks)) { + // eslint-disable-next-line no-unused-vars + const { [fn]: _, ...rest } = http3Callbacks; + throws(() => quic.setHttp3Callbacks(rest), { + code: 'ERR_MISSING_ARGS', + }); +} +// If all callbacks are present it should work +quic.setHttp3Callbacks(http3Callbacks); + +// Multiple calls should just be ignored. +quic.setHttp3Callbacks(http3Callbacks); diff --git a/test/parallel/test-quic-session-application-options.mjs b/test/parallel/test-quic-session-application-options.mjs deleted file mode 100644 index 1f5c9c0926808c..00000000000000 --- a/test/parallel/test-quic-session-application-options.mjs +++ /dev/null @@ -1,105 +0,0 @@ -// Flags: --experimental-quic --experimental-stream-iter --no-warnings - -// Test: session.applicationOptions -// Verifies that applicationOptions is available after ALPN negotiation -// completes (i.e., once the application has been selected), returns a -// null-prototype object, and reflects the configured values. - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; - -const { ok, strictEqual } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('../common/quic.mjs'); - -const customAppOptions = { - maxHeaderPairs: 50n, - maxHeaderLength: 8192n, - maxFieldSectionSize: 16384n, - qpackMaxDTableCapacity: 2048n, - qpackEncoderMaxDTableCapacity: 2048n, - qpackBlockedStreams: 50n, - enableConnectProtocol: false, - enableDatagrams: false, -}; - -const serverDone = Promise.withResolvers(); - -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; - - ok(opts != null, 'server applicationOptions should be available after handshake'); - strictEqual(typeof opts, 'object'); - strictEqual(Object.getPrototypeOf(opts), null); - - // Verify configured values are reflected. - strictEqual(opts.maxHeaderPairs, BigInt(customAppOptions.maxHeaderPairs)); - strictEqual(opts.maxHeaderLength, BigInt(customAppOptions.maxHeaderLength)); - strictEqual(opts.maxFieldSectionSize, - BigInt(customAppOptions.maxFieldSectionSize)); - strictEqual(opts.qpackMaxDtableCapacity, - BigInt(customAppOptions.qpackMaxDTableCapacity)); - strictEqual(opts.qpackEncoderMaxDtableCapacity, - BigInt(customAppOptions.qpackEncoderMaxDTableCapacity)); - strictEqual(opts.qpackBlockedStreams, - BigInt(customAppOptions.qpackBlockedStreams)); - strictEqual(opts.enableConnectProtocol, - customAppOptions.enableConnectProtocol); - strictEqual(opts.enableDatagrams, customAppOptions.enableDatagrams); - - stream.writer.endSync(); - await stream.closed; - serverSession.close(); - serverDone.resolve(); - }); -}), { - application: customAppOptions, -}); - -const clientSession = await connect(serverEndpoint.address, { - application: customAppOptions, -}); -await clientSession.opened; - -// After opened, ALPN negotiation is complete and applicationOptions -// should be available on the client session. -const clientOpts = clientSession.applicationOptions; -ok(clientOpts != null, 'client applicationOptions should be available after handshake'); -strictEqual(typeof clientOpts, 'object'); -strictEqual(Object.getPrototypeOf(clientOpts), null); - -// Verify configured values on the client side. -strictEqual(clientOpts.maxHeaderPairs, BigInt(customAppOptions.maxHeaderPairs)); -strictEqual(clientOpts.maxHeaderLength, BigInt(customAppOptions.maxHeaderLength)); -strictEqual(clientOpts.maxFieldSectionSize, - customAppOptions.maxFieldSectionSize); -strictEqual(clientOpts.qpackMaxDtableCapacity, - customAppOptions.qpackMaxDTableCapacity); -strictEqual(clientOpts.qpackEncoderMaxDtableCapacity, - customAppOptions.qpackEncoderMaxDTableCapacity); -strictEqual(clientOpts.qpackBlockedStreams, - customAppOptions.qpackBlockedStreams); -strictEqual(clientOpts.enableConnectProtocol, - customAppOptions.enableConnectProtocol); -strictEqual(clientOpts.enableDatagrams, customAppOptions.enableDatagrams); - -// Exchange data to let the server side run its assertions. -const stream = await clientSession.createBidirectionalStream(); -stream.writer.endSync(); - -// eslint-disable-next-line no-unused-vars -for await (const _ of stream) { /* drain */ } -await Promise.all([stream.closed, serverDone.promise]); - -// After close, applicationOptions should return null. -await clientSession.close(); -strictEqual(clientSession.applicationOptions, null); - -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-emit-ordering.mjs b/test/parallel/test-quic-session-emit-ordering.mjs new file mode 100644 index 00000000000000..56bfeb4daefe67 --- /dev/null +++ b/test/parallel/test-quic-session-emit-ordering.mjs @@ -0,0 +1,48 @@ +// Flags: --experimental-quic --no-warnings --expose-internals + +// Check that server `onsession` emit fires only after the session's +// ClientHello has been processed & validated. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { ok, notStrictEqual, strictEqual } = assert; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { createRequire } = await import('node:module'); +const require = createRequire(import.meta.url); +const { getQuicSessionState } = require('internal/quic/quic'); +const { listen, connect } = await import('../common/quic.mjs'); + +const sessionSeen = Promise.withResolvers(); + +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. + strictEqual(serverSession.servername, 'localhost'); + strictEqual(serverSession.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; + notStrictEqual(params, undefined); + notStrictEqual(params, null); + ok(params.initialMaxStreamsBidi >= 0n); + + // ALPN negotiation has completed without installing an application + // (none requested, so this is a raw session). + strictEqual(getQuicSessionState(serverSession).hasApplication, false); + + sessionSeen.resolve(); +})); + +const clientSession = await connect(serverEndpoint.address); +await clientSession.opened; +await sessionSeen.promise; + +await clientSession.close(); +await serverEndpoint.close(); diff --git a/test/parallel/test-quic-session-stream-lifecycle.mjs b/test/parallel/test-quic-session-stream-lifecycle.mjs index a4bd287cdc6dcd..f1d7ebc36a191d 100644 --- a/test/parallel/test-quic-session-stream-lifecycle.mjs +++ b/test/parallel/test-quic-session-stream-lifecycle.mjs @@ -34,7 +34,7 @@ const serverEndpoint = await quic.listen(mustCall(async (serverSession) => { serverDone.resolve(); serverSession.close(); -}), { sni: { '*': { keys, certs } } }); +}), { alpn: ['h3'], sni: { '*': { keys, certs } } }); strictEqual(serverEndpoint.busy, false); strictEqual(serverEndpoint.closing, false); @@ -53,6 +53,7 @@ ok(epStats.createdAt > 0n); // Connect with a client const clientSession = await quic.connect(serverEndpoint.address, { + alpn: 'h3', verifyPeer: 'manual', }); diff --git a/test/parallel/test-quic-sni-mismatch.mjs b/test/parallel/test-quic-sni-mismatch.mjs index dbb2de4c011a30..e27059222a8ece 100644 --- a/test/parallel/test-quic-sni-mismatch.mjs +++ b/test/parallel/test-quic-sni-mismatch.mjs @@ -5,7 +5,7 @@ // and no wildcard is configured. The handshake should fail with a // TLS alert (unrecognized_name). -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; @@ -24,21 +24,15 @@ const cert = readKey('agent1-cert.pem'); // Server only has an entry for 'specific.example.com', no wildcard. // Connections to any other hostname will be rejected at the TLS level. -const serverEndpoint = await listen(mustCall(async (serverSession) => { - await rejects(serverSession.opened, { - code: 'ERR_QUIC_TRANSPORT_ERROR', +// The handshake fails while the server processes the client's first +// flight, so the session is never surfaced to JS. +const serverEndpoint = await listen( + mustNotCall('server session must not be surfaced for a failed handshake'), + { + sni: { 'specific.example.com': { keys: [key], certs: [cert] } }, + alpn: ['quic-test'], + transportParams: { maxIdleTimeout: 1 }, }); - await rejects(serverSession.closed, { - code: 'ERR_QUIC_TRANSPORT_ERROR', - }); -}), { - sni: { 'specific.example.com': { keys: [key], certs: [cert] } }, - alpn: ['quic-test'], - transportParams: { maxIdleTimeout: 1 }, - onerror: mustCall((err) => { - strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR'); - }), -}); // Client connects with a different servername — no matching identity. const clientSession = await connect(serverEndpoint.address, { diff --git a/test/parallel/test-quic-stream-bidi-basic.mjs b/test/parallel/test-quic-stream-bidi-basic.mjs index de71890e888ac9..0cb86c2c07a061 100644 --- a/test/parallel/test-quic-stream-bidi-basic.mjs +++ b/test/parallel/test-quic-stream-bidi-basic.mjs @@ -50,7 +50,7 @@ const clientSession = await connect(serverEndpoint.address); await clientSession.opened; // Create a bidi stream with the message as the body. -// For DefaultApplication, the server's onstream fires when data arrives. +// For raw QUIC sessions, the server's onstream fires when data arrives. const stream = await clientSession.createBidirectionalStream({ body: body, }); diff --git a/test/parallel/test-quic-stream-destroy-emits-reset.mjs b/test/parallel/test-quic-stream-destroy-emits-reset.mjs index e565f5e9ff7b8f..192a885ea6a431 100644 --- a/test/parallel/test-quic-stream-destroy-emits-reset.mjs +++ b/test/parallel/test-quic-stream-destroy-emits-reset.mjs @@ -9,10 +9,10 @@ // idle timer fired. // // Verified by observing the server-side `onreset` callback. The wire -// code is the negotiated application's "internal error" code: for -// the test fixture's non-h3 ALPN (`quic-test`) the C++ -// DefaultApplication reports `1n`, which propagates to the server -// as `ERR_QUIC_APPLICATION_ERROR` exposing `errorCode === 1n`. +// code is the session's "internal error" code: with no application +// installed (the test fixture's raw `quic-test` ALPN) the native +// default is `1n`, which propagates to the server as +// `ERR_QUIC_APPLICATION_ERROR` exposing `errorCode === 1n`. import { hasQuic, skip, mustCall } from '../common/index.mjs'; import assert from 'node:assert'; @@ -31,7 +31,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => { serverSession.onstream = mustCall(async (stream) => { stream.onreset = mustCall((err) => { strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR'); - // The DefaultApplication's internal error code is 0x1n. + // The native (no application) internal error code is 0x1n. strictEqual(err.errorCode, 1n); serverResetSeen.resolve(); }); diff --git a/test/parallel/test-quic-stream-destroy-options-code.mjs b/test/parallel/test-quic-stream-destroy-options-code.mjs index b1fd70018d456b..0ec53c10910f10 100644 --- a/test/parallel/test-quic-stream-destroy-options-code.mjs +++ b/test/parallel/test-quic-stream-destroy-options-code.mjs @@ -5,8 +5,8 @@ // caller-supplied code is sent on RESET_STREAM (and STOP_SENDING for // the readable side) so the peer observes exactly that code. // -// For the test fixture's non-h3 ALPN (`quic-test`), the -// DefaultApplication's `internalErrorCode` is `0x1n`. Without +// For the test fixture's non-h3 ALPN (`quic-test`, a raw session +// with no application), the native `internalErrorCode` is `0x1n`. Without // `options.code`, a plain `Error` would result in the peer seeing // `0x1n`. With `options.code = 0x42n`, the peer must see `0x42n`. diff --git a/test/parallel/test-quic-stream-priority.mjs b/test/parallel/test-quic-stream-priority.mjs deleted file mode 100644 index bed1fd065fdfc9..00000000000000 --- a/test/parallel/test-quic-stream-priority.mjs +++ /dev/null @@ -1,96 +0,0 @@ -// Flags: --experimental-quic --no-warnings - -import { hasQuic, skip, mustCall } from '../common/index.mjs'; -import assert from 'node:assert'; -import * as fixtures from '../common/fixtures.mjs'; -const { readKey } = fixtures; - -const { rejects, strictEqual, throws } = assert; - -if (!hasQuic) { - skip('QUIC is not enabled'); -} - -const { listen, connect } = await import('node:quic'); -const { createPrivateKey } = await import('node:crypto'); - -const key = createPrivateKey(readKey('agent1-key.pem')); -const cert = readKey('agent1-cert.pem'); - -const serverEndpoint = await listen(mustCall(async (serverSession) => { - await serverSession.opened; - await serverSession.close(); -}), { - sni: { '*': { keys: [key], certs: [cert] } }, - alpn: ['quic-test'], -}); - -const clientSession = await connect(serverEndpoint.address, { - alpn: 'quic-test', - verifyPeer: 'manual', -}); -await clientSession.opened; - -// Collect stream.closed promises so we can await them all at the end. -// We must not await them inline because the server's CONNECTION_CLOSE -// arrives asynchronously and would put the session into a closing state, -// preventing subsequent createBidirectionalStream calls. -const streamClosedPromises = []; - -// Test 1: Priority getter returns null for non-HTTP/3 sessions. -// setPriority throws because the session doesn't support priority. -{ - const stream = await clientSession.createBidirectionalStream(); - streamClosedPromises.push(stream.closed); - strictEqual(stream.priority, null); - - throws( - () => stream.setPriority({ level: 'high', incremental: true }), - { code: 'ERR_INVALID_STATE' }, - ); -} - -// Test 2: Validation of createStream priority/incremental options -{ - await rejects( - clientSession.createBidirectionalStream({ priority: 'urgent' }), - { code: 'ERR_INVALID_ARG_VALUE' }, - ); - await rejects( - clientSession.createBidirectionalStream({ priority: 42 }), - { code: 'ERR_INVALID_ARG_VALUE' }, - ); - await rejects( - clientSession.createBidirectionalStream({ incremental: 'yes' }), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); - await rejects( - clientSession.createBidirectionalStream({ incremental: 1 }), - { code: 'ERR_INVALID_ARG_TYPE' }, - ); -} - -// Test 3: setPriority throws on non-H3 sessions regardless of arguments -{ - const stream = await clientSession.createBidirectionalStream(); - streamClosedPromises.push(stream.closed); - - throws( - () => stream.setPriority({ level: 'high' }), - { code: 'ERR_INVALID_STATE' }, - ); - throws( - () => stream.setPriority({ level: 'low', incremental: true }), - { code: 'ERR_INVALID_STATE' }, - ); - throws( - () => stream.setPriority(), - { code: 'ERR_INVALID_STATE' }, - ); -} - -// Wait for all streams to close (they close when the session closes -// in response to the server's CONNECTION_CLOSE). -await Promise.all(streamClosedPromises); -await clientSession.close(); -await serverEndpoint.close(); diff --git a/test/parallel/test-quic-stream-writer-fail-error-code.mjs b/test/parallel/test-quic-stream-writer-fail-error-code.mjs index ff3c18f69c5d03..23f1ad588dec8b 100644 --- a/test/parallel/test-quic-stream-writer-fail-error-code.mjs +++ b/test/parallel/test-quic-stream-writer-fail-error-code.mjs @@ -4,9 +4,9 @@ // application error code instead of the previously hard-coded 0n. // // Two cases are exercised against the test fixture's non-h3 ALPN -// (`quic-test`), which selects the C++ DefaultApplication and exposes -// `internalErrorCode === 0x1n` (NGTCP2_INTERNAL_ERROR) via session -// state: +// (`quic-test`), a raw session with no application installed, which +// exposes the native `internalErrorCode === 0x1n` +// (NGTCP2_INTERNAL_ERROR) via session state: // // 1. `writer.fail(plainError)` — peer receives RESET_STREAM with the // session's `internalErrorCode` (`0x1`), proving the hard-coded diff --git a/test/parallel/test-quic-zero-rtt.mjs b/test/parallel/test-quic-zero-rtt.mjs index ef1a345541045b..e979bb7f898bf2 100644 --- a/test/parallel/test-quic-zero-rtt.mjs +++ b/test/parallel/test-quic-zero-rtt.mjs @@ -90,15 +90,15 @@ const cs2 = await connect(serverEndpoint.address, { }); // Send data BEFORE the handshake completes — true 0-RTT. -const s2 = await cs2.createBidirectionalStream({ - body: encoder.encode('early data'), -}); +const s2 = await cs2.createBidirectionalStream(); +await s2.writer.write(encoder.encode('early data')); // Now wait for handshake completion. const info2 = await cs2.opened; strictEqual(info2.earlyDataAttempted, true); strictEqual(info2.earlyDataAccepted, true); +await s2.writer.end(); for await (const _ of s2) { /* drain */ } // eslint-disable-line no-unused-vars await s2.closed; diff --git a/test/parallel/test-require-resolve.js b/test/parallel/test-require-resolve.js index 44422706e186cc..049f90a68c9ddd 100644 --- a/test/parallel/test-require-resolve.js +++ b/test/parallel/test-require-resolve.js @@ -62,6 +62,8 @@ require(fixtures.path('resolve-paths', 'default', 'verify-paths.js')); builtinModules.forEach((mod) => { // TODO(@jasnell): Remove once node:quic is no longer flagged if (mod === 'node:quic') return; + // TODO: Remove once node:http3 is no longer flagged + if (mod === 'node:http3') return; // TODO: Remove once node:ffi is no longer flagged if (mod === 'node:ffi') return; // Remove once node:vfs is no longer flagged