From 42aa3f661209b72614af9435c1b1c06e153e8ba2 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 16 May 2026 09:20:31 -0700 Subject: [PATCH] quic: add applicationOptions to session Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/63267 Backport-PR-URL: https://github.com/nodejs/node/pull/64675 Reviewed-By: Matteo Collina --- doc/api/quic.md | 101 +++++++++++++---- lib/internal/quic/quic.js | 8 ++ src/quic/application.cc | 49 +++++++- src/quic/application.h | 4 + src/quic/bindingdata.cc | 12 +- src/quic/bindingdata.h | 4 + src/quic/http3.cc | 2 + src/quic/session.cc | 23 ++++ src/quic/session.h | 3 + src/quic/transportparams.cc | 80 ++++++------- .../test-quic-session-application-options.mjs | 105 ++++++++++++++++++ 11 files changed, 320 insertions(+), 71 deletions(-) create mode 100644 test/parallel/test-quic-session-application-options.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index 6f99c639b152..8d6925a5e170 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -724,6 +724,18 @@ 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. + ### `session.close([options])` + +* 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: {Object} +* Type: {quic.ApplicationOptions} -HTTP/3 application-specific options. These only apply when the negotiated -ALPN selects the HTTP/3 application (`'h3'`). - -* `maxHeaderPairs` {number} Maximum number of header name-value pairs - accepted per header block. Headers beyond this limit are silently - dropped. **Default:** `128` -* `maxHeaderLength` {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` -* `maxFieldSectionSize` {number} Maximum size of a compressed header - field section (QPACK). `0` means unlimited. **Default:** `0` -* `qpackMaxDTableCapacity` {number} QPACK dynamic table capacity in - bytes. Set to `0` to disable the dynamic table. **Default:** `4096` -* `qpackEncoderMaxDTableCapacity` {number} QPACK encoder maximum - dynamic table capacity. **Default:** `4096` -* `qpackBlockedStreams` {number} Maximum number of streams that can - be blocked waiting for QPACK dynamic table updates. - **Default:** `100` -* `enableConnectProtocol` {boolean} Enable the extended CONNECT - protocol (RFC 9220). **Default:** `false` -* `enableDatagrams` {boolean} Enable HTTP/3 datagrams (RFC 9297). - **Default:** `false` +Application-specific options. ```mjs const { listen } = await import('node:quic'); diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 4d2476cd77ab..2178c2673b63 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -2678,6 +2678,14 @@ 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; diff --git a/src/quic/application.cc b/src/quic/application.cc index 96ddf6c84cbf..c37b22c2bd32 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -19,9 +19,13 @@ 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; @@ -114,6 +118,44 @@ Maybe Session::Application_Options::From( return Just(options); } +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 {}; + } + return obj; +} + // ============================================================================ std::string Session::Application::StreamData::ToString() const { @@ -655,7 +697,10 @@ class DefaultApplication final : public Session::Application { // 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. - using Application::Application; // NOLINT + 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; @@ -827,6 +872,8 @@ class DefaultApplication final : public Session::Application { } } + Options options_; + Stream::Queue stream_queue_; }; diff --git a/src/quic/application.h b/src/quic/application.h index 59583b941b95..0df9b9f0a0e6 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -38,6 +38,10 @@ class Session::Application : public MemoryRetainer { Application(Session* session, const Options& options); 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. diff --git a/src/quic/bindingdata.cc b/src/quic/bindingdata.cc index a2a1921c087a..f029f0e8eb83 100644 --- a/src/quic/bindingdata.cc +++ b/src/quic/bindingdata.cc @@ -385,7 +385,17 @@ void BindingData::set_transport_params_template( Local BindingData::transport_params_template() const { return PersistentToLocal::Default(env()->isolate(), - transport_params_template_); + transport_params_template_); +} + +void BindingData::set_application_options_template( + Local tmpl) { + application_options_template_.Reset(env()->isolate(), tmpl); +} + +Local BindingData::application_options_template() const { + return PersistentToLocal::Default(env()->isolate(), + application_options_template_); } #define V(name, _) \ diff --git a/src/quic/bindingdata.h b/src/quic/bindingdata.h index 51d2035824bf..c3c5e9fc7834 100644 --- a/src/quic/bindingdata.h +++ b/src/quic/bindingdata.h @@ -306,6 +306,9 @@ 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; + #define V(name, _) \ void set_##name##_callback(v8::Local fn); \ v8::Local name##_callback() const; @@ -325,6 +328,7 @@ class BindingData final #undef V v8::Global transport_params_template_; + v8::Global application_options_template_; #define V(name, _) v8::Global name##_callback_; QUIC_JS_CALLBACKS(V) diff --git a/src/quic/http3.cc b/src/quic/http3.cc index da96b776668b..c9e249682f3a 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -157,6 +157,8 @@ class Http3ApplicationImpl final : public Session::Application { session->set_priority_supported(); } + const Options& options() const override { return options_; } + Session::Application::Type type() const override { return Session::Application::Type::HTTP3; } diff --git a/src/quic/session.cc b/src/quic/session.cc index 7a5cd6f70780..27cdecdc5d7c 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -193,6 +193,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) struct Session::State final { #define V(_, name, type) type name; @@ -1195,6 +1196,24 @@ struct Session::Impl final : public MemoryRetainer { args.GetReturnValue().Set(obj); } } + + JS_METHOD(ApplicationOptions) { + 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. + return args.GetReturnValue().SetUndefined(); + } + auto& options = session->application().options(); + if (options.ToObject(env).ToLocal(&obj)) { + args.GetReturnValue().Set(obj); + } + } // Internal ngtcp2 callbacks static int on_acknowledge_stream_data_offset(ngtcp2_conn* conn, @@ -2082,6 +2101,10 @@ TLSSession& Session::tls_session() const { return *tls_session_; } +bool Session::has_application() const { + return !is_destroyed() && impl_->application_ != nullptr; +} + Session::Application& Session::application() const { DCHECK(!is_destroyed()); DCHECK(impl_->application_); diff --git a/src/quic/session.h b/src/quic/session.h index 48383672f03a..9fb67fb918d8 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -91,6 +91,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { std::string ToString() const; + v8::MaybeLocal ToObject(Environment* env) const; + static const Application_Options kDefault; }; @@ -324,6 +326,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { uint32_t version() const; Endpoint& endpoint() const; TLSSession& tls_session() const; + bool has_application() const; Application& application() const; const Config& config() const; const Options& options() const; diff --git a/src/quic/transportparams.cc b/src/quic/transportparams.cc index 27d855cddf67..183ba973ac18 100644 --- a/src/quic/transportparams.cc +++ b/src/quic/transportparams.cc @@ -347,23 +347,23 @@ v8::MaybeLocal TransportParams::ToObject(Environment* env) const { auto& binding_data = BindingData::Get(env); auto tmpl = binding_data.transport_params_template(); static constexpr std::string_view names[] = { - "preferredAddressIpv4", - "preferredAddressIpv6", - "originalDCID", - "initialSCID", - "retrySCID", - "initialMaxStreamDataBidiLocal", - "initialMaxStreamDataBidiRemote", - "initialMaxStreamDataUni", - "initialMaxData", - "initialMaxStreamsBidi", - "initialMaxStreamsUni", - "maxIdleTimeout", - "activeConnectionIDLimit", - "ackDelayExponent", - "maxAckDelay", - "maxDatagramFrameSize", - "disableActiveMigration", + "preferredAddressIpv4", + "preferredAddressIpv6", + "originalDCID", + "initialSCID", + "retrySCID", + "initialMaxStreamDataBidiLocal", + "initialMaxStreamDataBidiRemote", + "initialMaxStreamDataUni", + "initialMaxData", + "initialMaxStreamsBidi", + "initialMaxStreamsUni", + "maxIdleTimeout", + "activeConnectionIDLimit", + "ackDelayExponent", + "maxAckDelay", + "maxDatagramFrameSize", + "disableActiveMigration", }; if (tmpl.IsEmpty()) { tmpl = DictionaryTemplate::New(env->isolate(), names); @@ -457,42 +457,30 @@ v8::MaybeLocal TransportParams::ToObject(Environment* env) const { values[kRetrySCIDIndex] = value; } - values[kInitialMaxStreamDataBidiLocalIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->initial_max_stream_data_bidi_local); - values[kInitialMaxStreamDataBidiRemoteIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->initial_max_stream_data_bidi_remote); - values[kInitialMaxStreamDataUniIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->initial_max_stream_data_uni); + values[kInitialMaxStreamDataBidiLocalIndex] = BigInt::NewFromUnsigned( + env->isolate(), ptr_->initial_max_stream_data_bidi_local); + values[kInitialMaxStreamDataBidiRemoteIndex] = BigInt::NewFromUnsigned( + env->isolate(), ptr_->initial_max_stream_data_bidi_remote); + values[kInitialMaxStreamDataUniIndex] = BigInt::NewFromUnsigned( + env->isolate(), ptr_->initial_max_stream_data_uni); values[kInitialMaxDataIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->initial_max_data); + BigInt::NewFromUnsigned(env->isolate(), ptr_->initial_max_data); values[kInitialMaxStreamsBidiIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->initial_max_streams_bidi); + BigInt::NewFromUnsigned(env->isolate(), ptr_->initial_max_streams_bidi); values[kInitialMaxStreamsUniIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->initial_max_streams_uni); - values[kMaxIdleTimeoutIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->max_idle_timeout / NGTCP2_SECONDS); - values[kActiveConnectionIDLimitIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->active_connection_id_limit); + BigInt::NewFromUnsigned(env->isolate(), ptr_->initial_max_streams_uni); + values[kMaxIdleTimeoutIndex] = BigInt::NewFromUnsigned( + env->isolate(), ptr_->max_idle_timeout / NGTCP2_SECONDS); + values[kActiveConnectionIDLimitIndex] = BigInt::NewFromUnsigned( + env->isolate(), ptr_->active_connection_id_limit); values[kAckDelayExponentIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->ack_delay_exponent); + BigInt::NewFromUnsigned(env->isolate(), ptr_->ack_delay_exponent); values[kMaxAckDelayIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->ack_delay_exponent); + BigInt::NewFromUnsigned(env->isolate(), ptr_->ack_delay_exponent); values[kMaxAckDelayIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->max_ack_delay); + BigInt::NewFromUnsigned(env->isolate(), ptr_->max_ack_delay); values[kMaxDatagramFrameSizeIndex] = - BigInt::NewFromUnsigned(env->isolate(), - ptr_->max_datagram_frame_size); + BigInt::NewFromUnsigned(env->isolate(), ptr_->max_datagram_frame_size); values[kDisableActiveMigrationIndex] = Boolean::New(env->isolate(), ptr_->disable_active_migration); } diff --git a/test/parallel/test-quic-session-application-options.mjs b/test/parallel/test-quic-session-application-options.mjs new file mode 100644 index 000000000000..1f5c9c092680 --- /dev/null +++ b/test/parallel/test-quic-session-application-options.mjs @@ -0,0 +1,105 @@ +// 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();