node/lib/internal/quic/quic.js
Tim Perry 04a17fe6f0
quic: expose QUIC certificates as JS X509Certificate, not raw handles
Signed-off-by: Tim Perry <pimterry@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/63191
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
2026-06-21 00:20:28 +02:00

5453 lines
175 KiB
JavaScript

'use strict';
// TODO(@jasnell) Temporarily ignoring c8 covrerage for this file while tests
// are still being developed.
/* c8 ignore start */
const {
ArrayFrom,
ArrayIsArray,
ArrayPrototypePush,
BigInt,
DataViewPrototypeGetByteLength,
ErrorCaptureStackTrace,
FunctionPrototypeBind,
Number,
ObjectDefineProperties,
ObjectKeys,
PromisePrototypeThen,
PromiseResolve,
PromiseWithResolvers,
SafeSet,
Symbol,
SymbolAsyncDispose,
SymbolAsyncIterator,
SymbolDispose,
SymbolIterator,
TypedArrayPrototypeGetByteLength,
Uint8Array,
} = primordials;
const {
getOptionValue,
} = require('internal/options');
// QUIC requires that Node.js be compiled with crypto support.
if (!process.features.quic || !getOptionValue('--experimental-quic')) {
return;
}
const { inspect } = require('internal/util/inspect');
const {
BlockList,
kHandle: kBlockListHandle,
} = require('internal/blocklist');
let debug = require('internal/util/debuglog').debuglog('quic', (fn) => {
debug = fn;
});
const {
Endpoint: Endpoint_,
setCallbacks,
// The constants to be exposed to end users for various options.
CC_ALGO_RENO_STR: CC_ALGO_RENO,
CC_ALGO_CUBIC_STR: CC_ALGO_CUBIC,
CC_ALGO_BBR_STR: CC_ALGO_BBR,
DEFAULT_CIPHERS,
DEFAULT_GROUPS,
// Internal constants for use by the implementation.
// These are not exposed to end users.
PREFERRED_ADDRESS_IGNORE: kPreferredAddressIgnore,
PREFERRED_ADDRESS_USE: kPreferredAddressUse,
DEFAULT_PREFERRED_ADDRESS_POLICY: kPreferredAddressDefault,
STREAM_DIRECTION_BIDIRECTIONAL: kStreamDirectionBidirectional,
STREAM_DIRECTION_UNIDIRECTIONAL: kStreamDirectionUnidirectional,
CLOSECONTEXT_CLOSE: kCloseContextClose,
CLOSECONTEXT_BIND_FAILURE: kCloseContextBindFailure,
CLOSECONTEXT_LISTEN_FAILURE: kCloseContextListenFailure,
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');
const {
isArrayBuffer,
isArrayBufferView,
isDataView,
isPromise,
isSharedArrayBuffer,
} = require('util/types');
const {
Buffer,
} = require('buffer');
const {
codes: {
ERR_ILLEGAL_CONSTRUCTOR,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_ARG_VALUE,
ERR_INVALID_STATE,
ERR_INVALID_THIS,
ERR_MISSING_ARGS,
ERR_OUT_OF_RANGE,
ERR_QUIC_CONNECTION_FAILED,
ERR_QUIC_ENDPOINT_CLOSED,
ERR_QUIC_OPEN_STREAM_FAILED,
ERR_QUIC_STREAM_ABORTED,
ERR_QUIC_STREAM_RESET,
ERR_QUIC_VERSION_NEGOTIATION_ERROR,
},
} = require('internal/errors');
const {
InternalSocketAddress,
SocketAddress,
kHandle: kSocketAddressHandle,
} = require('internal/socketaddress');
const {
createBlobReaderIterable,
isBlob,
kHandle: kBlobHandle,
} = require('internal/blob');
const {
drainableProtocol,
kValidatedSource,
} = require('internal/streams/iter/types');
const {
toUint8Array,
convertChunks,
} = require('internal/streams/iter/utils');
const {
from: streamFrom,
fromSync: streamFromSync,
} = require('internal/streams/iter/from');
const {
getKeyObjectHandle,
getKeyObjectType,
isKeyObject,
} = require('internal/crypto/keys');
const {
InternalX509Certificate,
} = require('internal/crypto/x509');
const {
FileHandle,
kHandle: kFileHandle,
kLocked: kFileLocked,
} = require('internal/fs/promises');
const {
validateAbortSignal,
validateBoolean,
validateFunction,
validateInteger,
validateObject,
validateOneOf,
validateString,
} = require('internal/validators');
const {
buildNgHeaderString,
assertValidPseudoHeader,
} = require('internal/http2/util');
const kEmptyObject = { __proto__: null };
const {
kAttachFileHandle,
kBlocked,
kConnect,
kDatagram,
kDatagramStatus,
kDrain,
kEarlyDataRejected,
kFinishClose,
kGoaway,
kHandshake,
kHandshakeCompleted,
kVerifyPeer,
kHeaders,
kOwner,
kRemoveSession,
kKeylog,
kListen,
kNewSession,
kQlog,
kRemoveStream,
kNewStream,
kNewToken,
kOrigin,
kStreamCallbacks,
kPathValidation,
kPrivateConstructor,
kReset,
kSendHeaders,
kSessionApplication,
kSessionTicket,
kTrailers,
kVersionNegotiation,
kInspect,
} = require('internal/quic/symbols');
const {
QuicEndpointStats,
QuicStreamStats,
QuicSessionStats,
kCreateDisconnected,
} = require('internal/quic/stats');
const {
QuicEndpointState,
QuicSessionState,
QuicStreamState,
} = require('internal/quic/state');
const assert = require('internal/assert');
const {
hasObserver,
startPerf,
stopPerf,
} = require('internal/perf/observe');
const kPerfEntry = Symbol('kPerfEntry');
const {
onEndpointCreatedChannel,
onEndpointListeningChannel,
onEndpointClosingChannel,
onEndpointClosedChannel,
onEndpointErrorChannel,
onEndpointBusyChangeChannel,
onEndpointClientSessionChannel,
onEndpointServerSessionChannel,
onSessionOpenStreamChannel,
onSessionReceivedStreamChannel,
onSessionSendDatagramChannel,
onSessionUpdateKeyChannel,
onSessionClosingChannel,
onSessionClosedChannel,
onSessionReceiveDatagramChannel,
onSessionReceiveDatagramStatusChannel,
onSessionPathValidationChannel,
onSessionNewTokenChannel,
onSessionApplicationChannel,
onSessionTicketChannel,
onSessionVersionNegotiationChannel,
onSessionOriginChannel,
onSessionHandshakeChannel,
onSessionGoawayChannel,
onSessionEarlyRejectedChannel,
onStreamClosedChannel,
onStreamHeadersChannel,
onStreamTrailersChannel,
onStreamInfoChannel,
onStreamResetChannel,
onStreamBlockedChannel,
onSessionErrorChannel,
onEndpointConnectChannel,
} = require('internal/quic/diagnostics');
const kNilDatagramId = 0n;
// 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.
const endpointRegistry = new SafeSet();
/**
* @typedef {import('../socketaddress.js').SocketAddress} SocketAddress
* @typedef {import('../crypto/keys.js').KeyObject} KeyObject
*/
/**
* @typedef {object} OpenStreamOptions
* @property {string|ArrayBuffer|SharedArrayBuffer|ArrayBufferView|Blob|
* 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.
*/
/**
* Provides the configuration options for a QuicEndpoint.
* @typedef {object} EndpointOptions
* @property {SocketAddress|string} [address] The local address to bind to
* @property {bigint|number} [addressLRUSize] The size of the address LRU cache
* @property {'reno'|'cubic'|'bbr'} [cc] The congestion control algorithm
* @property {boolean} [disableStatelessReset] When true, the endpoint will not send stateless resets
* @property {bigint|number} [idleTimeout] The default idle timeout for sessions on this endpoint
* @property {boolean} [ipv6Only] Use IPv6 only
* @property {boolean} [reusePort] Enable SO_REUSEPORT for multi-process load balancing
* @property {bigint|number} [maxConnectionsPerHost] The maximum number of connections per host
* @property {bigint|number} [maxConnectionsTotal] The maximum number of total connections
* @property {number} [retryRate] Global rate limit for retry packets (per second)
* @property {number} [retryBurst] Burst capacity for retry rate limiter
* @property {number} [statelessResetRate] Global rate limit for stateless reset packets (per second)
* @property {number} [statelessResetBurst] Burst capacity for stateless reset rate limiter
* @property {number} [versionNegotiationRate] Global rate limit for version negotiation packets (per second)
* @property {number} [versionNegotiationBurst] Burst capacity for version negotiation rate limiter
* @property {number} [immediateCloseRate] Global rate limit for immediate close packets (per second)
* @property {number} [immediateCloseBurst] Burst capacity for immediate close rate limiter
* @property {number} [sessionCreationRate] Per-host rate limit for session creation (per second)
* @property {number} [sessionCreationBurst] Per-host burst capacity for session creation rate limiter
* @property {net.BlockList} [blockList] Block list for filtering incoming packets by source address
* @property {'deny'|'allow'} [blockListPolicy='deny'] How to interpret the block list
* @property {ArrayBufferView} [resetTokenSecret] The reset token secret
* @property {bigint|number} [retryTokenExpiration] The retry token expiration
* @property {number} [rxDiagnosticLoss] The receive diagnostic loss probability (range 0.0-1.0)
* @property {bigint|number} [tokenExpiration] The token expiration
* @property {ArrayBufferView} [tokenSecret] The token secret
* @property {number} [txDiagnosticLoss] The transmit diagnostic loss probability (range 0.0-1.0)
* @property {number} [udpReceiveBufferSize] The UDP receive buffer size
* @property {number} [udpSendBufferSize] The UDP send buffer size
* @property {number} [udpTTL] The UDP TTL
* @property {boolean} [validateAddress] Validate the address using retry packets
*/
/**
* @typedef {object} TransportParams
* @property {SocketAddress} [preferredAddressIpv4] The preferred IPv4 address
* @property {SocketAddress} [preferredAddressIpv6] The preferred IPv6 address
* @property {bigint|number} [initialMaxStreamDataBidiLocal] The initial maximum stream data bidirectional local
* @property {bigint|number} [initialMaxStreamDataBidiRemote] The initial maximum stream data bidirectional remote
* @property {bigint|number} [initialMaxStreamDataUni] The initial maximum stream data unidirectional
* @property {bigint|number} [initialMaxData] The initial maximum data
* @property {bigint|number} [initialMaxStreamsBidi] The initial maximum streams bidirectional
* @property {bigint|number} [initialMaxStreamsUni] The initial maximum streams unidirectional
* @property {bigint|number} [maxIdleTimeout] The maximum idle timeout
* @property {bigint|number} [activeConnectionIDLimit] The active connection ID limit
* @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
*/
/**
* Per-identity TLS options. Used as the values in the `sni` map of
* `SessionOptions` for server endpoints.
* @typedef {object} IdentityOptions
* @property {KeyObject|KeyObject[]} keys The TLS private keys.
* @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} 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 {boolean} [authoritative] Whether to include this host name
* in HTTP/3 ORIGIN frames. **Default:** `true`. Wildcard (`'*'`)
* entries are always excluded regardless of this setting.
*/
/**
* @typedef {object} SessionOptions
* @property {EndpointOptions|QuicEndpoint} [endpoint] An endpoint to use.
* @property {boolean} [reuseEndpoint] When `true` (default), `connect()`
* will attempt to reuse an existing endpoint rather than create a new
* one. Has no effect for server sessions.
* @property {number} [version] The QUIC version
* @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).
* For client sessions, a single string. For server sessions, an array
* of protocol names in preference order.
* @property {string} [ciphers] The TLS ciphers
* @property {string} [groups] The TLS key-exchange groups
* @property {boolean} [keylog] Enable TLS key logging
* @property {boolean} [verifyClient] Verify the client certificate (server only)
* @property {boolean} [tlsTrace] Enable TLS tracing
* @property {boolean} [enableEarlyData] Enable 0-RTT early data.
* **Default:** `true`.
* @property {boolean} [rejectUnauthorized] Verify the peer certificate
* against the supplied CAs. **Default:** `true`.
* @property {boolean} [verifyPrivateKey] Verify the private key (client only)
* @property {KeyObject|KeyObject[]} [keys] The TLS private keys (client only)
* @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [certs] The TLS certificates (client only)
* @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [ca] The certificate authority
* @property {ArrayBuffer|ArrayBufferView|Array<ArrayBuffer|ArrayBufferView>} [crl] The certificate revocation list
* @property {{[key: string]: IdentityOptions}} [sni] Map of host names to
* per-identity TLS options for Server Name Indication. Required for
* server sessions. The special key `'*'` specifies the optional
* default/fallback identity.
* @property {boolean} [qlog] Enable qlog
* @property {ArrayBufferView} [sessionTicket] A session ticket from a
* 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 {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
* ngtcp2's built-in default of 333ms. Set lower for low-latency environments.
* @property {bigint|number} [keepAlive] The keep-alive timeout in milliseconds. When set,
* PING frames will be sent automatically to prevent idle timeout.
* @property {bigint|number} [maxStreamWindow] The maximum stream window
* @property {bigint|number} [maxWindow] The maximum connection window
* @property {bigint|number} [maxPayloadSize] The maximum payload size
* @property {bigint|number} [unacknowledgedPacketThreshold] The unacknowledged packet threshold
* @property {'reno'|'cubic'|'bbr'} [cc] The congestion control algorithm
* @property {'drop-oldest'|'drop-newest'} [datagramDropPolicy] The
* policy used when the pending datagram queue is full.
* **Default:** `'drop-oldest'`.
* @property {number} [drainingPeriodMultiplier] Multiplier applied to the
* draining period (3 * PTO) used by ngtcp2. Range `3..255`.
* **Default:** `3`.
* @property {bigint|number} [streamIdleTimeout] Time in ms before idle peer-initiated streams are destroyed
* @property {number} [maxDatagramSendAttempts] Maximum number of times a
* datagram is retried before being abandoned. Range `1..255`.
* **Default:** `5`.
* @property {OnSessionErrorCallback} [onerror] Session error callback.
* @property {OnStreamCallback} [onstream] Incoming stream callback.
* @property {OnDatagramCallback} [ondatagram] Incoming datagram callback.
* @property {OnDatagramStatusCallback} [ondatagramstatus] Outgoing datagram status callback.
* @property {OnPathValidationCallback} [onpathvalidation] Path validation callback.
* @property {OnSessionTicketCallback} [onsessionticket] New session-ticket callback.
* @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.
*/
/**
* @typedef {object} Datagrams
* @property {ReadableStream} readable The readable stream
* @property {WritableStream} writable The writable stream
*/
/**
* @typedef {object} Path
* @property {SocketAddress} local The local address
* @property {SocketAddress} remote The remote address
*/
/**
* @typedef {object} QuicSessionInfo
* @property {SocketAddress} local The local address
* @property {SocketAddress} remote The remote address
* @property {string} protocol The alpn protocol identifier negotiated for this session
* @property {string} servername The servername identifier for this session
* @property {string} cipher The cipher suite negotiated for this session
* @property {string} cipherVersion The version of the cipher suite negotiated for this session
* @property {string} [validationErrorReason] The reason the session failed validation (if any)
* @property {string} [validationErrorCode] The error code for the validation failure (if any)
*/
/**
* @typedef {object} QuicStreamDestroyOptions
* @property {bigint|number} [code] An explicit application
* error code to send on the resulting `RESET_STREAM` /
* `STOP_SENDING` frames. Numbers are coerced to `BigInt`. When
* omitted, the code is derived from `error` per the precedence
* above.
* @property {string} [reason] Optional human-readable reason.
* Accepted for symmetry with `session.close()` /
* `session.destroy()`; QUIC `RESET_STREAM` and `STOP_SENDING`
* 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
* @property {SocketAddress} remote The remote address for this path
*/
/**
* @typedef {object} SNIContextOptions
* @property {boolean} [replace] When `true`, the provided SNI context will replace
* the default context for the session. When `false` (default), the provided
* context will be merged with the default context, with precedence given to
* the provided context on any overlapping options.
*/
/**
* @typedef {object} ProcessSessionOptions
* @property {boolean} forServer true if processing options for a server session
* @property {string} addressFamily the address family to use for validating
*/
/**
* Called when the Endpoint receives a new server-side Session.
* @callback OnSessionCallback
* @this {QuicEndpoint}
* @param {QuicSession} session
* @returns {void}
*/
/**
* Called when a session is destroyed with an error.
* @callback OnSessionErrorCallback
* @this {QuicSession}
* @param {any} error
* @returns {void}
*/
/**
* @callback OnStreamCallback
* @this {QuicSession}
* @param {QuicStream} stream
* @returns {void}
*/
/**
* @callback OnDatagramCallback
* @this {QuicSession}
* @param {Uint8Array} datagram
* @param {boolean} early A datagram is early if it was received before the TLS handshake completed
* @returns {void}
*/
/**
* Called when the status of a previously sent datagram is reported.
* @callback OnDatagramStatusCallback
* @this {QuicSession}
* @param {bigint} id The datagram id
* @param {'acknowledged'|'lost'|'abandoned'} status
* @returns {void}
*/
/**
* Called when QUIC path validation completes (or fails).
* @callback OnPathValidationCallback
* @this {QuicSession}
* @param {'success'|'failure'|'aborted'} result
* @param {SocketAddress} newLocalAddress
* @param {SocketAddress} newRemoteAddress
* @param {SocketAddress|null} oldLocalAddress
* @param {SocketAddress|null} oldRemoteAddress
* @param {boolean} [preferredAddress] `true` if the validation was triggered
* by a preferred-address migration on the client side.
* @returns {void}
*/
/**
* @callback OnApplicationCallback
* @this {QuicSession}
* @param {ApplicationOptions} applicationoptions
* @returns {void}
*/
/**
* @callback OnSessionTicketCallback
* @this {QuicSession}
* @param {object} ticket
* @returns {void}
*/
/**
* Called when the server responds with a Version Negotiation packet.
* The session is destroyed immediately after this returns.
* @callback OnVersionNegotiationCallback
* @this {QuicSession}
* @param {number} version The QUIC version configured for this session
* @param {number[]} requestedVersions The versions advertised by the server
* @param {number[]} supportedVersions A `[minVersion, maxVersion]` pair
* @returns {void}
*/
/**
* Called when the TLS handshake completes successfully.
* @callback OnHandshakeCallback
* @this {QuicSession}
* @param {string} sni
* @param {string} alpn
* @param {string} cipher
* @param {string} cipherVersion
* @param {string} [validationErrorReason]
* @param {number} [validationErrorCode]
* @param {boolean} earlyDataAttempted
* @param {boolean} earlyDataAccepted
* @returns {void}
*/
/**
* Called when the server issues a NEW_TOKEN frame to the client.
* @callback OnNewTokenCallback
* @this {QuicSession}
* @param {Buffer} token The opaque token data
* @param {SocketAddress} address The remote server address
* @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`.
* @callback OnKeylogCallback
* @this {QuicSession}
* @param {string} line A single NSS Key Log Format line, including trailing newline.
* @returns {void}
*/
/**
* Called when qlog diagnostic data is available. Only fires when
* `sessionOptions.qlog` is `true`.
* @callback OnQlogCallback
* @this {QuicSession}
* @param {string} data A chunk of JSON-SEQ formatted qlog data
* @param {boolean} fin `true` if this is the final qlog chunk for the session.
* @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}
* @returns {void}
*/
/**
* @callback OnStreamErrorCallback
* @this {QuicStream}
* @param {any} error
* @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
/**
* Called when the QuicEndpoint C++ handle has closed and we need to finish
* cleaning up the JS side.
* @param {number} context Identifies the reason the endpoint was closed.
* @param {number} status If context indicates an error, provides the error code.
*/
onEndpointClose(context, status) {
debug('endpoint close callback', status);
this[kOwner][kFinishClose](context, status);
},
/**
* Called when the QuicEndpoint C++ handle receives a new server-side session
* @param {object} session The QuicSession C++ handle
*/
onSessionNew(session) {
debug('new server session callback', this[kOwner], session);
this[kOwner][kNewSession](session);
},
// QuicSession callbacks
/**
* Called when the underlying session C++ handle is closed either normally
* or with an error.
* @param {number} errorType
* @param {number} code
* @param {string} [reason]
* @param {string} [errorName] Decoded TLS alert name when `code` is a
* CRYPTO_ERROR; otherwise undefined.
*/
onSessionClose(errorType, code, reason, errorName) {
debug('session close callback', errorType, code, reason, errorName);
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
* @param {boolean} early
*/
onSessionDatagram(uint8Array, early) {
debug('session datagram callback', TypedArrayPrototypeGetByteLength(uint8Array), early);
this[kOwner][kDatagram](uint8Array, early);
},
/**
* Called when the status of a datagram is received.
* @param {bigint} id
* @param {'lost' | 'acknowledged'} status
*/
onSessionDatagramStatus(id, status) {
debug('session datagram status callback', id, status);
this[kOwner][kDatagramStatus](id, status);
},
/**
* Called when the session handshake completes.
* @param {string} servername
* @param {string} protocol
* @param {string} cipher
* @param {string} cipherVersion
* @param {string} validationErrorReason
* @param {number} validationErrorCode
* @param {boolean} earlyDataAttempted
* @param {boolean} earlyDataAccepted
*/
onSessionHandshake(servername, protocol, cipher, cipherVersion,
validationErrorReason,
validationErrorCode,
earlyDataAttempted,
earlyDataAccepted) {
debug('session handshake callback', servername, protocol, cipher, cipherVersion,
validationErrorReason, validationErrorCode,
earlyDataAttempted, earlyDataAccepted);
this[kOwner][kHandshake](servername, protocol, cipher, cipherVersion,
validationErrorReason, validationErrorCode,
earlyDataAttempted, earlyDataAccepted);
},
/**
* Called when the session path validation completes.
* @param {'aborted'|'failure'|'success'} result
* @param {SocketAddress} newLocalAddress
* @param {SocketAddress} newRemoteAddress
* @param {SocketAddress} oldLocalAddress
* @param {SocketAddress} oldRemoteAddress
* @param {boolean} preferredAddress
*/
onSessionPathValidation(result, newLocalAddress, newRemoteAddress,
oldLocalAddress, oldRemoteAddress, preferredAddress) {
debug('session path validation callback', this[kOwner]);
this[kOwner][kPathValidation](result, newLocalAddress, newRemoteAddress,
oldLocalAddress, oldRemoteAddress,
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
*/
onSessionTicket(ticket) {
debug('session ticket callback', this[kOwner]);
this[kOwner][kSessionTicket](ticket);
},
/**
* Called when the client receives a NEW_TOKEN frame from the server.
* The token can be used for future connections to the same server
* address to skip address validation.
* @param {Buffer} token The opaque token data
* @param {SocketAddress} address The remote server address
*/
onSessionNewToken(token, address) {
debug('session new token callback', this[kOwner]);
this[kOwner][kNewToken](token, address);
},
/**
* Called when the server rejects 0-RTT early data. All streams
* opened during the 0-RTT phase have been destroyed. The
* application should re-open streams if needed.
*/
onSessionEarlyDataRejected() {
debug('session early data rejected callback', this[kOwner]);
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
* @param {number[]} requestedVersions
* @param {number[]} supportedVersions
*/
onSessionVersionNegotiation(version,
requestedVersions,
supportedVersions) {
debug('session version negotiation callback', version, requestedVersions, supportedVersions,
this[kOwner]);
this[kOwner][kVersionNegotiation](version, requestedVersions, supportedVersions);
// Note that immediately following a version negotiation event, the
// session will be destroyed.
},
onSessionKeyLog(line) {
debug('session key log callback', line, this[kOwner]);
this[kOwner][kKeylog](line);
},
onSessionQlog(data, fin) {
if (this[kOwner] === undefined) {
// Qlog data can arrive during ngtcp2_conn creation, before the
// QuicSession JS wrapper exists. Cache until the wrapper is ready.
this._pendingQlog ??= [];
this._pendingQlog.push(data, fin);
return;
}
debug('session qlog callback', this[kOwner]);
this[kOwner][kQlog](data, fin);
},
/**
* Called when a new stream has been received for the session
* @param {object} stream The QuicStream C++ handle
* @param {number} direction The stream direction (0 == bidi, 1 == uni)
*/
onStreamCreated(stream, direction) {
const session = this[kOwner];
// The event is ignored and the stream destroyed if the session has been destroyed.
debug('stream created callback', session, direction);
if (session.destroyed) {
stream.destroy();
return;
};
session[kNewStream](stream, direction);
},
// QuicStream callbacks
onStreamBlocked() {
debug('stream blocked callback', this[kOwner]);
// Called when the stream C++ handle has been blocked by flow control.
this[kOwner][kBlocked]();
},
onStreamDrain() {
// Called when the stream's outbound buffer has capacity for more data.
debug('stream drain callback', this[kOwner]);
this[kOwner][kDrain]();
},
onStreamClose(error) {
// Called when the stream C++ handle has been closed. The error is
// either undefined (clean close) or a raw array [type, code, reason]
// from QuicError::ToV8Value. Convert to a proper Node.js Error.
if (error !== undefined) {
error = convertQuicError(error);
} else if (this[kOwner] && !this[kOwner].destroyed) {
// The stream is closing cleanly, but it may have been reset by the
// peer (ReceiveStreamReset) or locally (resetStream). The C++ side
// records the reset code in state.resetCode. If set, surface the
// reset as the close error so stream.closed rejects -- the reset
// was an abnormal termination even if the session closed cleanly.
const resetCode = getQuicStreamState(this[kOwner]).resetCode;
if (resetCode !== undefined && resetCode > 0n) {
error = makeQuicError(
'ERR_QUIC_APPLICATION_ERROR',
'QUIC application error',
'application',
resetCode,
`stream reset with code ${resetCode}`);
}
}
debug(`stream ${this[kOwner].id} closed callback with error: ${error}`);
this[kOwner][kFinishClose](error);
},
onStreamReset(error) {
// Called when the stream C++ handle has received a stream reset.
if (error !== undefined) {
error = convertQuicError(error);
}
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) {
if (privateSymbol !== kPrivateConstructor) {
throw new ERR_ILLEGAL_CONSTRUCTOR();
}
}
// QUIC error codes are 62-bit varints (RFC 9000 section 16). The
// maximum representable code is 2**62 - 1.
const kMaxQuicErrorCode = (1n << 62n) - 1n;
/**
* An Error subclass that carries an explicit numeric QUIC error code.
* Use this when destroying a stream or aborting an outbound writer to
* communicate a specific application-protocol-defined error code to
* the peer. When a `QuicError` is supplied, the QUIC stack uses
* `errorCode` as the wire code for the resulting RESET_STREAM /
* STOP_SENDING / CONNECTION_CLOSE frame; otherwise the negotiated
* application's "internal error" code is used (see
* `QuicSessionState.internalErrorCode`).
*
* The Node.js error code (`error.code`) defaults to
* `'ERR_QUIC_STREAM_ABORTED'` but can be overridden via
* `options.code`. The numeric QUIC code lives on the separate
* `errorCode` property to avoid colliding with Node.js's convention
* that `error.code` is a string.
*/
class QuicError extends Error {
/** @type {bigint} */
#errorCode;
/** @type {'transport' | 'application'} */
#type;
static isQuicError(val) {
return val != null && typeof val === 'object' && #errorCode in val;
}
/**
* @param {string} message
* @param {object} options
* @param {bigint|number} options.errorCode The numeric QUIC error
* code. Numbers are coerced to BigInt. Must be a non-negative
* 62-bit unsigned varint
* (`0n <= errorCode <= 2n ** 62n - 1n`).
* @param {string} [options.code] The Node.js-style error code
* string assigned to `error.code`. Defaults to
* `'ERR_QUIC_STREAM_ABORTED'`.
* @param {'transport'|'application'} [options.type] Whether the
* code is a transport-layer code (defined by RFC 9000) or an
* application-layer code (defined by the negotiated ALPN, e.g.
* RFC 9114 for HTTP/3). Defaults to `'application'`. Stream
* resets always carry application codes; this option is exposed
* for use sites that may target either layer.
*/
constructor(message, options = kEmptyObject) {
validateString(message, 'message');
validateObject(options, 'options');
const {
errorCode,
code = 'ERR_QUIC_STREAM_ABORTED',
type = 'application',
} = options;
if (errorCode === undefined) {
throw new ERR_MISSING_ARGS('options.errorCode');
}
if (typeof errorCode !== 'bigint' && typeof errorCode !== 'number') {
throw new ERR_INVALID_ARG_TYPE('options.errorCode',
['bigint', 'number'], errorCode);
}
validateString(code, 'options.code');
validateOneOf(type, 'options.type', ['transport', 'application']);
const numericCode = BigInt(errorCode);
if (numericCode < 0n || numericCode > kMaxQuicErrorCode) {
throw new ERR_OUT_OF_RANGE('options.errorCode',
`>= 0 and <= ${kMaxQuicErrorCode}`,
errorCode);
}
super(message);
this.code = code;
this.#errorCode = numericCode;
this.#type = type;
}
/** @type {bigint} */
get errorCode() {
return this.#errorCode;
}
/** @type {'transport' | 'application'} */
get type() {
return this.#type;
}
}
// Build the human-readable message for an ERR_QUIC_TRANSPORT_ERROR or
// ERR_QUIC_APPLICATION_ERROR. `errorName` is the symbolic name for
// the wire code when known: either the OpenSSL-decoded TLS alert
// (CRYPTO_ERROR; 0x100..0x1ff) or one of the named transport codes
// from RFC 9000 (e.g. PROTOCOL_VIOLATION). Otherwise undefined.
// `reason` is the peer-supplied UTF-8 reason string from the
// CONNECTION_CLOSE / RESET_STREAM frame, often empty.
function quicErrorMessage(prefix, errorCode, reason, errorName) {
let msg = `${prefix} `;
msg += errorName ? `${errorName} (${errorCode})` : `${errorCode}`;
if (reason) msg += `: ${reason}`;
return msg;
}
function makeQuicError(code, prefix, type, errorCode, reason, errorName) {
const err = new QuicError(
quicErrorMessage(prefix, errorCode, reason, errorName),
{ errorCode, code, type });
ErrorCaptureStackTrace(err, makeQuicError);
if (reason) err.reason = reason;
if (errorName) err.errorName = errorName;
return err;
}
function convertQuicError(error) {
const type = error[0];
const code = error[1];
const reason = error[2];
const errorName = error[3];
switch (type) {
case 'transport':
return makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
'QUIC transport error',
'transport', code, reason, errorName);
case 'application':
return makeQuicError('ERR_QUIC_APPLICATION_ERROR',
'QUIC application error',
'application', code, reason, errorName);
case 'version_negotiation':
return new ERR_QUIC_VERSION_NEGOTIATION_ERROR();
default:
return makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
'QUIC transport error',
'transport', code, reason, errorName);
}
}
// Convert a JavaScript error into close options suitable for
// `session.close()` / `session.destroy(error, options)`. The returned
// shape is `{ code, type, reason }` matching what `validateCloseOptions`
// expects (and what the native side reads via `MaybeSetCloseError`).
//
// Used so that destroying a session with an error actually emits a
// CONNECTION_CLOSE frame on the wire, instead of dropping the connection
// silently and leaving the peer waiting on its idle timer.
//
// Returns `undefined` when no error was supplied (caller falls back to
// a clean / silent close).
function errorToCloseOptions(error) {
if (error === undefined || error === null) return undefined;
// Generic mapping for now: any error becomes a transport-level
// INTERNAL_ERROR (NGTCP2_INTERNAL_ERROR == 0x1) with the original
// error message used as the human-readable reason. Future work could
// detect specific `ERR_QUIC_*` subclasses and round-trip their
// original code/type back onto the wire.
const reason = typeof error === 'object' && error !== null && error.message ?
`${error.message}` :
`${error}`;
return { code: 0x1n, type: 'transport', reason };
}
/**
* Safely invoke a user-supplied callback. If the callback throws
* synchronously, the owning object is destroyed with the error. If the
* callback returns a promise that rejects, the rejection is caught and the
* owning object is destroyed. Sync callbacks that do not throw incur no
* promise allocation overhead.
* @param {Function} fn The callback to invoke.
* @param {object} owner The QuicSession or QuicStream that owns the callback.
* @param {...any} args Arguments forwarded to the callback.
*/
function safeCallbackInvoke(fn, owner, ...args) {
try {
const result = fn(...args, owner);
if (isPromise(result)) {
// Block body - do NOT return the result of `owner.destroy(err)`.
// For some owners (e.g. `QuicEndpoint`), `destroy(err)` returns the
// owner's `closed` promise which itself eventually rejects with
// the same error. If we let that propagate through the `.then()`
// chain promise, nobody is awaiting that chain and we surface the
// rejection as unhandled.
PromisePrototypeThen(result, undefined, (err) => {
owner.destroy(err);
});
}
} catch (err) {
owner.destroy(err);
}
}
/**
* Invoke an onerror callback. If the callback itself throws synchronously
* or returns a promise that rejects, a SuppressedError wrapping both the
* onerror failure and the original error is surfaced as an uncaught exception.
* @param {Function} fn The onerror callback.
* @param {any} error The original error that triggered destruction.
*/
function invokeOnerror(fn, error) {
try {
const result = fn(error);
if (isPromise(result)) {
PromisePrototypeThen(result, undefined, (err) => {
process.nextTick(() => {
// eslint-disable-next-line no-restricted-syntax
throw new SuppressedError(err, error, err?.message);
});
});
}
} catch (err) {
process.nextTick(() => {
// eslint-disable-next-line no-restricted-syntax
throw new SuppressedError(err, error, err?.message);
});
}
}
function validateBody(body) {
if (body === undefined) return body;
// ArrayBuffers, SharedArrayBuffers, and ArrayBufferViews are passed
// through to the C++ layer which copies the bytes into its own
// BackingStore. Callers can therefore safely reuse or mutate their
// input buffers after the call returns. Callers that want to ensure
// their buffer cannot be mutated after handing it off (for example,
// when sharing the source with another async consumer) can call
// ArrayBuffer.prototype.transfer() themselves before passing the
// buffer.
if (isArrayBuffer(body) ||
isSharedArrayBuffer(body) ||
isArrayBufferView(body)) {
return body;
}
if (isBlob(body)) return body[kBlobHandle];
// Strings are encoded as UTF-8.
if (typeof body === 'string') {
return Buffer.from(body, 'utf8');
}
// FileHandle -- lock it and pass the C++ handle to GetDataQueueFromSource
// which creates an fd-backed DataQueue entry from the file path.
if (FileHandle.isFileHandle(body)) {
if (body[kFileLocked]) {
throw new ERR_INVALID_STATE('FileHandle is locked');
}
body[kFileLocked] = true;
return body[kFileHandle];
}
throw new ERR_INVALID_ARG_TYPE('options.body', [
'string',
'ArrayBuffer',
'ArrayBufferView',
'Blob',
'FileHandle',
], 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.
* @param {QuicSession} session
* @param {object} cbs
*/
function applyCallbacks(session, cbs) {
if (cbs.onerror) session.onerror = cbs.onerror;
if (cbs.onstream) session.onstream = cbs.onstream;
if (cbs.ondatagram) session.ondatagram = cbs.ondatagram;
if (cbs.ondatagramstatus) session.ondatagramstatus = cbs.ondatagramstatus;
if (cbs.onpathvalidation) session.onpathvalidation = cbs.onpathvalidation;
if (cbs.onsessionticket) session.onsessionticket = cbs.onsessionticket;
if (cbs.onversionnegotiation) session.onversionnegotiation = cbs.onversionnegotiation;
if (cbs.onhandshake) session.onhandshake = cbs.onhandshake;
if (cbs.onnewtoken) session.onnewtoken = cbs.onnewtoken;
if (cbs.onearlyrejected) session.onearlyrejected = cbs.onearlyrejected;
if (cbs.onorigin) session.onorigin = cbs.onorigin;
if (cbs.ongoaway) session.ongoaway = cbs.ongoaway;
if (cbs.onkeylog) session.onkeylog = cbs.onkeylog;
if (cbs.onqlog) session.onqlog = cbs.onqlog;
if (cbs.onapplication) session.onapplication = cbs.onapplication;
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,
};
}
}
/**
* Configures the outbound data source for a stream. Detects the source
* type and calls the appropriate C++ method.
* @param {object} handle The C++ stream handle
* @param {QuicStream} stream The JS stream object
* @param {any} body The body source
*/
const kDefaultHighWaterMark = 65536;
const kDefaultMaxPendingDatagrams = 128;
function configureOutbound(handle, stream, body) {
// body: null - close writable side immediately (FIN)
if (body === null) {
handle.initStreamingSource();
handle.endWrite();
return;
}
// Handle Promise - await and recurse. Native promises auto-flatten,
// so the resolved value will never itself be a promise.
if (isPromise(body)) {
PromisePrototypeThen(
body,
(resolved) => configureOutbound(handle, stream, resolved),
(err) => {
if (!stream.destroyed) {
stream.destroy(err);
}
},
);
return;
}
// Tier: One-shot - string (checked before sync iterable since
// strings are iterable but we want the one-shot path).
// Buffer.from may return a pooled buffer whose ArrayBuffer cannot
// be transferred, so run it through validateBody which copies when
// the buffer is a partial view of a larger ArrayBuffer.
if (typeof body === 'string') {
handle.attachSource(validateBody(Buffer.from(body, 'utf8')));
return;
}
// Tier: One-shot - FileHandle. The C++ layer creates an fd-backed
// DataQueue entry from the file path. The FileHandle is locked to
// prevent concurrent use and closed automatically when the stream
// finishes.
if (FileHandle.isFileHandle(body)) {
if (body[kFileLocked]) {
throw new ERR_INVALID_STATE('FileHandle is locked');
}
body[kFileLocked] = true;
handle.attachSource(body[kFileHandle]);
return;
}
// Tier: One-shot - ArrayBuffer, SharedArrayBuffer, TypedArray,
// DataView, Blob. validateBody handles transfer-vs-copy logic,
// SharedArrayBuffer copying, and partial view safety.
if (isArrayBuffer(body) || isSharedArrayBuffer(body) ||
isArrayBufferView(body) || isBlob(body)) {
handle.attachSource(validateBody(body));
return;
}
// Tier: Streaming - AsyncIterable (ReadableStream, stream.Readable,
// async generators, etc.). Checked before sync iterable because some
// objects implement both protocols and we prefer async.
if (isAsyncIterable(body)) {
consumeAsyncSource(handle, stream, body);
return;
}
// Tier: Sync iterable - consumed synchronously
if (isSyncIterable(body)) {
consumeSyncSource(handle, stream, body);
return;
}
throw new ERR_INVALID_ARG_TYPE(
'body',
['string', 'ArrayBuffer', 'SharedArrayBuffer', 'TypedArray',
'Blob', 'Iterable', 'AsyncIterable', 'Promise', 'null'],
body,
);
}
// Sets the high water mark and initial writeDesiredSize for a streaming
// outbound source. Called after handle.initStreamingSource() for both
// body-source and writer paths. One-shot body sources (string, Uint8Array,
// Blob, FileHandle, etc.) do not use this -- they go through attachSource
// and are not subject to backpressure.
function initStreamingBackpressure(stream) {
const state = getQuicStreamState(stream);
// Only set defaults if the user hasn't already configured them
// (e.g., via createBidirectionalStream({ highWaterMark: N })).
if (state.highWaterMark === 0) {
state.highWaterMark = kDefaultHighWaterMark;
}
if (state.writeDesiredSize === 0) {
state.writeDesiredSize = state.highWaterMark;
}
}
// Waits for the stream's drain callback to fire, indicating the
// outbound has capacity for more data.
function waitForDrain(stream) {
const { promise, resolve } = PromiseWithResolvers();
const prevDrain = stream[kDrain];
stream[kDrain] = () => {
stream[kDrain] = prevDrain;
resolve();
};
return promise;
}
// Writes a batch to the handle, awaiting drain if backpressured.
// Returns true if the stream was destroyed during the wait.
// Only waits when writeDesiredSize is 0 (no capacity at all).
// When there is any capacity, the write proceeds even if the batch
// is larger -- the C++ side buffers the data and writeDesiredSize
// drops toward 0, letting the normal drain mechanism take over.
async function writeBatchWithDrain(handle, stream, batch) {
const state = getQuicStreamState(stream);
if (state.writeDesiredSize === 0) {
await waitForDrain(stream);
if (stream.destroyed) return true;
}
// Write the batch. The return value is the total queued byte count
// on success, or undefined on failure (e.g., DataQueue append
// rejected). Guard against silent data loss.
const result = handle.write(batch);
if (result === undefined) {
if (!stream.destroyed) {
stream.destroy(new ERR_INVALID_STATE('Stream write failed'));
}
return true;
}
return false;
}
async function consumeAsyncSource(handle, stream, source) {
handle.initStreamingSource();
initStreamingBackpressure(stream);
try {
// Normalize to AsyncIterable<Uint8Array[]>
const normalized = streamFrom(source);
for await (const batch of normalized) {
if (stream.destroyed) return;
if (await writeBatchWithDrain(handle, stream, batch)) return;
}
handle.endWrite();
} catch (err) {
if (!stream.destroyed) {
stream.destroy(err);
} else {
throw err;
}
}
}
async function consumeSyncSource(handle, stream, source) {
handle.initStreamingSource();
initStreamingBackpressure(stream);
// Normalize to Iterable<Uint8Array[]>. Manually iterate so we can
// pause between next() calls when backpressure hits.
const normalized = streamFromSync(source);
const iter = normalized[SymbolIterator]();
try {
while (true) {
if (stream.destroyed) return;
const { value: batch, done } = iter.next();
if (done) break;
if (await writeBatchWithDrain(handle, stream, batch)) return;
}
handle.endWrite();
} catch (err) {
if (!stream.destroyed) {
stream.destroy(err);
} else {
// If the stream is already destroyed, rethrow the error to avoid
// silently swallowing it. Tho in practice this shouldn't happen.
throw err;
}
}
}
function isAsyncIterable(obj) {
return obj != null && typeof obj[SymbolAsyncIterator] === 'function';
}
function isSyncIterable(obj) {
return obj != null && typeof obj[SymbolIterator] === 'function';
}
// Functions used specifically for internal or assertion purposes only.
let getQuicStreamState;
let getQuicSessionState;
let getQuicEndpointState;
let assertIsQuicEndpoint;
let assertIsQuicStream;
let assertIsQuicSession;
let assertHeadersSupported;
let assertEndpointNotClosedOrClosing;
let assertEndpointIsNotBusy;
let isQuicStream;
let isQuicSession;
let isQuicEndpoint;
function maybeGetCloseError(context, status, pendingError) {
switch (context) {
case kCloseContextClose: {
return pendingError;
}
case kCloseContextBindFailure: {
return new ERR_QUIC_ENDPOINT_CLOSED('Bind failure', status);
}
case kCloseContextListenFailure: {
return new ERR_QUIC_ENDPOINT_CLOSED('Listen failure', status);
}
case kCloseContextReceiveFailure: {
return new ERR_QUIC_ENDPOINT_CLOSED('Receive failure', status);
}
case kCloseContextSendFailure: {
return new ERR_QUIC_ENDPOINT_CLOSED('Send failure', status);
}
case kCloseContextStartFailure: {
return new ERR_QUIC_ENDPOINT_CLOSED('Start failure', status);
}
}
// Otherwise return undefined.
}
class QuicStream {
#handle;
#inner = {
__proto__: null,
session: undefined,
direction: undefined,
isLocal: false,
state: undefined,
stats: undefined,
pendingClose: undefined,
reader: undefined,
destroying: false,
iteratorLocked: false,
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 {
isQuicStream = function(val) {
return val != null && typeof val === 'object' && #handle in val;
};
assertIsQuicStream = function(val) {
if (!isQuicStream(val)) {
throw new ERR_INVALID_THIS('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;
};
}
/**
* @param {symbol} privateSymbol
* @param {object} handle
* @param {QuicSession} session
* @param {number} direction
* @param {boolean} [isLocal]
*/
constructor(privateSymbol, handle, session, direction, isLocal) {
assertPrivateSymbol(privateSymbol);
this.#handle = handle;
handle[kOwner] = this;
const inner = this.#inner;
inner.session = session;
inner.direction = direction;
inner.isLocal = isLocal;
inner.state = new QuicStreamState(
kPrivateConstructor, handle.state, handle.stateByteOffset);
if (hasObserver('quic')) {
startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicStream' });
}
debug('stream created');
}
get [kValidatedSource]() { return true; }
/**
* Returns an AsyncIterator that yields Uint8Array[] batches of
* incoming data. Only one iterator can be obtained per stream.
* Non-readable streams return an immediately-finished iterator.
* @yields {Uint8Array[]}
*/
async *[SymbolAsyncIterator]() {
assertIsQuicStream(this);
const inner = this.#inner;
if (inner.iteratorLocked) {
throw new ERR_INVALID_STATE('Stream is already being read');
}
inner.iteratorLocked = true;
inner.reader ??= this.#handle?.getReader();
// Non-readable stream (outbound-only unidirectional, or closed)
if (!inner.reader) return;
yield* createBlobReaderIterable(inner.reader, {
getReadError: () => {
// The read side ends for one of three reasons:
// * Clean FIN received from the peer (state.finReceived
// === true). The iterator stops without calling this;
// fall through to the generic state error if it does.
// * Peer sent us a RESET_STREAM. The C++ side records the
// code in state.resetCode regardless of whether the JS
// onreset handler was attached. state.finReceived stays
// false because no FIN was seen.
// * We aborted locally via stream.resetStream() or
// stream.stopSending(). Both paths run EndReadable in
// C++, setting state.readEnded without setting
// state.finReceived. There is no peer code to surface.
if (inner.state.readEnded && !inner.state.finReceived) {
const peerResetCode = inner.state.resetCode;
if (peerResetCode !== undefined && peerResetCode > 0n) {
return new ERR_QUIC_STREAM_RESET(Number(peerResetCode));
}
return new ERR_QUIC_STREAM_ABORTED(
'Stream aborted before FIN was received');
}
return new ERR_INVALID_STATE('The stream is not readable');
},
});
}
/**
* True if the stream is still pending (i.e. it has not yet been opened
* and assigned an ID).
* @type {boolean}
*/
get pending() {
assertIsQuicStream(this);
return this.#inner.state.pending;
}
/**
* True if any data on this stream was received as 0-RTT (early data)
* before the TLS handshake completed. Early data is less secure and
* could be replayed by an attacker.
* @type {boolean}
*/
get early() {
assertIsQuicStream(this);
return this.#inner.state.early;
}
/**
* The high water mark for write backpressure. When the total queued
* outbound bytes exceeds this value, writeSync returns false and
* desiredSize drops to 0. Default is 65536 (64KB).
* @type {number}
*/
get highWaterMark() {
assertIsQuicStream(this);
return this.#inner.state.highWaterMark;
}
set highWaterMark(val) {
assertIsQuicStream(this);
validateInteger(val, 'highWaterMark', 0, 0xFFFFFFFF);
const inner = this.#inner;
inner.state.highWaterMark = val;
// If writeDesiredSize hasn't been set yet (still 0 from initialization),
// initialize it to the highWaterMark so the first write can proceed.
if (inner.state.writeDesiredSize === 0 && val > 0) {
inner.state.writeDesiredSize = val;
}
}
/** @type {Function|undefined} */
get onerror() {
assertIsQuicStream(this);
return this.#inner.onerror;
}
set onerror(fn) {
assertIsQuicStream(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onerror = undefined;
} else {
validateFunction(fn, 'onerror');
inner.onerror = FunctionPrototypeBind(fn, this);
// Lazily create the close promise so it can be marked handled.
inner.pendingClose ??= PromiseWithResolvers();
markPromiseAsHandled(inner.pendingClose.promise);
}
}
/** @type {OnBlockedCallback} */
get onblocked() {
assertIsQuicStream(this);
return this.#inner.onblocked;
}
set onblocked(fn) {
assertIsQuicStream(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onblocked = undefined;
inner.state.wantsBlock = false;
} else {
validateFunction(fn, 'onblocked');
inner.onblocked = FunctionPrototypeBind(fn, this);
inner.state.wantsBlock = true;
}
}
/** @type {OnStreamErrorCallback} */
get onreset() {
assertIsQuicStream(this);
return this.#inner.onreset;
}
set onreset(fn) {
assertIsQuicStream(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onreset = undefined;
inner.state.wantsReset = false;
} else {
validateFunction(fn, 'onreset');
inner.onreset = FunctionPrototypeBind(fn, this);
inner.state.wantsReset = true;
}
}
/** @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.
* @type {object|undefined}
*/
get headers() {
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;
}
/**
* The statistics collected for this stream.
* @type {QuicStreamStats}
*/
get stats() {
assertIsQuicStream(this);
const inner = this.#inner;
const handle = this.#handle;
return inner.stats ??= (handle == null) ?
QuicStreamStats[kCreateDisconnected]() :
new QuicStreamStats(kPrivateConstructor, handle.stats, handle.statsByteOffset);
}
/**
* The session this stream belongs to. If the stream is destroyed,
* `null` will be returned.
* @type {QuicSession | null}
*/
get session() {
assertIsQuicStream(this);
return this.#inner.session;
}
/**
* Returns the id for this stream. If the stream is still pending,
* `null` will be returned.
* @type {bigint | null}
*/
get id() {
assertIsQuicStream(this);
if (this.pending) return null;
return this.#inner.state.id;
}
/**
* Returns the directionality of this stream.
* @type {'bidi'|'uni'}
*/
get direction() {
assertIsQuicStream(this);
return this.#inner.direction === kStreamDirectionBidirectional ? 'bidi' : 'uni';
}
/**
* True if the stream has been destroyed.
* @type {boolean}
*/
get destroyed() {
assertIsQuicStream(this);
return this.#handle === undefined;
}
/**
* A promise that will be resolved when the stream is closed.
* @type {Promise<void>}
*/
get closed() {
assertIsQuicStream(this);
this.#inner.pendingClose ??= PromiseWithResolvers();
return this.#inner.pendingClose.promise;
}
/**
* Immediately destroys the stream. Any queued data is discarded. If
* an error is given, the closed promise will be rejected with that
* error. If no error is given, the closed promise will be resolved.
* When destroying with an error, RESET_STREAM and/or STOP_SENDING
* 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"
* code from `QuicSessionState.internalErrorCode`.
* @param {any} error
* @param {QuicStreamDestroyOptions} [options]
*/
destroy(error, options = kEmptyObject) {
assertIsQuicStream(this);
const inner = this.#inner;
// Two distinct guards:
// * `#destroying` flips synchronously here so any re-entrant call
// from inside this method's user callbacks hits the guard and
// returns immediately.
// * `destroyed` (i.e. `#handle === undefined`) catches the case
// where the C++ side already finished cleanup via the
// `onStreamClose -> [kFinishClose]` path - which does NOT go
// through `destroy()` and therefore never sets `#destroying`.
// `[kFinishClose]` clears `#handle` at the end of its work.
if (inner.destroying || this.destroyed) return;
// Validate options up front so a malformed `options` argument
// throws before any side effects (mutating `#destroying`,
// emitting wire frames, invoking `onerror`, settling the closed
// promise). The caller may retry with valid options.
validateObject(options, 'options');
const { code: optionCode, reason } = options;
if (optionCode !== undefined &&
typeof optionCode !== 'bigint' &&
typeof optionCode !== 'number') {
throw new ERR_INVALID_ARG_TYPE('options.code',
['bigint', 'number'], optionCode);
}
if (reason !== undefined) {
validateString(reason, 'options.reason');
}
inner.destroying = true;
// Resolve the wire error code for any RESET_STREAM / STOP_SENDING
// frames emitted below.
let abortCode;
if (optionCode !== undefined) {
abortCode = BigInt(optionCode);
} else if (error !== undefined) {
abortCode = QuicError.isQuicError(error) ?
error.errorCode :
getQuicSessionState(inner.session).internalErrorCode;
}
// When destroying with an error, ensure the peer stops sending
// data we are about to discard by emitting STOP_SENDING. The
// condition gates the emission to error-path destroys with a
// still-open readable side. The C++ state.readEnded flag is
// authoritative -- it is set for locally-initiated uni streams
// (which have no readable side) and when reading completes.
if (abortCode !== undefined &&
!inner.state.readEnded) {
this.#handle.stopSending(abortCode);
}
// When destroying with an error, ensure the peer learns about
// it via RESET_STREAM. The writer.fail path inside [kFinishClose]
// emits RESET_STREAM only when a writer has been created;
// streams that destroy without ever accessing stream.writer
// (e.g. used setBody or never wrote at all) need an explicit
// RESET_STREAM here so the write side does not dangle on the
// wire. The C++ state.writeEnded flag is authoritative.
if (abortCode !== undefined &&
inner.writer === undefined &&
!inner.state.writeEnded) {
this.#handle.resetStream(abortCode);
}
if (error !== undefined && typeof inner.onerror === 'function') {
invokeOnerror(inner.onerror, error);
}
const handle = this.#handle;
this[kFinishClose](error);
handle.destroy();
}
/**
* Sets the outbound data source for the stream. This can only be called
* once and must be called before any data will be sent. The body can be
* an ArrayBuffer, a TypedArray or DataView, or a Blob. If the stream
* is destroyed or already has an outbound data source, an error will
* be thrown.
* @param {ArrayBuffer|SharedArrayBuffer|ArrayBufferView|Blob} outbound
*/
setOutbound(outbound) {
assertIsQuicStream(this);
if (this.destroyed) {
throw new ERR_INVALID_STATE('Stream is destroyed');
}
if (this.#inner.state.hasOutbound) {
throw new ERR_INVALID_STATE('Stream already has an outbound data source');
}
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
* or via setBody(). Non-writable streams return an already-closed Writer.
* @type {object}
*/
get writer() {
assertIsQuicStream(this);
const inner = this.#inner;
if (inner.writer !== undefined) return inner.writer;
if (inner.outboundSet) {
throw new ERR_INVALID_STATE(
'Stream outbound already configured with a body source');
}
const handle = this.#handle;
const stream = this;
let closed = false;
let errored = false;
let error = null;
let totalBytesWritten = 0;
let drainWakeup = null;
// Drain callback - C++ fires this when send buffer has space
stream[kDrain] = () => {
if (drainWakeup) {
drainWakeup.resolve(true);
drainWakeup = null;
}
};
// A note on backpressure handling: per the stream/iter spec, the default
// backpressure policy for writers is strict, meaning that if the stream
// signals backpressure additional writes are rejected until the buffer has
// capacity again.
function writeSync(chunk) {
// If the stream is closed, errored, or write-ended, we cannot accept
// more data. Refuse the sync write.
// If a drain is already pending, another operation is waiting
// for capacity. Refuse the sync write.
if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) {
return false;
}
chunk = toUint8Array(chunk);
const len = TypedArrayPrototypeGetByteLength(chunk);
if (len === 0) return true;
// Refuse the write only when there is no available capacity at
// all. When writeDesiredSize > 0 we allow the write even if the
// chunk is larger than the remaining capacity -- the C++ side
// will accept the data into the DataQueue and
// UpdateWriteDesiredSize() will drop writeDesiredSize toward 0,
// at which point the standard drain mechanism takes over.
// This follows the Web Streams model where writes beyond the HWM
// succeed and backpressure applies to *subsequent* writes.
if (stream.#inner.state.writeDesiredSize === 0) return false;
const result = handle.write([chunk]);
if (result === undefined) return false;
totalBytesWritten += len;
return true;
}
async function write(chunk, options = kEmptyObject) {
validateObject(options, 'options');
const { signal } = options;
if (signal !== undefined) {
validateAbortSignal(signal, 'options.signal');
signal.throwIfAborted();
}
if (errored) throw error;
if (closed || stream.#inner.state.writeEnded) {
throw new ERR_INVALID_STATE('Writer is closed');
}
// If a drain is already pending, another operation is waiting
// for capacity. Under strict policy, reject immediately.
// Later, if we add support for other backpressure policies,
// we could instead await the existing drain before proceeding.
if (drainWakeup != null) {
throw new ERR_INVALID_STATE('Stream write buffer is full');
}
if (!writeSync(chunk)) {
throw new ERR_INVALID_STATE('Stream write buffer is full');
}
}
function writevSync(chunks) {
if (closed || errored || stream.#inner.state.writeEnded || drainWakeup != null) {
return false;
}
chunks = convertChunks(chunks);
let len = 0;
for (const c of chunks) len += TypedArrayPrototypeGetByteLength(c);
if (len === 0) return true;
if (stream.#inner.state.writeDesiredSize === 0) return false;
const result = handle.write(chunks);
if (result === undefined) return false;
totalBytesWritten += len;
return true;
}
async function writev(chunks, options = kEmptyObject) {
validateObject(options, 'options');
const { signal } = options;
if (signal !== undefined) {
validateAbortSignal(signal, 'options.signal');
signal.throwIfAborted();
}
if (errored) throw error;
if (closed || stream.#inner.state.writeEnded) {
throw new ERR_INVALID_STATE('Writer is closed');
}
// If a drain is already pending, another operation is waiting
// for capacity. Under strict policy, reject immediately.
// Later, if we add support for other backpressure policies,
// we could instead await the existing drain before proceeding.
if (drainWakeup != null) {
throw new ERR_INVALID_STATE('Stream write buffer is full');
}
if (!writevSync(chunks)) {
throw new ERR_INVALID_STATE('Stream write buffer is full');
}
}
function endSync() {
// Per the streams/iter spec, endSync and end follow a try-fallback
// pattern. That is, callers should try endSync first and if it returns
// -1, then they should call and await end(). This is a signal that sync
// end is not currently possible. However, we always support sync end
// here unless the stream is already errored.
if (errored) return -1;
// If we're already closed, just return the total bytes written.
if (closed) return totalBytesWritten;
// If we are waiting for drain to complete, we cannot end synchronously.
if (drainWakeup != null) return -1;
// Fantastic, we can end synchronously!
handle.endWrite();
closed = true;
return totalBytesWritten;
}
async function end(options = kEmptyObject) {
validateObject(options, 'options');
const { signal } = options;
if (signal !== undefined) {
validateAbortSignal(signal, 'options.signal');
signal.throwIfAborted();
// TODO(@jasnell): The stream/iter spec allows individual sync end
// calls to be canceled via an AbortSignal. We currently do not support
// this, but we can add before the impl is graduated from experimental.
// At most we do here is check for signal abort at the start of the call.
}
// Per the streams/iter spec, endSync and end follow a try-fallback
// pattern. That is, callers should try endSync first and if it returns
// -1, then they should call and await end(). This is a signal that sync
// end is not currently possible. However, we always support sync end
// here unless the stream is already errored.
// While the user should have already called endSync, we call it again
// here to actually process the end request. At worst it's called twice.
const n = endSync();
// A return value of -1 indicates that endSync was not yet able to
// process the end request, either because we are errored or because we
// are awaiting drain. If we're errored, throw the error. If we're waiting
// for drain, await it and then try ending again.
if (n >= 0) return n;
if (errored) throw error;
drainWakeup ??= PromiseWithResolvers();
try {
await drainWakeup.promise;
} finally {
drainWakeup = null;
}
return endSync();
}
function fail(reason) {
if (closed || errored) return;
errored = true;
error = reason ?? new ERR_INVALID_STATE('Failed');
// `writer.fail()` is always an error path, so the wire code on
// RESET_STREAM must never be `0n` (which means "no error" in
// most application protocols). Resolve the code in priority
// order:
// 1. If `reason` is a `QuicError`, use its explicit
// `errorCode`.
// 2. Otherwise fall back to the negotiated application's
// "internal error" code, surfaced via
// `QuicSessionState.internalErrorCode`. For HTTP/3 this is
// `H3_INTERNAL_ERROR` (0x102); for raw QUIC applications
// it falls back to the QUIC transport-layer
// `INTERNAL_ERROR` (0x1).
const code = QuicError.isQuicError(error) ?
error.errorCode :
getQuicSessionState(stream.#inner.session).internalErrorCode;
handle.resetStream(code);
if (drainWakeup != null) {
drainWakeup.reject(error);
drainWakeup = null;
}
}
const writer = {
__proto__: null,
get desiredSize() {
if (closed || errored || stream.#inner.state.writeEnded) return null;
return stream.#inner.state.writeDesiredSize;
},
writeSync,
write,
writevSync,
writev,
endSync,
end,
fail,
[drainableProtocol]() {
if (closed || errored) return null;
// If a drain is already pending, return the existing promise.
if (drainWakeup != null) return drainWakeup.promise;
if (stream.#inner.state.writeDesiredSize > 0) return null;
drainWakeup = PromiseWithResolvers();
return drainWakeup.promise;
},
[SymbolAsyncDispose]() {
if (!closed && !errored) fail();
return PromiseResolve();
},
[SymbolDispose]() {
if (!closed && !errored) fail();
},
};
// Non-writable stream - return a pre-closed writer.
// A remote unidirectional stream is read-only and has no writable
// side. isLocal distinguishes locally-initiated (writable) from
// remotely-initiated (read-only) uni streams.
if (!handle || this.destroyed || inner.state.writeEnded ||
(inner.direction === kStreamDirectionUnidirectional &&
!inner.isLocal)) {
closed = true;
return inner.writer = writer;
}
// Initialize the outbound DataQueue for streaming writes
handle.initStreamingSource();
initStreamingBackpressure(this);
return inner.writer = writer;
}
/**
* Sets the outbound body source for this stream. Accepts all body
* source types (string, TypedArray, Blob, AsyncIterable, Promise, null).
* Can only be called once. Mutually exclusive with stream.writer.
* @param {any} body
*/
setBody(body) {
assertIsQuicStream(this);
if (this.destroyed) {
throw new ERR_INVALID_STATE('Stream is destroyed');
}
const inner = this.#inner;
if (inner.outboundSet) {
throw new ERR_INVALID_STATE('Stream outbound already configured');
}
if (inner.writer !== undefined) {
throw new ERR_INVALID_STATE('Stream writer already accessed');
}
inner.outboundSet = true;
// If the body is a FileHandle, store it so it is closed
// automatically when the stream finishes.
if (FileHandle.isFileHandle(body)) {
inner.fileHandle = body;
}
configureOutbound(this.#handle, this, body);
}
/**
* Associates a FileHandle with this stream so it is closed automatically
* when the stream finishes. Called internally when a FileHandle is used
* as a body source.
* @param {FileHandle} fh
*/
[kAttachFileHandle](fh) {
this.#inner.fileHandle = fh;
}
/**
* Tells the peer to stop sending data for this stream. The optional error
* code will be sent to the peer as part of the request. If the stream is
* already destroyed, this is a no-op. No acknowledgement of this action
* will be provided.
* @param {number|bigint} code
*/
stopSending(code = 0n) {
assertIsQuicStream(this);
if (this.destroyed) return;
this.#handle.stopSending(BigInt(code));
}
/**
* Tells the peer that this end will not send any more data on this stream.
* The optional error code will be sent to the peer as part of the
* request. If the stream is already destroyed, this is a no-op. No
* acknowledgement of this action will be provided.
* @param {number|bigint} code
*/
resetStream(code = 0n) {
assertIsQuicStream(this);
if (this.destroyed) return;
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();
if (this.destroyed) {
return inner.pendingClose.promise;
}
if (error !== undefined) {
inner.pendingClose.reject(error);
} else {
inner.pendingClose.resolve();
}
debug('stream closed');
if (onStreamClosedChannel.hasSubscribers) {
onStreamClosedChannel.publish({
__proto__: null,
stream: this,
session: inner.session,
error,
stats: this.stats,
});
}
if (this[kPerfEntry] && hasObserver('quic')) {
stopPerf(this, kPerfEntry, {
detail: {
stats: this.stats,
direction: this.direction,
},
});
}
inner.stats?.[kFinishClose]();
inner.state?.[kFinishClose]();
inner.session[kRemoveStream](this);
inner.writer?.fail(error);
inner.session = undefined;
inner.pendingClose.reject = undefined;
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
// may fail if the user already closed it -- that's expected and
// harmless, so mark the promise as handled.
markPromiseAsHandled(this.#inner.fileHandle.close());
inner.fileHandle = undefined;
}
}
[kBlocked]() {
const inner = this.#inner;
// The blocked event should only be called if the stream was created with
// an onblocked callback. The callback should always exist here.
assert(inner.onblocked, 'Unexpected stream blocked event');
if (onStreamBlockedChannel.hasSubscribers) {
onStreamBlockedChannel.publish({
__proto__: null,
stream: this,
session: inner.session,
});
}
safeCallbackInvoke(inner.onblocked, this);
}
[kDrain]() {
// No-op by default. Overridden by the writer closure when
// stream.writer is accessed.
}
[kReset](error) {
const inner = this.#inner;
// The reset event should only be called if the stream was created with
// an onreset callback. The callback should always exist here.
assert(inner.onreset, 'Unexpected stream reset event');
if (onStreamResetChannel.hasSubscribers) {
onStreamResetChannel.publish({
__proto__: null,
stream: this,
session: inner.session,
error,
});
}
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 { }';
}
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
const {
id,
direction,
pending,
stats,
session,
} = this;
return `QuicStream ${inspect({
__proto__: null,
id,
direction,
pending,
stats,
state: this.#inner.state,
session,
}, opts)}`;
}
}
class QuicSession {
/** @type {object|undefined} */
#handle;
#inner = {
__proto__: null,
/** @type {QuicEndpoint} */
endpoint: undefined,
isPendingClose: false,
selfInitiatedClose: false,
destroying: false,
handshakeCompleted: false,
pendingClose: PromiseWithResolvers(),
pendingOpen: PromiseWithResolvers(),
/** @type {QuicSessionState} */
state: undefined,
/** @type {QuicSessionStats} */
stats: undefined,
streams: new SafeSet(),
onerror: undefined,
onstream: undefined,
ondatagram: undefined,
ondatagramstatus: undefined,
onpathvalidation: undefined,
onsessionticket: undefined,
onversionnegotiation: undefined,
onhandshake: undefined,
onnewtoken: undefined,
onearlyrejected: undefined,
onorigin: undefined,
ongoaway: undefined,
onkeylog: undefined,
onqlog: undefined,
pendingQlog: undefined,
// Default to 'manual' (no auto-rejection). Client sessions override
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
// because server-side cert validation is handled by rejectUnauthorized
// at the C++ level.
verifyPeer: 'manual',
handshakeInfo: undefined,
/** @type {QuicSessionPath|undefined} */
path: undefined,
certificate: undefined,
peerCertificate: undefined,
ephemeralKeyInfo: undefined,
localTransportParams: undefined,
remoteTransportParams: undefined,
};
static {
isQuicSession = function(val) {
return val != null && typeof val === 'object' && #handle in val;
};
assertIsQuicSession = function(val) {
if (!isQuicSession(val)) {
throw new ERR_INVALID_THIS('QuicSession');
}
};
getQuicSessionState = function(session) {
assertIsQuicSession(session);
return session.#inner.state;
};
}
/**
* @param {symbol} privateSymbol
* @param {object} handle
* @param {QuicEndpoint} endpoint
*/
constructor(privateSymbol, handle, endpoint) {
// Instances of QuicSession can only be created internally.
assertPrivateSymbol(privateSymbol);
this.#handle = handle;
this.#handle[kOwner] = this;
const inner = this.#inner;
inner.endpoint = endpoint;
// Move any qlog entries that arrived before the wrapper existed.
if (handle._pendingQlog !== undefined) {
inner.pendingQlog = handle._pendingQlog;
handle._pendingQlog = undefined;
}
inner.stats = new QuicSessionStats(
kPrivateConstructor, handle.stats, handle.statsByteOffset);
inner.state = new QuicSessionState(
kPrivateConstructor, handle.state, handle.stateByteOffset);
if (hasObserver('quic')) {
startPerf(this, kPerfEntry, { type: 'quic', name: '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;
}
// If the handle is already gone, we cannot retrieve the transport params.
if (this.destroyed) return null;
const params = this.#handle.localTransportParams();
if (params.preferredAddressIpv4 !== undefined) {
params.preferredAddressIpv4 = new InternalSocketAddress(params.preferredAddressIpv4);
}
if (params.preferredAddressIpv6 !== undefined) {
params.preferredAddressIpv6 = new InternalSocketAddress(params.preferredAddressIpv6);
}
return this.#inner.localTransportParams = params;
}
get remoteTransportParams() {
if (this.#inner.remoteTransportParams !== undefined) {
return this.#inner.remoteTransportParams;
}
// If the handle is already gone, we cannot retrieve the transport params.
if (this.destroyed) return null;
const params = this.#handle.remoteTransportParams();
// If params is undefined, the transport parameters have not yet been received.
// Note the distinction between this and the case where the handle is gone.
// If the handle is gone, we return null because we know the transport
// parameters will be unavailable. If the transport parameters have not yet
// been received, we return undefined to indicate that they may still become
// available in the future.
if (params === undefined) return undefined;
if (params.preferredAddressIpv4 !== undefined) {
params.preferredAddressIpv4 = new InternalSocketAddress(params.preferredAddressIpv4);
}
if (params.preferredAddressIpv6 !== undefined) {
params.preferredAddressIpv6 = new InternalSocketAddress(params.preferredAddressIpv6);
}
return this.#inner.remoteTransportParams = params;
}
/** @type {boolean} */
get #isClosedOrClosing() {
return this.#handle === undefined || this.#inner.isPendingClose;
}
/** @type {Function|undefined} */
get onerror() {
assertIsQuicSession(this);
return this.#inner.onerror;
}
set onerror(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onerror = undefined;
} else {
validateFunction(fn, 'onerror');
inner.onerror = FunctionPrototypeBind(fn, this);
// When an onerror handler is provided, mark the pending promises
// as handled so that rejections from destroy(error) don't surface
// as unhandled rejections. The onerror callback is the
// application's error handler for this session.
markPromiseAsHandled(inner.pendingClose.promise);
markPromiseAsHandled(inner.pendingOpen.promise);
// Also mark existing streams' closed promises. Stream rejections
// during session destruction are expected collateral when the
// session has an error handler.
for (const stream of inner.streams) {
markPromiseAsHandled(stream.closed);
}
}
}
/** @type {OnStreamCallback} */
get onstream() {
assertIsQuicSession(this);
return this.#inner.onstream;
}
set onstream(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onstream = undefined;
} else {
validateFunction(fn, 'onstream');
inner.onstream = FunctionPrototypeBind(fn, this);
}
}
/** @type {OnDatagramCallback} */
get ondatagram() {
assertIsQuicSession(this);
return this.#inner.ondatagram;
}
set ondatagram(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.ondatagram = undefined;
inner.state.hasDatagramListener = false;
} else {
validateFunction(fn, 'ondatagram');
inner.ondatagram = FunctionPrototypeBind(fn, this);
inner.state.hasDatagramListener = true;
}
}
/**
* The ondatagramstatus callback is called when the status of a sent datagram
* is received. This is best-effort only.
* @type {OnDatagramStatusCallback}
*/
get ondatagramstatus() {
assertIsQuicSession(this);
return this.#inner.ondatagramstatus;
}
set ondatagramstatus(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.ondatagramstatus = undefined;
inner.state.hasDatagramStatusListener = false;
} else {
validateFunction(fn, 'ondatagramstatus');
inner.ondatagramstatus = FunctionPrototypeBind(fn, this);
inner.state.hasDatagramStatusListener = true;
}
}
/** @type {Function|undefined} */
get onpathvalidation() {
assertIsQuicSession(this);
return this.#inner.onpathvalidation;
}
set onpathvalidation(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onpathvalidation = undefined;
inner.state.hasPathValidationListener = false;
} else {
validateFunction(fn, 'onpathvalidation');
inner.onpathvalidation = FunctionPrototypeBind(fn, this);
inner.state.hasPathValidationListener = true;
}
}
get onkeylog() {
assertIsQuicSession(this);
return this.#inner.onkeylog;
}
set onkeylog(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onkeylog = undefined;
} else {
validateFunction(fn, 'onkeylog');
inner.onkeylog = FunctionPrototypeBind(fn, this);
}
}
get onqlog() {
assertIsQuicSession(this);
return this.#inner.onqlog;
}
set onqlog(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onqlog = undefined;
} else {
validateFunction(fn, 'onqlog');
inner.onqlog = FunctionPrototypeBind(fn, this);
// Flush any qlog entries that were cached before the callback was set.
if (inner.pendingQlog !== undefined) {
const pending = inner.pendingQlog;
inner.pendingQlog = undefined;
for (let i = 0; i < pending.length; i += 2) {
this[kQlog](pending[i], pending[i + 1]);
}
}
}
}
/** @type {Function|undefined} */
get onsessionticket() {
assertIsQuicSession(this);
return this.#inner.onsessionticket;
}
set onsessionticket(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onsessionticket = undefined;
inner.state.hasSessionTicketListener = false;
} else {
validateFunction(fn, 'onsessionticket');
inner.onsessionticket = FunctionPrototypeBind(fn, this);
inner.state.hasSessionTicketListener = true;
}
}
/** @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);
return this.#inner.onversionnegotiation;
}
set onversionnegotiation(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onversionnegotiation = undefined;
} else {
validateFunction(fn, 'onversionnegotiation');
inner.onversionnegotiation = FunctionPrototypeBind(fn, this);
}
}
/** @type {Function|undefined} */
get onhandshake() {
assertIsQuicSession(this);
return this.#inner.onhandshake;
}
set onhandshake(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onhandshake = undefined;
} else {
validateFunction(fn, 'onhandshake');
inner.onhandshake = FunctionPrototypeBind(fn, this);
}
}
/** @type {Function|undefined} */
get onnewtoken() {
assertIsQuicSession(this);
return this.#inner.onnewtoken;
}
set onnewtoken(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onnewtoken = undefined;
inner.state.hasNewTokenListener = false;
} else {
validateFunction(fn, 'onnewtoken');
inner.onnewtoken = FunctionPrototypeBind(fn, this);
inner.state.hasNewTokenListener = true;
}
}
/** @type {Function|undefined} */
get onearlyrejected() {
assertIsQuicSession(this);
return this.#inner.onearlyrejected;
}
set onearlyrejected(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onearlyrejected = undefined;
} else {
validateFunction(fn, 'onearlyrejected');
inner.onearlyrejected = FunctionPrototypeBind(fn, this);
}
}
/** @type {Function|undefined} */
get onorigin() {
assertIsQuicSession(this);
return this.#inner.onorigin;
}
set onorigin(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.onorigin = undefined;
inner.state.hasOriginListener = false;
} else {
validateFunction(fn, 'onorigin');
inner.onorigin = FunctionPrototypeBind(fn, this);
inner.state.hasOriginListener = true;
}
}
/** @type {Function|undefined} */
get ongoaway() {
assertIsQuicSession(this);
return this.#inner.ongoaway;
}
set ongoaway(fn) {
assertIsQuicSession(this);
const inner = this.#inner;
if (fn === undefined) {
inner.ongoaway = undefined;
} else {
validateFunction(fn, 'ongoaway');
inner.ongoaway = FunctionPrototypeBind(fn, this);
}
}
/**
* The maximum datagram size the peer will accept, or 0 if datagrams
* are not supported or the handshake has not yet completed.
* @type {bigint}
*/
get maxDatagramSize() {
assertIsQuicSession(this);
return this.#inner.state.maxDatagramSize;
}
/**
* Maximum number of datagrams that can be queued while inside a
* ngtcp2 callback scope. When the queue is full, the oldest
* datagram is dropped and reported as lost. Default is 128.
* @type {number}
*/
get maxPendingDatagrams() {
assertIsQuicSession(this);
return this.#inner.state.maxPendingDatagrams;
}
set maxPendingDatagrams(val) {
assertIsQuicSession(this);
validateInteger(val, 'maxPendingDatagrams', 0, 0xFFFF);
this.#inner.state.maxPendingDatagrams = val;
}
/**
* The statistics collected for this session.
* @type {QuicSessionStats}
*/
get stats() {
assertIsQuicSession(this);
return this.#inner.stats;
}
/**
* The endpoint this session belongs to. If the session has been destroyed,
* `null` will be returned.
* @type {QuicEndpoint|null}
*/
get endpoint() {
assertIsQuicSession(this);
if (this.destroyed) return null;
return this.#inner.endpoint;
}
/**
* The local and remote socket addresses associated with the session.
* @type {QuicSessionPath | undefined}
*/
get path() {
assertIsQuicSession(this);
if (this.destroyed) return undefined;
return this.#inner.path ??= {
__proto__: null,
local: new InternalSocketAddress(this.#handle.getLocalAddress()),
remote: new InternalSocketAddress(this.#handle.getRemoteAddress()),
};
}
/**
* The local certificate as a {@link crypto.X509Certificate}, or undefined
* if no local certificate is available. Server sessions return their
* configured certificate; client sessions return undefined unless a
* client certificate was sent.
* @type {crypto.X509Certificate|undefined}
*/
get certificate() {
assertIsQuicSession(this);
if (this.destroyed) return undefined;
if (this.#inner.certificate === undefined) {
const handle = this.#handle.getCertificate();
this.#inner.certificate = handle ? new InternalX509Certificate(handle) : null;
}
return this.#inner.certificate ?? undefined;
}
/**
* The peer's certificate as a {@link crypto.X509Certificate}, or undefined
* if the peer did not present a certificate or the session is destroyed.
* @type {crypto.X509Certificate|undefined}
*/
get peerCertificate() {
assertIsQuicSession(this);
if (this.destroyed) return undefined;
if (this.#inner.peerCertificate === undefined) {
const handle = this.#handle.getPeerCertificate();
this.#inner.peerCertificate = handle ?
new InternalX509Certificate(handle) :
null;
}
return this.#inner.peerCertificate ?? undefined;
}
/**
* The ephemeral key info for the session. Only available on client
* sessions. Returns undefined for server sessions or if the session
* is destroyed.
* @type {object|undefined}
*/
get ephemeralKeyInfo() {
assertIsQuicSession(this);
if (this.destroyed) return undefined;
return this.#inner.ephemeralKeyInfo ??= this.#handle.getEphemeralKey();
}
/**
* @param {number} direction
* @param {OpenStreamOptions} options
* @returns {QuicStream}
*/
async #createStream(direction, options = kEmptyObject) {
const inner = this.#inner;
if (this.#isClosedOrClosing) {
throw new ERR_INVALID_STATE('Session is closed. New streams cannot be opened.');
}
const dir = direction === kStreamDirectionBidirectional ? 'bidi' : 'uni';
if (inner.state.isStreamOpenAllowed) {
debug(`opening new pending ${dir} stream`);
} else {
debug(`opening new ${dir} stream`);
}
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);
if (handle === undefined) {
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);
if (typeof this.#inner.onerror === 'function') {
markPromiseAsHandled(stream.closed);
}
// If the body was a FileHandle, store it on the stream so it is
// closed automatically when the stream finishes.
if (FileHandle.isFileHandle(body)) {
stream[kAttachFileHandle](body);
}
// 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,
stream,
session: this,
direction: dir,
});
}
return stream;
}
/**
* Creates a new bidirectional stream on this session. If the session
* does not allow new streams to be opened, an error will be thrown.
* @param {OpenStreamOptions} [options]
* @returns {Promise<QuicStream>}
*/
async createBidirectionalStream(options = kEmptyObject) {
assertIsQuicSession(this);
return await this.#createStream(kStreamDirectionBidirectional, options);
}
/**
* Creates a new unidirectional stream on this session. If the session
* does not allow new streams to be opened, an error will be thrown.
* @param {OpenStreamOptions} [options]
* @returns {Promise<QuicStream>}
*/
async createUnidirectionalStream(options = kEmptyObject) {
assertIsQuicSession(this);
return await this.#createStream(kStreamDirectionUnidirectional, options);
}
/**
* Send a datagram. The id of the sent datagram will be returned. The status
* of the sent datagram will be reported via the datagram-status event if
* possible.
*
* If a string is given it will be encoded using the specified encoding.
*
* If an ArrayBufferView is given, the bytes are copied into an internal
* buffer; the caller's source buffer is unchanged and may be reused
* immediately. Callers that want to ensure their source cannot be
* mutated after the call (for example, when handing the buffer off to
* another async consumer) can call ArrayBuffer.prototype.transfer()
* themselves before passing it.
*
* If a Promise is given, it will be awaited before sending. If the
* session closes while awaiting, 0n is returned silently.
* @param {ArrayBufferView|string|Promise} datagram The datagram payload
* @param {string} [encoding] The encoding to use if datagram is a string
* @returns {Promise<bigint>} The datagram ID
*/
async sendDatagram(datagram, encoding = 'utf8') {
assertIsQuicSession(this);
if (this.#isClosedOrClosing) {
throw new ERR_INVALID_STATE('Session is closed');
}
const maxDatagramSize = this.#inner.state.maxDatagramSize;
// The peer max datagram size is either unknown or they have explicitly
// indicated that they do not support datagrams by setting it to 0. In
// either case, we do not send the datagram.
if (maxDatagramSize === 0) return kNilDatagramId;
if (isPromise(datagram)) {
datagram = await datagram;
// Session may have closed while awaiting. Since datagrams are
// inherently unreliable, silently return rather than throwing.
if (this.#isClosedOrClosing) return kNilDatagramId;
}
if (typeof datagram === 'string') {
datagram = new Uint8Array(Buffer.from(datagram, encoding));
} else if (!isArrayBufferView(datagram)) {
throw new ERR_INVALID_ARG_TYPE('datagram',
['ArrayBufferView', 'string'],
datagram);
}
const length = isDataView(datagram) ?
DataViewPrototypeGetByteLength(datagram) :
TypedArrayPrototypeGetByteLength(datagram);
// If the view has zero length (e.g. detached buffer), there's
// nothing to send.
if (length === 0) return kNilDatagramId;
// The peer max datagram size is less than the datagram we want to send,
// so... don't send it.
if (length > maxDatagramSize) return kNilDatagramId;
const id = this.#handle.sendDatagram(datagram);
if (id !== kNilDatagramId && onSessionSendDatagramChannel.hasSubscribers) {
onSessionSendDatagramChannel.publish({
__proto__: null,
id,
length,
session: this,
});
}
debug(`datagram ${id} sent with ${length} bytes`);
return id;
}
/**
* Initiate a key update.
*/
updateKey() {
assertIsQuicSession(this);
if (this.#isClosedOrClosing) {
throw new ERR_INVALID_STATE('Session is closed');
}
debug('updating session key');
this.#handle.updateKey();
if (onSessionUpdateKeyChannel.hasSubscribers) {
onSessionUpdateKeyChannel.publish({
__proto__: null,
session: this,
});
}
}
/**
* Gracefully closes the session. Any streams created on the session will be
* allowed to complete gracefully and any datagrams that have already been
* queued for sending will be allowed to complete. Once all streams have been
* completed and all datagrams have been sent, the session will be closed.
* New streams will not be allowed to be created. The returned promise will
* be resolved when the session closes, or will be rejected if the session
* closes abruptly due to an error.
* @param {object} [options]
* @param {bigint|number} [options.code] The error code to send in the
* CONNECTION_CLOSE frame. Defaults to NO_ERROR (0).
* @param {string} [options.type] Either `'transport'` (default) or
* `'application'`. Determines the error code namespace.
* @param {string} [options.reason] An optional human-readable reason
* string included in the CONNECTION_CLOSE frame (diagnostic only).
* @returns {Promise<void>}
*/
close(options = kEmptyObject) {
assertIsQuicSession(this);
options = validateCloseOptions(options);
const inner = this.#inner;
if (!this.#isClosedOrClosing) {
inner.isPendingClose = true;
if (options?.code !== undefined) {
inner.selfInitiatedClose = true;
}
debug('gracefully closing the session');
this.#handle.gracefulClose(options);
if (onSessionClosingChannel.hasSubscribers) {
onSessionClosingChannel.publish({
__proto__: null,
session: this,
});
}
}
return this.closed;
}
/** @type {boolean} */
get closing() {
return this.#inner.isPendingClose;
}
/** @type {Promise<QuicSessionInfo>} */
get opened() {
assertIsQuicSession(this);
return this.#inner.pendingOpen.promise;
}
/**
* A promise that is resolved when the session is closed, or is rejected if
* the session is closed abruptly due to an error.
* @type {Promise<void>}
*/
get closed() {
assertIsQuicSession(this);
return this.#inner.pendingClose.promise;
}
/** @type {boolean} */
get destroyed() {
assertIsQuicSession(this);
return this.#handle === undefined;
}
/**
* Forcefully closes the session abruptly without waiting for streams to be
* completed naturally. Any streams that are still open will be immediately
* destroyed and any queued datagrams will be dropped. If an error is given,
* the closed promise will be rejected with that error. If no error is given,
* the closed promise will be resolved.
* @param {any} error
* @param {object} [options]
* @param {bigint|number} [options.code] The error code to send in the
* CONNECTION_CLOSE frame. Defaults to NO_ERROR (0).
* @param {string} [options.type] Either `'transport'` (default) or
* `'application'`. Determines the error code namespace.
* @param {string} [options.reason] An optional human-readable reason
* string included in the CONNECTION_CLOSE frame (diagnostic only).
*/
destroy(error, options) {
assertIsQuicSession(this);
const inner = this.#inner;
// Two distinct guards (see also `QuicStream.destroy`):
// * `#destroying` flips synchronously here so any re-entrant call
// (e.g. from a user `onerror` callback or from a cascading
// `stream.destroy(error)` whose own `onerror` re-enters
// `session.destroy()`) hits this guard and returns immediately
// without running the teardown twice.
// * `destroyed` (i.e. `#handle === undefined`) signals
// "fully torn down". Defense-in-depth for paths that may have
// finished teardown without setting `#destroying` and for
// repeat invocations after this method has fully run.
if (inner.destroying || this.destroyed) return;
if (options !== undefined) options = validateCloseOptions(options);
inner.destroying = true;
debug('destroying the session');
if (error !== undefined) {
if (onSessionErrorChannel.hasSubscribers) {
onSessionErrorChannel.publish({
__proto__: null,
session: this,
error,
});
}
if (typeof inner.onerror === 'function') {
invokeOnerror(inner.onerror, error);
}
}
// First, forcefully and immediately destroy all open streams, if any.
for (const stream of inner.streams) {
stream.destroy(error);
}
// The streams should remove themselves when they are destroyed but let's
// be doubly sure.
if (inner.streams.size) {
process.emitWarning(
`The session is destroyed with ${inner.streams.size} active streams. ` +
'This should not happen and indicates a bug in Node.js. Please open an ' +
'issue in the Node.js GitHub repository at https://github.com/nodejs/node ' +
'to report the problem.',
);
}
inner.streams.clear();
// Remove this session immediately from the endpoint
inner.endpoint[kRemoveSession](this);
inner.endpoint = undefined;
inner.isPendingClose = false;
// If the handshake never completed, reject the opened promise. The
// session is being destroyed, so the handshake will never complete
// and `await session.opened` would otherwise hang forever. The
// documented contract is that opened rejects when the session is
// destroyed before opening; see the `session.opened` docs in
// doc/api/quic.md. `[kHandshake]` clears `#pendingOpen.reject` once
// the handshake completes successfully, so this branch only runs if
// we are racing against a still-pending handshake.
//
// Mark the rejection as handled before rejecting so that callers who
// never explicitly `await session.opened` do not get an unhandled
// rejection warning - common for server-side sessions delivered via
// `onsession`, which often do not await opened. The rejection is
// still observable via `await session.opened`.
if (inner.pendingOpen.reject) {
markPromiseAsHandled(inner.pendingOpen.promise);
inner.pendingOpen.reject(error ?? new ERR_INVALID_STATE(
'Session was destroyed before it opened'));
}
if (error) {
// If the session is still waiting to be closed, and error
// is specified, reject the closed promise.
inner.pendingClose.reject?.(error);
} else {
inner.pendingClose.resolve?.();
}
inner.pendingClose.reject = undefined;
inner.pendingClose.resolve = undefined;
inner.pendingOpen.reject = undefined;
inner.pendingOpen.resolve = undefined;
inner.state[kFinishClose]();
inner.stats[kFinishClose]();
if (this[kPerfEntry] && hasObserver('quic')) {
stopPerf(this, kPerfEntry, {
detail: {
stats: inner.stats,
handshake: inner.handshakeInfo,
path: inner.path,
},
});
}
inner.onerror = undefined;
inner.onstream = undefined;
inner.ondatagram = undefined;
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;
inner.ephemeralKeyInfo = undefined;
// Destroy the underlying C++ handle. Pass close error options if
// provided so the CONNECTION_CLOSE frame carries the correct code.
// Note: #onqlog is intentionally NOT cleared here because ngtcp2
// emits the final qlog statement during ngtcp2_conn destruction,
// and the deferred callback must still be reachable. The reference
// is released when the QuicSession object is garbage collected.
this.#handle.destroy(options);
this.#handle = undefined;
if (onSessionClosedChannel.hasSubscribers) {
onSessionClosedChannel.publish({
__proto__: null,
session: this,
error,
stats: inner.stats,
});
}
}
/**
* 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
* @param {string} [reason]
*/
[kFinishClose](errorType, code, reason, errorName) {
// If code is zero, then we closed without an error. Yay! We can destroy
// safely without specifying an error.
if (code === 0n) {
debug('finishing closing the session with no error');
this.destroy();
return;
}
debug('finishing closing the session with an error',
errorType, code, reason, errorName);
// If the local side initiated this close with an error code (via
// close({ code })), this is an intentional shutdown; not an error.
// The closed promise should resolve, not reject.
if (this.#inner.selfInitiatedClose) {
this.destroy();
return;
}
// Otherwise, errorType indicates the type of error that occurred, code indicates
// the specific error, and reason is an optional string describing the error.
// code !== 0n here (the early return above handles code === 0n).
// The errorType values map to ngtcp2_ccerr_type:
// 0 = NGTCP2_CCERR_TYPE_TRANSPORT
// 1 = NGTCP2_CCERR_TYPE_APPLICATION
// 2 = NGTCP2_CCERR_TYPE_VERSION_NEGOTIATION
// 3 = NGTCP2_CCERR_TYPE_IDLE_CLOSE
// 4 = NGTCP2_CCERR_TYPE_DROP_CONN
// 5 = NGTCP2_CCERR_TYPE_RETRY
// The DROP_CONN/RETRY cases are typically intercepted before reaching
// here (DROP_CONN tears the connection down without notifying us, RETRY
// is server-only). The default branch is a safety net so any
// unexpected value still completes the close path - without it the
// session would leak with `closed` hanging forever.
switch (errorType) {
case 0: /* Transport Error */
this.destroy(makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
'QUIC transport error',
'transport', code, reason, errorName));
break;
case 1: /* Application Error */
this.destroy(makeQuicError('ERR_QUIC_APPLICATION_ERROR',
'QUIC application error',
'application', code, reason, errorName));
break;
case 2: /* Version Negotiation Error */
this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR());
break;
case 3: /* Idle close */
this.destroy();
break;
default:
this.destroy(makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
'QUIC transport error',
'transport', code, reason, errorName));
break;
}
}
[kKeylog](line) {
const inner = this.#inner;
if (this.destroyed || inner.onkeylog === undefined) return;
safeCallbackInvoke(inner.onkeylog, this, line);
}
[kQlog](data, fin) {
const inner = this.#inner;
if (inner.onqlog === undefined) return;
safeCallbackInvoke(inner.onqlog, this, data, fin);
}
/**
* @param {Uint8Array} u8 The datagram payload
* @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) {
onSessionReceiveDatagramChannel.publish({
__proto__: null,
length,
early,
session: this,
});
}
safeCallbackInvoke(inner.ondatagram, this, u8, early);
}
/**
* @param {bigint} id
* @param {'lost'|'acknowledged'} status
*/
[kDatagramStatus](id, status) {
const inner = this.#inner;
// The datagram status event should only be called if the session has
// an ondatagramstatus callback. The callback should always exist here.
assert(typeof inner.ondatagramstatus === 'function', 'Unexpected datagram status event');
if (this.destroyed) return;
if (onSessionReceiveDatagramStatusChannel.hasSubscribers) {
onSessionReceiveDatagramStatusChannel.publish({
__proto__: null,
id,
status,
session: this,
});
}
safeCallbackInvoke(inner.ondatagramstatus, this, id, status);
}
/**
* @param {'aborted'|'failure'|'success'} result
* @param {SocketAddress} newLocalAddress
* @param {SocketAddress} newRemoteAddress
* @param {SocketAddress} oldLocalAddress
* @param {SocketAddress} oldRemoteAddress
* @param {boolean} preferredAddress
*/
[kPathValidation](result, newLocalAddress, newRemoteAddress, oldLocalAddress,
oldRemoteAddress, preferredAddress) {
const inner = this.#inner;
assert(typeof inner.onpathvalidation === 'function',
'Unexpected path validation event');
if (this.destroyed) return;
const newLocal = new InternalSocketAddress(newLocalAddress);
const newRemote = new InternalSocketAddress(newRemoteAddress);
const oldLocal = oldLocalAddress !== undefined ?
new InternalSocketAddress(oldLocalAddress) : null;
const oldRemote = oldRemoteAddress !== undefined ?
new InternalSocketAddress(oldRemoteAddress) : null;
if (onSessionPathValidationChannel.hasSubscribers) {
onSessionPathValidationChannel.publish({
__proto__: null,
result,
newLocalAddress: newLocal,
newRemoteAddress: newRemote,
oldLocalAddress: oldLocal,
oldRemoteAddress: oldRemote,
preferredAddress,
session: this,
});
}
safeCallbackInvoke(inner.onpathvalidation, this, result, newLocal, newRemote,
oldLocal, oldRemote, preferredAddress);
}
/**
* @param {object} ticket
*/
[kSessionTicket](ticket) {
const inner = this.#inner;
assert(typeof inner.onsessionticket === 'function',
'Unexpected session ticket event');
if (this.destroyed) return;
if (onSessionTicketChannel.hasSubscribers) {
onSessionTicketChannel.publish({
__proto__: null,
ticket,
session: this,
});
}
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
*/
[kNewToken](token, address) {
const inner = this.#inner;
assert(typeof inner.onnewtoken === 'function',
'Unexpected new token event');
if (this.destroyed) return;
const addr = new InternalSocketAddress(address);
if (onSessionNewTokenChannel.hasSubscribers) {
onSessionNewTokenChannel.publish({
__proto__: null,
token,
address: addr,
session: this,
});
}
safeCallbackInvoke(inner.onnewtoken, this, token, addr);
}
[kEarlyDataRejected]() {
if (this.destroyed) return;
if (onSessionEarlyRejectedChannel.hasSubscribers) {
onSessionEarlyRejectedChannel.publish({
__proto__: null,
session: this,
});
}
const inner = this.#inner;
if (typeof inner.onearlyrejected === 'function') {
safeCallbackInvoke(inner.onearlyrejected, this);
}
}
/**
* @param {number} version
* @param {number[]} requestedVersions
* @param {number[]} supportedVersions
*/
[kVersionNegotiation](version, requestedVersions, supportedVersions) {
if (this.destroyed) return;
if (onSessionVersionNegotiationChannel.hasSubscribers) {
onSessionVersionNegotiationChannel.publish({
__proto__: null,
version,
requestedVersions,
supportedVersions,
session: this,
});
}
const inner = this.#inner;
if (typeof inner.onversionnegotiation === 'function') {
safeCallbackInvoke(inner.onversionnegotiation, this,
version, requestedVersions, supportedVersions);
}
// Version negotiation is always a fatal event - the session must be
// destroyed regardless of whether the callback is set.
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
* @param {string} cipher
* @param {string} cipherVersion
* @param {string} validationErrorReason
* @param {number} validationErrorCode
*/
[kHandshake](servername, protocol, cipher, cipherVersion, validationErrorReason,
validationErrorCode, earlyDataAttempted, earlyDataAccepted) {
const inner = this.#inner;
if (this.destroyed || !inner.pendingOpen.resolve) return;
const addr = this.#handle.getRemoteAddress();
const info = {
__proto__: null,
local: inner.endpoint.address,
remote: addr !== undefined ?
new InternalSocketAddress(addr) :
undefined,
servername,
protocol,
cipher,
cipherVersion,
validationErrorReason,
validationErrorCode,
earlyDataAttempted,
earlyDataAccepted,
};
// Stash timing-relevant handshake info for the perf entry detail.
inner.handshakeInfo = {
__proto__: null,
servername,
protocol,
earlyDataAttempted,
earlyDataAccepted,
};
if (onSessionHandshakeChannel.hasSubscribers) {
onSessionHandshakeChannel.publish({
__proto__: null,
session: this,
...info,
});
}
if (typeof inner.onhandshake === 'function') {
safeCallbackInvoke(inner.onhandshake, this, info);
}
// In 'auto' mode, reject the connection if peer certificate validation
// failed. In 'manual' mode, resolve regardless and let the application
// decide. In 'strict' mode, the handshake already failed at the C++
// level (SSL_VERIFY_PEER) so we won't reach here.
if (inner.verifyPeer === 'auto' && validationErrorReason !== undefined) {
const err = makeQuicError(
'ERR_QUIC_TRANSPORT_ERROR',
'QUIC transport error',
'transport',
0n,
`Peer certificate validation failed: ${validationErrorReason}` +
` [${validationErrorCode}]`);
inner.pendingOpen.reject?.(err);
inner.pendingOpen.resolve = undefined;
inner.pendingOpen.reject = undefined;
inner.handshakeCompleted = true;
this.destroy();
return;
}
inner.pendingOpen.resolve?.(info);
inner.pendingOpen.resolve = undefined;
inner.pendingOpen.reject = undefined;
inner.handshakeCompleted = true;
}
/** @type {boolean} */
get [kHandshakeCompleted]() {
return this.#inner.handshakeCompleted;
}
get [kVerifyPeer]() {
return this.#inner.verifyPeer;
}
set [kVerifyPeer](value) {
this.#inner.verifyPeer = value;
}
/**
* @param {object} handle
* @param {number} direction
*/
[kNewStream](handle, direction) {
const inner = this.#inner;
const stream = new QuicStream(kPrivateConstructor, handle, this, direction,
false /* isLocal */);
// 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.
if (typeof inner.onstream !== 'function') {
process.emitWarning('A new stream was received but no onstream callback was provided');
stream.destroy();
return;
}
inner.streams.add(stream);
// If the session has an onerror handler, mark the stream's closed
// promise as handled. See the onerror setter for explanation.
if (typeof inner.onerror === 'function') {
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,
stream,
session: this,
direction: direction === kStreamDirectionBidirectional ? 'bidi' : 'uni',
});
}
safeCallbackInvoke(inner.onstream, this, stream);
}
[kRemoveStream](stream) {
this.#inner.streams.delete(stream);
}
[kInspect](depth, options) {
if (depth < 0) {
return 'QuicSession { }';
}
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
const {
isPendingClose: closing,
endpoint,
path,
state,
stats,
streams,
} = this.#inner;
return `QuicSession ${inspect({
closed: this.closed,
closing,
destroyed: this.destroyed,
endpoint,
path,
state,
stats,
streams,
}, opts)}`;
}
async [SymbolAsyncDispose]() { await this.close(); }
}
// The QuicEndpoint represents a local UDP port binding. It can act as both a
// server for receiving peer sessions, or a client for initiating them. The
// local UDP port will be lazily bound only when connect() or listen() are
// called.
class QuicEndpoint {
#handle;
#inner = {
__proto__: null,
address: undefined,
busy: false,
isPendingClose: false,
listening: false,
pendingClose: PromiseWithResolvers(),
pendingError: undefined,
sessions: new SafeSet(),
stat: undefined,
stats: undefined,
onsession: undefined,
sessionCallbacks: undefined,
};
static {
isQuicEndpoint = function(val) {
return val != null && typeof val === 'object' && #handle in val;
};
assertIsQuicEndpoint = function(val) {
if (!isQuicEndpoint(val)) {
throw new ERR_INVALID_THIS('QuicEndpoint');
}
};
getQuicEndpointState = function(endpoint) {
assertIsQuicEndpoint(endpoint);
return endpoint.#inner.state;
};
assertEndpointNotClosedOrClosing = function(endpoint) {
if (endpoint.#isClosedOrClosing) {
throw new ERR_INVALID_STATE('Endpoint is closed');
}
};
assertEndpointIsNotBusy = function(endpoint) {
if (endpoint.#inner.state.isBusy) {
throw new ERR_INVALID_STATE('Endpoint is busy');
}
};
}
/**
* @param {EndpointOptions} options
* @returns {EndpointOptions}
*/
#processEndpointOptions(options) {
validateObject(options, 'options');
let { address } = options;
const {
retryTokenExpiration,
tokenExpiration,
maxConnectionsPerHost = 100,
maxConnectionsTotal = 10_000,
disableStatelessReset,
addressLRUSize,
retryRate,
retryBurst,
statelessResetRate,
statelessResetBurst,
versionNegotiationRate,
versionNegotiationBurst,
immediateCloseRate,
immediateCloseBurst,
sessionCreationRate,
sessionCreationBurst,
blockList,
blockListPolicy = 'deny',
rxDiagnosticLoss,
txDiagnosticLoss,
udpReceiveBufferSize,
udpSendBufferSize,
udpTTL,
idleTimeout,
validateAddress,
ipv6Only,
reusePort,
cc,
resetTokenSecret,
tokenSecret,
} = options;
if (blockList !== undefined) {
if (!BlockList.isBlockList(blockList)) {
throw new ERR_INVALID_ARG_TYPE('options.blockList',
'net.BlockList', blockList);
}
}
validateOneOf(blockListPolicy, 'options.blockListPolicy',
['deny', 'allow']);
// All of the other options will be validated internally by the C++ code
if (address !== undefined && !SocketAddress.isSocketAddress(address)) {
if (typeof address === 'string') {
address = SocketAddress.parse(address);
} else if (typeof address === 'object' && address !== null) {
address = new SocketAddress(address);
} else {
throw new ERR_INVALID_ARG_TYPE('options.address', ['SocketAddress', 'string'], address);
}
}
return {
__proto__: null,
address: address?.[kSocketAddressHandle],
retryTokenExpiration,
tokenExpiration,
// Connection limits are set on the state buffer, not passed to C++.
maxConnectionsPerHost,
maxConnectionsTotal,
disableStatelessReset,
addressLRUSize,
retryRate,
retryBurst,
statelessResetRate,
statelessResetBurst,
versionNegotiationRate,
versionNegotiationBurst,
immediateCloseRate,
immediateCloseBurst,
sessionCreationRate,
sessionCreationBurst,
// Pass the C++ handle, not the JS BlockList wrapper.
blockList: blockList?.[kBlockListHandle],
blockListPolicy,
rxDiagnosticLoss,
txDiagnosticLoss,
udpReceiveBufferSize,
udpSendBufferSize,
udpTTL,
idleTimeout,
validateAddress,
ipv6Only,
reusePort,
cc,
resetTokenSecret,
tokenSecret,
};
}
#newSession(handle) {
const session = new QuicSession(kPrivateConstructor, handle, this);
this.#inner.sessions.add(session);
// Set default pending datagram queue size.
session.maxPendingDatagrams = kDefaultMaxPendingDatagrams;
return session;
}
/**
* @param {EndpointOptions} config
*/
constructor(config = kEmptyObject) {
const options = this.#processEndpointOptions(config);
this.#handle = new Endpoint_(options);
this.#handle[kOwner] = this;
const inner = this.#inner;
inner.stats = new QuicEndpointStats(kPrivateConstructor, this.#handle.stats);
inner.state = new QuicEndpointState(kPrivateConstructor, this.#handle.state);
// Connection limits are stored in the shared state buffer so they
// can be read by C++ and mutated from JS after construction.
// Use the public setters which validate the range.
if (options.maxConnectionsPerHost !== undefined) {
this.maxConnectionsPerHost = options.maxConnectionsPerHost;
}
if (options.maxConnectionsTotal !== undefined) {
this.maxConnectionsTotal = options.maxConnectionsTotal;
}
endpointRegistry.add(this);
if (hasObserver('quic')) {
startPerf(this, kPerfEntry, { type: 'quic', name: 'QuicEndpoint' });
}
if (onEndpointCreatedChannel.hasSubscribers) {
onEndpointCreatedChannel.publish({
__proto__: null,
endpoint: this,
config,
});
}
debug('endpoint created');
}
/**
* Statistics collected while the endpoint is operational.
* @type {QuicEndpointStats}
*/
get stats() {
assertIsQuicEndpoint(this);
return this.#inner.stats;
}
get #isClosedOrClosing() {
return this.destroyed || this.#inner.isPendingClose;
}
/**
* When an endpoint is marked as busy, it will not accept new connections.
* Existing connections will continue to work.
* @type {boolean}
*/
get busy() {
assertIsQuicEndpoint(this);
return this.#inner.busy;
}
/**
* @type {boolean}
*/
set busy(val) {
assertIsQuicEndpoint(this);
assertEndpointNotClosedOrClosing(this);
// The val is allowed to be any truthy value
// Non-op if there is no change
const inner = this.#inner;
if (!!val !== inner.busy) {
debug('toggling endpoint busy status to ', !inner.busy);
inner.busy = !inner.busy;
this.#handle.markBusy(inner.busy);
if (onEndpointBusyChangeChannel.hasSubscribers) {
onEndpointBusyChangeChannel.publish({
__proto__: null,
endpoint: this,
busy: inner.busy,
});
}
}
}
/**
* Maximum concurrent connections per remote IP address.
* 0 means unlimited (default).
* @type {number}
*/
get maxConnectionsPerHost() {
assertIsQuicEndpoint(this);
return this.#inner.state.maxConnectionsPerHost;
}
set maxConnectionsPerHost(val) {
assertIsQuicEndpoint(this);
validateInteger(val, 'maxConnectionsPerHost', 0, 0xFFFF);
this.#inner.state.maxConnectionsPerHost = val;
}
/**
* Maximum total concurrent connections.
* 0 means unlimited (default).
* @type {number}
*/
get maxConnectionsTotal() {
assertIsQuicEndpoint(this);
return this.#inner.state.maxConnectionsTotal;
}
set maxConnectionsTotal(val) {
assertIsQuicEndpoint(this);
validateInteger(val, 'maxConnectionsTotal', 0, 0xFFFF);
this.#inner.state.maxConnectionsTotal = val;
}
/**
* The local address the endpoint is bound to (if any)
* @type {SocketAddress|undefined}
*/
get address() {
assertIsQuicEndpoint(this);
if (this.#isClosedOrClosing) return undefined;
if (this.#inner.address === undefined) {
const addr = this.#handle.address();
if (addr !== undefined) this.#inner.address = new InternalSocketAddress(addr);
}
return this.#inner.address;
}
/**
* Configures the endpoint to listen for incoming connections.
* @param {OnSessionCallback|SessionOptions} [onsession]
* @param {SessionOptions} [options]
*/
[kListen](onsession, options) {
assertEndpointNotClosedOrClosing(this);
assertEndpointIsNotBusy(this);
const inner = this.#inner;
if (inner.listening) {
throw new ERR_INVALID_STATE('Endpoint is already listening');
}
validateObject(options, 'options');
validateFunction(onsession, 'onsession');
this.#inner.onsession = FunctionPrototypeBind(onsession, this);
const {
onerror,
onstream,
ondatagram,
ondatagramstatus,
onpathvalidation,
onsessionticket,
onversionnegotiation,
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.
inner.sessionCallbacks = {
__proto__: null,
onerror,
onstream,
ondatagram,
ondatagramstatus,
onpathvalidation,
onsessionticket,
onversionnegotiation,
onhandshake,
onnewtoken,
onearlyrejected,
onorigin,
ongoaway,
onkeylog,
onqlog,
onapplication,
onheaders,
ontrailers,
oninfo,
onwanttrailers,
};
this.#handle.listen(rest);
inner.listening = true;
debug('endpoint listening as a server');
}
/**
* Initiates a session with a remote endpoint.
* @param {object} address
* @param {SessionOptions} [options]
* @returns {QuicSession}
*/
[kConnect](address, options) {
assertEndpointNotClosedOrClosing(this);
assertEndpointIsNotBusy(this);
validateObject(options, 'options');
const {
sessionTicket,
...rest
} = options;
debug('endpoint connecting as a client');
const handle = this.#handle.connect(address, rest, sessionTicket);
if (handle === undefined) {
throw new ERR_QUIC_CONNECTION_FAILED();
}
const session = this.#newSession(handle);
// Set callbacks before any async work to avoid missing events
// that fire during or immediately after the handshake.
applyCallbacks(session, options);
// Store the verifyPeer policy for use in the handshake handler.
if (options.verifyPeer !== undefined) {
session[kVerifyPeer] = options.verifyPeer;
}
return session;
}
/**
* Gracefully closes the endpoint. Any existing sessions will be permitted to
* end gracefully, after which the endpoint will be closed immediately. New
* sessions will not be accepted or created. The returned promise will be resolved
* when closing is complete, or will be rejected if the endpoint is closed abruptly
* due to an error.
* @returns {Promise<void>} Returns this.closed
*/
close() {
assertIsQuicEndpoint(this);
if (!this.#isClosedOrClosing) {
debug('gracefully closing the endpoint');
const inner = this.#inner;
inner.isPendingClose = true;
this.#handle.closeGracefully();
if (onEndpointClosingChannel.hasSubscribers) {
onEndpointClosingChannel.publish({
__proto__: null,
endpoint: this,
hasPendingError: inner.pendingError !== undefined,
});
}
}
return this.closed;
}
/**
* Returns a promise that is resolved when the endpoint is closed or rejects
* if the endpoint is closed abruptly due to an error. The closed property
* is set to the same promise that is returned by the close() method.
* @type {Promise<void>}
*/
get closed() {
assertIsQuicEndpoint(this);
return this.#inner.pendingClose.promise;
}
/**
* True if the endpoint is pending close.
* @type {boolean}
*/
get closing() {
assertIsQuicEndpoint(this);
return this.#inner.isPendingClose;
}
/** @type {boolean} */
get listening() {
assertIsQuicEndpoint(this);
return this.#inner.listening;
}
/** @type {boolean} */
get destroyed() {
assertIsQuicEndpoint(this);
return this.#handle === undefined;
}
/**
* Forcefully terminates the endpoint by immediately destroying all sessions
* after calling close. If an error is given, the closed promise will be
* rejected with that error. If no error is given, the closed promise will
* be resolved.
* @param {any} [error]
* @returns {Promise<void>} Returns this.closed
*/
destroy(error) {
assertIsQuicEndpoint(this);
debug('destroying the endpoint');
const inner = this.#inner;
// Record the error before deciding whether to initiate a close. If
// `close()` was already called (e.g. the user kicked off a graceful
// shutdown and then a fatal error was reported afterwards via
// `destroy(err)`) we still want that error to surface on
// `endpoint.closed` rather than being silently swallowed when the
// last in-flight session finishes draining. Only the *first* error
// is recorded, matching how other Node subsystems handle a
// double-error race.
if (error !== undefined) inner.pendingError ??= error;
// Force all sessions to be abruptly closed *before* signalling the
// endpoint to close gracefully. The order matters: each session's
// `destroy(error, options)` asks the C++ side to emit a
// `CONNECTION_CLOSE` frame via `endpoint.Send(...)`. Once the
// endpoint has entered its closing state (after `close()`) it
// can drop those outgoing packets, in which case the peer would
// never learn of the teardown until its own idle timer fires
// (pimterry's B8).
//
// Important: only pass close options to sessions whose handshake
// has actually completed. Pre-handshake sessions cannot create a
// valid CONNECTION_CLOSE packet on the C++ side; the fallback
// synchronously fires `EmitClose` -> JS `[kFinishClose]` ->
// `destroy()`, which trips the `#destroying` guard and leaves the
// C++ side asserting an inconsistent destroyed state.
const closeOptions = errorToCloseOptions(error);
for (const session of inner.sessions) {
// Mark each cascaded session's `closed` as handled before
// destroying it. This prevents unhandled-rejection warnings when
// the session is collateral damage from an endpoint-level destroy
// (e.g. a synchronous throw out of a user `onsession` callback
// routed through safeCallbackInvoke). The rejection is still
// observable to any caller that explicitly awaits `session.closed`.
markPromiseAsHandled(session.closed);
session.destroy(
error,
session[kHandshakeCompleted] ? closeOptions : undefined);
}
if (!this.#isClosedOrClosing) {
// Trigger a graceful close of the endpoint that'll ensure that the
// endpoint is closed down after all sessions are closed... All
// sessions were just forcefully destroyed above, so this should
// resolve promptly with nothing left to drain.
this.close();
}
return this.closed;
}
/**
* Replace or merge SNI TLS contexts for this endpoint. Each entry
* in the map is a host name to TLS identity options object. If
* replace is true, the entire SNI map is replaced. Otherwise, the
* provided entries are merged into the existing map.
* @param {object} entries
* @param {SNIContextOptions} [options]
*/
setSNIContexts(entries, options = kEmptyObject) {
assertIsQuicEndpoint(this);
if (this.#handle === undefined) {
throw new ERR_INVALID_STATE('Endpoint is destroyed');
}
validateObject(entries, 'entries');
const { replace = false } = options;
validateBoolean(replace, 'options.replace');
// Process each entry through the identity options validator,
// then build a full TLS options object (shared + identity).
const processed = { __proto__: null };
for (const hostname of ObjectKeys(entries)) {
validateString(hostname, 'entries key');
const identity = processIdentityOptions(entries[hostname],
`entries['${hostname}']`);
if (identity.keys.length === 0) {
throw new ERR_MISSING_ARGS(`entries['${hostname}'].keys`);
}
if (identity.certs === undefined) {
throw new ERR_MISSING_ARGS(`entries['${hostname}'].certs`);
}
processed[hostname] = identity;
}
this.#handle.setSNIContexts(processed, replace);
}
[kFinishClose](context, status) {
if (this.#handle === undefined) return;
debug('endpoint is finishing close', context, status);
endpointRegistry.delete(this);
this.#handle = undefined;
const inner = this.#inner;
inner.stats[kFinishClose]();
inner.state[kFinishClose]();
if (this[kPerfEntry] && hasObserver('quic')) {
stopPerf(this, kPerfEntry, {
detail: { stats: this.stats },
});
}
inner.address = undefined;
inner.busy = false;
inner.listening = false;
inner.isPendingClose = false;
// As QuicSessions are closed they are expected to remove themselves
// from the sessions collection. Just in case they don't, let's force
// it by resetting the set so we don't leak memory. Let's emit a warning,
// tho, if the set is not empty at this point as that would indicate a
// bug in Node.js that should be fixed.
if (inner.sessions.size > 0) {
process.emitWarning(
`The endpoint is closed with ${inner.sessions.size} active sessions. ` +
'This should not happen and indicates a bug in Node.js. Please open an ' +
'issue in the Node.js GitHub repository at https://github.com/nodejs/node ' +
'to report the problem.',
);
}
inner.sessions.clear();
// If destroy was called with an error, then the this.#pendingError will be
// set. Or, if context indicates an error condition that caused the endpoint
// to be closed, the status will indicate the error code. In either case,
// we will reject the pending close promise at this point.
const maybeCloseError = maybeGetCloseError(context, status, inner.pendingError);
if (maybeCloseError !== undefined) {
if (onEndpointErrorChannel.hasSubscribers) {
onEndpointErrorChannel.publish({
__proto__: null,
endpoint: this,
error: maybeCloseError,
});
}
inner.pendingClose.reject(maybeCloseError);
} else {
// Otherwise we are good to resolve the pending close promise!
inner.pendingClose.resolve();
}
if (onEndpointClosedChannel.hasSubscribers) {
onEndpointClosedChannel.publish({
__proto__: null,
endpoint: this,
stats: inner.stats,
});
}
// Note that we are intentionally not clearing the
// this.#pendingClose.promise here.
inner.pendingClose.resolve = undefined;
inner.pendingClose.reject = undefined;
inner.pendingError = undefined;
}
[kNewSession](handle) {
const inner = this.#inner;
assert(typeof inner.onsession === 'function',
'onsession callback not specified');
const session = this.#newSession(handle);
// Apply session callbacks stored at listen time before notifying
// the onsession callback, to avoid missing events that fire
// during or immediately after the handshake.
if (inner.sessionCallbacks) {
applyCallbacks(session, inner.sessionCallbacks);
}
if (onEndpointServerSessionChannel.hasSubscribers) {
onEndpointServerSessionChannel.publish({
__proto__: null,
endpoint: this,
session,
address: session.path?.remote,
});
}
// Route through safeCallbackInvoke so that a synchronous throw or a
// rejected promise from the user's onsession callback destroys this
// endpoint with the error rather than surfacing as an unhandled
// exception or unhandled rejection coming out of the C++ -> JS
// boundary.
safeCallbackInvoke(inner.onsession, this, session);
}
// Called by the QuicSession when it closes to remove itself from
// the active sessions tracked by the QuicEndpoint.
[kRemoveSession](session) {
this.#inner.sessions.delete(session);
}
[kInspect](depth, options) {
if (depth < 0) {
return 'QuicEndpoint { }';
}
const opts = {
__proto__: null,
...options,
depth: options.depth == null ? null : options.depth - 1,
};
const {
address,
busy,
isPendingClose: closing,
listening,
sessions,
stats,
state,
} = this.#inner;
return `QuicEndpoint ${inspect({
address,
busy,
closed: this.closed,
closing,
destroyed: this.destroyed,
listening,
sessions,
stats,
state,
}, opts)}`;
}
async [SymbolAsyncDispose]() { await this.close(); }
};
/**
* Find an existing endpoint from the registry that is suitable for reuse.
* @param {SocketAddress} [targetAddress] The address the client will connect
* to. If provided, endpoints that are listening on that same address are
* excluded to prevent CID namespace collisions (the client's initial DCID
* association would conflict with the server's session routing on the
* same endpoint).
* @returns {QuicEndpoint|undefined}
*/
function findSuitableEndpoint(targetAddress) {
for (const endpoint of endpointRegistry) {
if (!endpoint.destroyed &&
!endpoint.closing &&
!endpoint.busy) {
// Don't reuse an endpoint for a connection to itself.
if (targetAddress && endpoint.listening && endpoint.address &&
targetAddress.address === endpoint.address.address &&
targetAddress.port === endpoint.address.port) {
continue;
}
return endpoint;
}
}
return undefined;
}
/**
* Returns a list of all active endpoints.
* @param {object} [options]
* @param {boolean} [options.active] When true, only return endpoints that are not destroyed, closing, or busy.
* @returns {QuicEndpoint[]}
*/
function listEndpoints(options = kEmptyObject) {
validateObject(options, 'options');
const { active = true } = options;
validateBoolean(active, 'options.active');
if (!active) {
return ArrayFrom(endpointRegistry);
}
return ArrayFrom(endpointRegistry).filter((endpoint) => {
return !endpoint.destroyed && !endpoint.closing && !endpoint.busy;
});
}
/**
* @param {EndpointOptions|QuicEndpoint|undefined} endpoint
* @param {boolean} reuseEndpoint
* @param {boolean} forServer
* @param {SocketAddress} [targetAddress]
* @returns {QuicEndpoint}
*/
function processEndpointOption(endpoint,
reuseEndpoint = true,
forServer = false,
targetAddress) {
if (isQuicEndpoint(endpoint)) {
// We were given an existing endpoint. Use it as-is.
return endpoint;
}
if (endpoint !== undefined) {
// We were given endpoint options. If reuse is enabled, we could
// look for a matching endpoint, but endpoint options imply the
// caller wants specific configuration. Create a new one.
return new QuicEndpoint(endpoint);
}
// No endpoint specified. Try to reuse an existing one if allowed.
if (reuseEndpoint && !forServer) {
const existing = findSuitableEndpoint(targetAddress);
if (existing !== undefined) return existing;
}
return new QuicEndpoint();
}
/**
* Validate and extract identity options (keys, certs) from an SNI entry.
* CA and CRL are shared TLS options, not per-identity.
* @param {object} identity
* @param {string} label
* @returns {object}
*/
function processIdentityOptions(identity, label) {
const {
keys,
certs,
verifyPrivateKey = false,
} = identity;
if (certs !== undefined) {
const certInputs = ArrayIsArray(certs) ? certs : [certs];
for (const cert of certInputs) {
if (!isArrayBufferView(cert) && !isArrayBuffer(cert)) {
throw new ERR_INVALID_ARG_TYPE(`${label}.certs`,
['ArrayBufferView', 'ArrayBuffer'], cert);
}
}
}
const keyHandles = [];
if (keys !== undefined) {
const keyInputs = ArrayIsArray(keys) ? keys : [keys];
for (const key of keyInputs) {
if (isKeyObject(key)) {
if (getKeyObjectType(key) !== 'private') {
throw new ERR_INVALID_ARG_VALUE(`${label}.keys`, key,
'must be a private key');
}
ArrayPrototypePush(keyHandles, getKeyObjectHandle(key));
} else {
throw new ERR_INVALID_ARG_TYPE(`${label}.keys`, 'KeyObject', key);
}
}
}
validateBoolean(verifyPrivateKey, `${label}.verifyPrivateKey`);
return {
__proto__: null,
keys: keyHandles,
certs,
verifyPrivateKey,
};
}
/**
* @param {object} tls
* @param {boolean} forServer
* @returns {object}
*/
function processTlsOptions(tls, forServer) {
const {
servername,
alpn,
ciphers = DEFAULT_CIPHERS,
groups = DEFAULT_GROUPS,
keylog = false,
verifyClient = false,
rejectUnauthorized = true,
enableEarlyData = true,
tlsTrace = false,
sni,
// Client-only: identity options are specified directly (no sni map)
keys,
certs,
ca,
crl,
verifyPrivateKey = false,
} = tls;
if (servername !== undefined) {
validateString(servername, 'options.servername');
}
if (ciphers !== undefined) {
validateString(ciphers, 'options.ciphers');
}
if (groups !== undefined) {
validateString(groups, 'options.groups');
}
validateBoolean(keylog, 'options.keylog');
validateBoolean(verifyClient, 'options.verifyClient');
validateBoolean(rejectUnauthorized, 'options.rejectUnauthorized');
validateBoolean(enableEarlyData, 'options.enableEarlyData');
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 (ca !== undefined) {
const caInputs = ArrayIsArray(ca) ? ca : [ca];
for (const caCert of caInputs) {
if (!isArrayBufferView(caCert) && !isArrayBuffer(caCert)) {
throw new ERR_INVALID_ARG_TYPE('options.ca',
['ArrayBufferView', 'ArrayBuffer'],
caCert);
}
}
}
if (crl !== undefined) {
const crlInputs = ArrayIsArray(crl) ? crl : [crl];
for (const crlCert of crlInputs) {
if (!isArrayBufferView(crlCert) && !isArrayBuffer(crlCert)) {
throw new ERR_INVALID_ARG_TYPE('options.crl',
['ArrayBufferView', 'ArrayBuffer'],
crlCert);
}
}
}
// Shared TLS options (same for all identities on the endpoint).
const shared = {
__proto__: null,
servername,
alpn: encodedAlpn,
ciphers,
groups,
keylog,
verifyClient,
rejectUnauthorized,
enableEarlyData,
tlsTrace,
ca,
crl,
};
// For servers, identity options come from the sni map.
// The '*' entry is the optional default/fallback identity. If omitted,
// only connections with a servername matching a specific entry will
// succeed; all others will be rejected at the TLS level.
if (forServer) {
if (sni === undefined || typeof sni !== 'object') {
throw new ERR_MISSING_ARGS('options.sni');
}
// Must have at least one identity entry (wildcard or hostname-specific).
// A server with no identity at all cannot serve any connections.
const sniKeys = ObjectKeys(sni);
if (sniKeys.length === 0) {
throw new ERR_MISSING_ARGS('options.sni');
}
// Process the default ('*') identity if present.
let defaultIdentity = {};
if (sni['*'] !== undefined) {
defaultIdentity = processIdentityOptions(sni['*'], "options.sni['*']");
if (defaultIdentity.keys.length === 0) {
throw new ERR_MISSING_ARGS("options.sni['*'].keys");
}
if (defaultIdentity.certs === undefined) {
throw new ERR_MISSING_ARGS("options.sni['*'].certs");
}
}
// Build the SNI entries (excluding '*') as full TLS options objects.
// Each inherits the shared options and overrides the identity fields.
const sniEntries = { __proto__: null };
for (const hostname of sniKeys) {
if (hostname === '*') continue;
validateString(hostname, 'options.sni key');
const identity = processIdentityOptions(sni[hostname],
`options.sni['${hostname}']`);
if (identity.keys.length === 0) {
throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].keys`);
}
if (identity.certs === undefined) {
throw new ERR_MISSING_ARGS(`options.sni['${hostname}'].certs`);
}
// Extract ORIGIN frame options from the SNI entry.
const {
port,
authoritative,
} = sni[hostname];
// Build a full TLS options object: shared + identity + origin options.
sniEntries[hostname] = {
__proto__: null,
...shared,
...identity,
...(port !== undefined ? { port } : {}),
...(authoritative !== undefined ? { authoritative } : {}),
};
}
return {
__proto__: null,
...shared,
...defaultIdentity,
sni: sniEntries,
};
}
// For clients, identity options are specified directly (no sni map).
// CA and CRL are in the shared options, not per-identity.
const clientIdentity = processIdentityOptions({
keys, certs, verifyPrivateKey,
}, 'options');
return {
__proto__: null,
...shared,
...clientIdentity,
};
}
/**
* @param {'use'|'ignore'|'default'} policy
* @returns {number}
*/
/**
* Validate and normalize close error options for session.close() and
* session.destroy(). Returns the options object to pass to C++.
* @param {object} options
* @returns {object}
*/
function validateCloseOptions(options) {
validateObject(options, 'options');
const {
code,
type = 'transport',
reason,
} = options;
if (code !== undefined) {
if (typeof code !== 'bigint' && typeof code !== 'number') {
throw new ERR_INVALID_ARG_TYPE('options.code',
['bigint', 'number'], code);
}
}
validateOneOf(type, 'options.type', ['transport', 'application']);
if (reason !== undefined) {
validateString(reason, 'options.reason');
}
return { __proto__: null, code, type, reason };
}
function getPreferredAddressPolicy(policy = 'default') {
switch (policy) {
case 'use': return kPreferredAddressUse;
case 'ignore': return kPreferredAddressIgnore;
case 'default': return kPreferredAddressDefault;
}
throw new ERR_INVALID_ARG_VALUE('options.preferredAddressPolicy', policy);
}
/**
* @param {SessionOptions} options
* @param {ProcessSessionOptions} [config]
* @returns {SessionOptions}
*/
function processSessionOptions(options, config = kEmptyObject) {
validateObject(options, 'options');
const {
endpoint,
reuseEndpoint = true,
version,
minVersion,
preferredAddressPolicy = 'ignore',
transportParams = kEmptyObject,
qlog = false,
sessionTicket,
token,
maxPayloadSize,
unacknowledgedPacketThreshold = 0,
handshakeTimeout,
initialRtt,
keepAlive,
maxStreamWindow,
maxWindow,
cc,
datagramDropPolicy = 'drop-oldest',
drainingPeriodMultiplier = 3,
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.
onerror,
onstream,
ondatagram,
ondatagramstatus,
onpathvalidation,
onsessionticket,
onversionnegotiation,
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 {
forServer = false,
targetAddress,
} = config;
if (token !== undefined) {
if (!isArrayBufferView(token)) {
throw new ERR_INVALID_ARG_TYPE('options.token',
['ArrayBufferView'], token);
}
}
if (cc !== undefined) {
validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]);
}
validateOneOf(datagramDropPolicy, 'options.datagramDropPolicy',
['drop-oldest', 'drop-newest']);
validateOneOf(verifyPeer, 'options.verifyPeer',
['strict', 'auto', 'manual']);
validateInteger(drainingPeriodMultiplier, 'options.drainingPeriodMultiplier',
3, 255);
validateInteger(maxDatagramSendAttempts, 'options.maxDatagramSendAttempts',
1, 255);
// Validate preferred address in transport params if provided.
const { preferredAddressIpv4, preferredAddressIpv6 } = transportParams;
if (preferredAddressIpv4 !== undefined) {
if (!SocketAddress.isSocketAddress(preferredAddressIpv4)) {
throw new ERR_INVALID_ARG_TYPE(
'options.transportParams.preferredAddressIpv4',
'SocketAddress', preferredAddressIpv4);
}
if (preferredAddressIpv4.family !== 'ipv4') {
throw new ERR_INVALID_ARG_VALUE(
'options.transportParams.preferredAddressIpv4',
preferredAddressIpv4, 'must be an IPv4 address');
}
}
if (preferredAddressIpv6 !== undefined) {
if (!SocketAddress.isSocketAddress(preferredAddressIpv6)) {
throw new ERR_INVALID_ARG_TYPE(
'options.transportParams.preferredAddressIpv6',
'SocketAddress', preferredAddressIpv6);
}
if (preferredAddressIpv6.family !== 'ipv6') {
throw new ERR_INVALID_ARG_VALUE(
'options.transportParams.preferredAddressIpv6',
preferredAddressIpv6, 'must be an IPv6 address');
}
}
const actualEndpoint = processEndpointOption(endpoint,
reuseEndpoint,
forServer,
targetAddress);
return {
__proto__: null,
endpoint: actualEndpoint,
version,
minVersion,
preferredAddressPolicy: getPreferredAddressPolicy(preferredAddressPolicy),
transportParams: {
...transportParams,
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
},
tls: {
...processTlsOptions(options, forServer),
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
// completes regardless and the result is handled in JS.
verifyPeerStrict: verifyPeer === 'strict',
// Enable hostname verification for 'strict' and 'auto' modes.
// SSL_set1_host tells OpenSSL to verify the server certificate's
// SAN/CN matches the servername. Without this, a valid cert for
// any domain would be accepted.
verifyHostname: verifyPeer !== 'manual',
},
verifyPeer,
qlog,
maxPayloadSize,
unacknowledgedPacketThreshold,
handshakeTimeout,
initialRtt,
keepAlive,
maxStreamWindow,
maxWindow,
sessionTicket,
token,
cc,
datagramDropPolicy,
drainingPeriodMultiplier,
maxDatagramSendAttempts,
streamIdleTimeout,
application,
onerror,
onstream,
ondatagram,
ondatagramstatus,
onpathvalidation,
onsessionticket,
onversionnegotiation,
onhandshake,
onnewtoken,
onearlyrejected,
onorigin,
ongoaway,
onkeylog,
onqlog,
onapplication,
onheaders,
ontrailers,
oninfo,
onwanttrailers,
};
}
// ============================================================================
/**
* @param {OnSessionCallback} callback
* @param {SessionOptions} [options]
* @returns {Promise<QuicEndpoint>}
*/
async function listen(callback, options = kEmptyObject) {
validateFunction(callback, 'callback');
const {
endpoint,
...sessionOptions
} = processSessionOptions(options, { forServer: true });
endpoint[kListen](callback, sessionOptions);
if (onEndpointListeningChannel.hasSubscribers) {
onEndpointListeningChannel.publish({
__proto__: null,
endpoint,
options,
});
}
return endpoint;
}
/**
* @param {string|SocketAddress} address
* @param {SessionOptions} [options]
* @returns {Promise<QuicSession>}
*/
async function connect(address, options = kEmptyObject) {
if (typeof address === 'string') {
address = SocketAddress.parse(address);
}
if (!SocketAddress.isSocketAddress(address)) {
if (address == null || typeof address !== 'object') {
throw new ERR_INVALID_ARG_TYPE('address', ['SocketAddress', 'string'], address);
}
address = new SocketAddress(address);
}
const {
endpoint,
...rest
} = processSessionOptions(options, { targetAddress: address });
if (onEndpointConnectChannel.hasSubscribers) {
onEndpointConnectChannel.publish({
__proto__: null,
endpoint,
address,
options,
});
}
const session = endpoint[kConnect](address[kSocketAddressHandle], rest);
if (onEndpointClientSessionChannel.hasSubscribers) {
onEndpointClientSessionChannel.publish({
__proto__: null,
endpoint,
session,
address,
options,
});
}
return session;
}
ObjectDefineProperties(QuicEndpoint, {
Stats: {
__proto__: null,
writable: false,
configurable: false,
enumerable: true,
value: QuicEndpointStats,
},
});
ObjectDefineProperties(QuicSession, {
Stats: {
__proto__: null,
writable: false,
configurable: false,
enumerable: true,
value: QuicSessionStats,
},
});
ObjectDefineProperties(QuicStream, {
Stats: {
__proto__: null,
writable: false,
configurable: false,
enumerable: true,
value: QuicStreamStats,
},
});
// ============================================================================
module.exports = {
listen,
connect,
QuicEndpoint,
QuicError,
QuicSession,
QuicStream,
CC_ALGO_RENO,
CC_ALGO_CUBIC,
CC_ALGO_BBR,
DEFAULT_CIPHERS,
DEFAULT_GROUPS,
// These are exported only for internal testing purposes.
getQuicStreamState,
getQuicSessionState,
getQuicEndpointState,
listEndpoints,
};
/* c8 ignore stop */