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:
- W3C WebTransport: https://w3c.github.io/webtransport/
- WebTransport over HTTP/3: https://datatracker.ietf.org/doc/draft-ietf-webtrans-http3/
- HTTP Datagrams: https://www.rfc-editor.org/rfc/rfc9297
- QUIC DATAGRAM: https://www.rfc-editor.org/rfc/rfc9221
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
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,
);