js/net/http/webtransport

js/net/http/webtransport.ts

fino:net/http/webtransport - WebTransport over HTTP/3.

The WebTransport class mirrors the W3C WebTransport object model while using Fino's HTTP/3 and QUIC implementation underneath. Both WebTransport and WebTransportDatagramDuplexStream are also installed as globals during bootstrap, so browser-style code that references the bare WebTransport constructor works unchanged. Applications may construct it directly with an HTTPS URL or receive an already-connected instance from HttpClient.webtransport(), HttpSession.webtransport(), or server-side IncomingWebTransportRequest.accept().

A directly constructed transport enters its setup flow immediately: it opens an HTTP/3 connection to the URL origin and issues an extended CONNECT request. ready resolves with undefined after the CONNECT succeeds and any serverCertificateHashes pins validate against the peer certificate. closed resolves with { closeCode, reason } on clean shutdown and rejects when setup or a transport error aborts the session.

Datagrams use the standard WebTransportDatagramDuplexStream shape. Outgoing writes are framed as HTTP Datagrams bound to the session's CONNECT stream; incoming datagrams belonging to other sessions on the same QUIC connection are filtered out. Incoming streams arrive with a WebTransport stream-type prefix, are matched to the session, and are exposed as ReadableStreams of standard receive/bidirectional stream wrappers.

import { WebTransport } from 'fino:net/http/webtransport';

const wt = new WebTransport('https://example.test/session');
await wt.ready;

const writer = wt.datagrams.createWritable().getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
writer.releaseLock();

const stream = await wt.createBidirectionalStream();
await stream.writable.getWriter().write(new Uint8Array([4, 5]));

wt.close({ closeCode: 0, reason: 'done' });

Learn more:

Interfaces

interface WebTransportCloseInfo {

Information reported when a WebTransport closes cleanly.

This is the resolution value of WebTransport.closed and the argument shape accepted by WebTransport.close(). On a remote close the values come from the underlying connection's close information; on a local close() call they echo the caller's arguments.

const { closeCode, reason } = await wt.closed;
console.log(`session ended: ${closeCode} ${reason}`);

Properties

closeCode?: number

Application close code. Defaults to 0.

reason?: string

Human-readable close reason. Defaults to the empty string.

interface WebTransportHash {

Server certificate hash pin accepted by the standard constructor options.

When one or more hashes are supplied via WebTransportOptions.serverCertificateHashes, session setup digests the peer's certificate and requires at least one pin to match before ready resolves. Only SHA-256 is supported; other algorithms cause setup to fail with a TypeError.

const hash: WebTransportHash = {
  algorithm: 'sha-256',
  value: await crypto.subtle.digest('SHA-256', certificateDer),
};

Properties

algorithm: string

Digest algorithm name. Only 'sha-256' (case/underscore-insensitive) is accepted.

value: BufferSource

Expected digest of the peer's DER-encoded certificate.

interface WebTransportOptions {

Options accepted by new WebTransport() and Fino HTTP helpers.

This matches the standard WebTransportOptions dictionary plus Fino's headers extension for the CONNECT request. serverCertificateHashes is validated during setup, congestionControl is recorded and exposed via the congestionControl getter, and protocols is sent as the sec-webtransport-protocol request header so the server can pick an application protocol. The remaining standard members are accepted for compatibility but are currently advisory: they do not change connection behavior.

const wt = new WebTransport('https://example.test/session', {
  protocols: ['chat-v2', 'chat-v1'],
  congestionControl: 'low-latency',
  serverCertificateHashes: [{ algorithm: 'sha-256', value: pinnedHash }],
});

Properties

allowPooling?: boolean

Standard pooling hint. Accepted for compatibility; currently advisory.

requireUnreliable?: boolean

Standard requirement that the connection support datagrams. Accepted for compatibility; currently advisory.

headers?: Headers | Record<string, string>

Extra headers to send on the extended CONNECT request. Fino extension.

serverCertificateHashes?: readonly WebTransportHash[]

Certificate pins checked against the peer certificate before ready resolves.

congestionControl?: 'default' | 'throughput' | 'low-latency'

Congestion control preference, surfaced through the congestionControl getter.

anticipatedConcurrentIncomingUnidirectionalStreams?: number | null

Standard concurrency hint. Accepted for compatibility; currently advisory.

anticipatedConcurrentIncomingBidirectionalStreams?: number | null

Standard concurrency hint. Accepted for compatibility; currently advisory.

protocols?: readonly string[]

Application protocols offered via the sec-webtransport-protocol header.

datagramsReadableType?: 'bytes'

Standard datagram readable-type selector. Only 'bytes' exists; currently advisory.

interface WebTransportSendStreamStats {

Statistics reported by WebTransportSendStream.getStats().

const { bytesWritten } = await sendStream.getStats();

Properties

bytesWritten: number

Total payload bytes written to the stream so far.

interface WebTransportReceiveStreamStats {

Statistics reported by WebTransportReceiveStream.getStats().

const { bytesRead } = await receiveStream.getStats();

Properties

bytesRead: number

Total payload bytes read from the stream so far.

interface WebTransportStats {

Session-level statistics reported by WebTransport.getStats().

Values come from the underlying QUIC connection when it exposes stats; otherwise they fall back to counters tracked by the transport itself.

const stats = await wt.getStats();
console.log(stats.datagramsSent, stats.bytesReceived);

Properties

datagramsSent: number

Number of datagrams sent on this session.

datagramsReceived: number

Number of datagrams received for this session.

bytesSent: number

Bytes sent, when the underlying connection reports it; otherwise 0.

bytesReceived: number

Bytes received in datagrams for this session.

interface WebTransportBidirectionalStream {

Standard bidirectional stream wrapper.

Returned by createBidirectionalStream() and enqueued on incomingBidirectionalStreams. The readable and writable halves operate independently: closing one direction does not affect the other.

const stream = await wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
await writer.write(new Uint8Array([1]));
const reader = stream.readable.getReader();
const { value } = await reader.read();

Readonly Properties

readonly readable: WebTransportReceiveStream

Data flowing from the peer to this endpoint.

readonly writable: WebTransportSendStream

Data flowing from this endpoint to the peer.

Types

type WebTransportSendStream = WritableStream<Uint8Array> & { sendGroup: string | null; sendOrder: number | null; getStats(): Promise<WebTransportSendStreamStats>; }

Writable side of a WebTransport stream.

A WritableStream<Uint8Array> extended with the standard sendGroup / sendOrder properties (currently always null, send-order prioritization is not applied) and a getStats() method reporting bytes written. Aborting the stream resets the underlying QUIC stream when the backend supports it and throws otherwise.

const send = await wt.createUnidirectionalStream();
const writer = send.getWriter();
await writer.write(new TextEncoder().encode('hello'));
await writer.close();
console.log(await send.getStats());

type WebTransportReceiveStream = ReadableStream<Uint8Array> & { getStats(): Promise<WebTransportReceiveStreamStats>; }

Readable side of a WebTransport stream.

A ReadableStream<Uint8Array> extended with a getStats() method reporting bytes read. The stream closes when the peer finishes the QUIC stream.

for await (const chunk of receiveStream) {
  console.log('received', chunk.byteLength, 'bytes');
}

Classes

class WebTransportDatagramDuplexStream {

Standard datagram duplex stream facade.

Exposed as WebTransport.datagrams (and installed as a global constructor for API-shape compatibility). Incoming session datagrams are enqueued on readable; outgoing datagrams are written through writers created with createWritable(). Each write is delivered unreliably as a single QUIC DATAGRAM frame, so payloads must fit in maxDatagramSize - oversized writes reject with a RangeError.

When the owning transport closes, readable closes and further writes reject; when the transport fails, readable errors with the failure.

const writer = wt.datagrams.createWritable().getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
writer.releaseLock();

const reader = wt.datagrams.readable.getReader();
const { value } = await reader.read();

Properties

incomingMaxAge: number | null

Standard incoming max-age knob in milliseconds. Currently advisory; expiry is not enforced.

outgoingMaxAge: number | null

Standard outgoing max-age knob in milliseconds. Currently advisory; expiry is not enforced.

incomingMaxBufferedDatagrams

Standard incoming buffer-depth knob. Currently advisory; the queue is not trimmed.

outgoingMaxBufferedDatagrams

Standard outgoing buffer-depth knob. Currently advisory; writes are delivered immediately.

Constructors

constructor(send: DatagramSink, maxDatagramSize = 65535)

Create a duplex facade that delivers outgoing datagrams through send.

Constructed internally by WebTransport; applications normally reach an instance through wt.datagrams rather than constructing one directly.

Getters

get readable(): ReadableStream<Uint8Array>

Stream of incoming datagram payloads for this session, one Uint8Array per datagram.

get maxDatagramSize(): number

Largest payload, in bytes, accepted by a single datagram write.

Methods

createWritable(_options: { sendOrder?: number; } = {}): WritableStream<Uint8Array>

Create a writable stream that sends each chunk as one datagram.

Writes reject with a RangeError when the chunk exceeds maxDatagramSize, and with an Error after the session has closed. The sendOrder option is accepted for standard shape but not currently applied.

const writer = wt.datagrams.createWritable().getWriter();
await writer.write(new Uint8Array([0xca, 0xfe]));
[kDatagramPush](data: Uint8Array): void

Enqueue an incoming datagram payload. Called by the owning transport; not part of the standard surface.

[kDatagramClose](): void

Close the incoming readable and reject further writes. Called by the owning transport on clean close.

[kDatagramError](error: unknown): void

Error the incoming readable and reject further writes. Called by the owning transport on failure.

[kDatagramStats](): { datagramsSent: number; datagramsReceived: number; }

Report locally counted datagram totals, used as a fallback by WebTransport.getStats().

class WebTransport {

WebTransport session over HTTP/3.

Constructing an instance starts session setup immediately: an HTTP/3 client is opened against the URL origin and an extended CONNECT request is issued for the URL path. ready settles when setup completes; closed settles when the session ends. Setup failures reject ready, closed, and draining with the same error, and error the datagram and incoming-stream readables, so every consumption path observes the failure.

Server code and Fino's HTTP client hand out already-connected instances, so direct construction is mainly for browser-style client code.

Throws a TypeError from the constructor when the URL is not https:.

import { WebTransport } from 'fino:net/http/webtransport';

const wt = new WebTransport('https://example.test/session', {
  protocols: ['chat-v1'],
});
await wt.ready;
console.log(wt.protocol);

const reader = wt.incomingUnidirectionalStreams.getReader();
const { value: incoming } = await reader.read();

wt.close({ closeCode: 0, reason: 'bye' });

Constructors

constructor(url: string | URL, options: WebTransportOptions = {}, connect = true)

Create a transport for url and begin connecting.

The URL must use the https: scheme or a TypeError is thrown. Passing connect = false skips the self-connect flow and leaves the object detached; the internal factories use this to attach state themselves.

Static Methods

static unavailable( url: string | URL, reason = 'WebTransport over HTTP/3 is not available' ): WebTransport

Create a failed transport object for fail-fast unavailable paths.

The returned instance is already in the failed state: ready, closed, and draining reject with an Error carrying reason, and all stream surfaces are errored. Used where WebTransport support is known to be missing (for example, no HTTP/3 stack) but a standards-shaped object must still be returned.

const wt = WebTransport.unavailable('https://example.test/wt', 'no h3 support');
await wt.ready; // rejects with 'no h3 support'

Getters

get url(): string

Normalized session URL this transport was created for.

get ready(): Promise<void>

Resolves with undefined once the session is established.

Establishment requires the extended CONNECT to succeed and any serverCertificateHashes pins to validate. Rejects if setup fails or the transport was created via unavailable().

get closed(): Promise<WebTransportCloseInfo>

Settles when the session ends.

Resolves with WebTransportCloseInfo on a clean close - whether initiated locally via close() or by the peer - and rejects with the failure error when setup fails or the transport errors.

wt.closed.then(
  ({ closeCode, reason }) => console.log('closed', closeCode, reason),
  (error) => console.error('failed', error),
);
get draining(): Promise<void>

Resolves when the session begins shutting down.

In the current implementation this settles together with closed: it resolves on clean close and rejects on transport failure.

get datagrams(): WebTransportDatagramDuplexStream

Datagram duplex stream for unreliable, unordered messaging on this session.

get incomingBidirectionalStreams(): ReadableStream<WebTransportBidirectionalStream>

Stream of bidirectional streams opened by the peer.

Each entry is a WebTransportBidirectionalStream whose stream-type prefix matched this session. Closes on clean shutdown; errors on failure.

const reader = wt.incomingBidirectionalStreams.getReader();
const { value: stream, done } = await reader.read();
get incomingUnidirectionalStreams(): ReadableStream<WebTransportReceiveStream>

Stream of unidirectional receive streams opened by the peer.

Closes on clean shutdown; errors on failure.

get responseHeaders(): Headers | null

Headers from the CONNECT response, or null before the session connects.

Fino extension beyond the W3C surface; useful for reading negotiated metadata such as sec-webtransport-http3-draft.

get protocol(): string

Application protocol negotiated via sec-webtransport-protocol.

Empty string when no protocol was offered or the server chose none.

get reliability(): 'pending' | 'reliable-only' | 'supports-unreliable'

Whether the session can carry unreliable datagrams. Fino's HTTP/3 stack reports 'supports-unreliable'.

get congestionControl(): 'default' | 'throughput' | 'low-latency'

Congestion control preference recorded from the constructor options.

get supportsReliableOnly(): boolean

Standard flag indicating a reliable-only transport; always false for HTTP/3 sessions.

Methods

async createBidirectionalStream(): Promise<WebTransportBidirectionalStream>

Open a bidirectional stream to the peer.

The WebTransport stream-type prefix carrying this session's id is written before the returned stream is handed out, so callers can write payload bytes immediately. Throws if the session is not connected or the underlying connection cannot open bidirectional streams.

const stream = await wt.createBidirectionalStream();
const writer = stream.writable.getWriter();
await writer.write(new TextEncoder().encode('ping'));
const { value } = await stream.readable.getReader().read();
async createUnidirectionalStream(): Promise<WebTransportSendStream>

Open a send-only stream to the peer.

Like createBidirectionalStream(), the session prefix is written before the stream is returned. Throws if the session is not connected or the underlying connection cannot open unidirectional streams.

const send = await wt.createUnidirectionalStream();
const writer = send.getWriter();
await writer.write(new Uint8Array([1, 2, 3]));
await writer.close();
close(info: WebTransportCloseInfo = {}): void

Close the session cleanly.

Settles the transport's local state: the datagram readable and both incoming stream queues close, draining resolves, and closed resolves with the provided close info (defaulting to code 0 and an empty reason). Calling close() on an already-closed transport is a no-op.

async getStats(): Promise<WebTransportStats>

Snapshot session-level statistics.

Prefers counters reported by the underlying QUIC connection; when a field is unavailable it falls back to the transport's own datagram counters (bytesSent falls back to 0).

async exportKeyingMaterial( label: string, context: BufferSource, length: number ): Promise<ArrayBuffer>

Derive keying material from the session's TLS exporter.

Waits for the session to be ready, then invokes the TLS exporter (RFC 8446 / RFC 5705) on the QUIC connection. Both peers calling this with the same label, context, and length obtain identical bytes, which makes it useful for binding application-level secrets to the connection. Throws if the QUIC TLS backend does not expose an exporter.

const secret = await wt.exportKeyingMaterial(
  'my-app token',
  new TextEncoder().encode('context'),
  32,
);