transport-io API
The TypeScript surface as it exists today. Every signature below is the real declaration.
Every snippet in this document is extracted and typechecked against the built package in CI. When the API changes, the docs stop compiling and the build breaks.
1. defineContract
The contract is the single source of truth. Reading contract.ts tells you every event,
payload and lane in the application without reading anything else.
A lane is a guarantee. reliable means the message arrives, in order; it is carried on a
QUIC stream. unreliable means it may be dropped, duplicated or reordered; it is carried on
a QUIC datagram.
import { defineContract, type MapOf, reliable, rpc, streaming, unreliable } from 'transport-io'
export const contract = defineContract({ chat: reliable<{ room: string; body: string }>(), cursor: unreliable<{ x: number; y: number }>(), save: rpc<{ text: string }, { revision: number }>(), ask: streaming<{ prompt: string }, string>(),})
export interface AppMap extends MapOf<typeof contract> {}reliable and unreliable take the payload. rpc and streaming take the payload and
what comes back: save answers with one value, ask with a sequence.
AppMap is passed at each construction site, which is what every example on this page does.
Registering it globally makes that argument implicit and is opt-in; see §7.
Write both lines. Without the second, every hover shows the whole contract with your
validator’s internals in it. The measurement is in DECISIONS.md, D57 and D100.
The lane lives in the contract, never at the call site. The guarantee belongs to the message type, so client and server cannot disagree about it.
1.1 A type, or a schema
Every helper takes either a type argument or a schema, and both give the same payload types to the rest of the application. What differs is what happens at runtime.
A type argument, as in the contract above, is types-only: nothing checks it and it costs nothing at runtime. A peer that sends the wrong shape reaches your handler.
A schema validates every inbound payload on arrival, at one check per message. payload
accepts anything implementing the Standard Schema interface - zod, valibot and arktype all
do - and core has no runtime dependency on a validator:
import { defineContract, type MapOf, reliable, rpc } from 'transport-io'import { z } from 'zod'
export const validated = defineContract({ chat: reliable(z.object({ room: z.string(), body: z.string().max(2000) })), save: rpc(z.object({ text: z.string() }), z.object({ revision: z.number() })),})
export interface ValidatedMap extends MapOf<typeof validated> {}A payload the schema rejects never reaches a handler; the sender’s call fails with
WT_VALIDATION_FAILED and an emit is dropped. Use a schema wherever a peer you do not
control can reach, which for a server is every client. Use a type argument where both ends
are yours and the traffic is high.
type$<T>() is the same types-only schema the helpers build for you, exported for the
object form below.
Inbound payloads are validated; outbound are not.
Or bytes. bytes() declares a slot whose value is a Uint8Array on both ends and bytes
on the wire, under its own codec, never through JSON: a Yjs update, an image chunk,
anything already encoded. It fits any slot of any helper, alone or beside a schema:
import { bytes, defineContract, type MapOf, reliable, rpc, streaming } from 'transport-io'import { z } from 'zod'
export const binary = defineContract({ update: reliable(bytes()), snapshot: rpc(z.object({ since: z.number() }), bytes()), chunks: streaming(bytes(), bytes()),})
export interface BinaryMap extends MapOf<typeof binary> {}A payload is JSON or bytes, never a mix: bytes inside an object are still JSON. Sending
anything but a Uint8Array to a bytes slot fails before the wire with
WT_VALIDATION_FAILED, and the value handed to a handler is a copy the application owns.
1.2 Event identity
An event’s wire id is the first four bytes of SHA-256 of its name, so adding or removing
an event changes no existing identifier and a contract change survives a rolling deploy.
Two names whose hashes collide are a build-time error naming both events; set an explicit
id on one rather than renaming your domain language.
export const withOverride = defineContract({ chat: { ...reliable<{ body: string }>(), id: 0x31e06f7d },})1.3 The object form
A helper returns a plain object, and defineContract accepts one written out. This is the
form to use when a contract is assembled programmatically, where a helper call cannot be
written literally:
import { defineContract, type MapOf, type$ } from 'transport-io'
export const explicit = defineContract({ chat: { lane: 'reliable', payload: type$<{ body: string }>() }, cursor: { lane: 'unreliable', payload: type$<{ x: number; y: number }>() }, save: { lane: 'reliable', payload: type$<{ text: string }>(), returns: type$<number>() }, ask: { lane: 'reliable', payload: type$<{ prompt: string }>(), yields: type$<string>() },})
export interface ExplicitMap extends MapOf<typeof explicit> {}rpc and streaming are both lane: 'reliable': a call and a stream are carried on their
own QUIC stream, so there is no unreliable variant of either. The rest of this page uses the
helpers.
1.4 What an unreliable event accepts on a fallback
A fallback transport carries the reliable lane only. An unreliable event says what it accepts there beside its lane, in the contract:
import { defineContract, type MapOf, reliable, unreliable } from 'transport-io'
export const declared = defineContract({ chat: reliable<{ from: string; body: string }>(), cursor: unreliable<{ x: number; y: number }>({ fallback: 'newest' }),})
export interface DeclaredMap extends MapOf<typeof declared> {}'newest' is carried on the reliable pipe with the oldest frame dropped on overflow and stale
frames dropped at dequeue, exactly as the datagram ring drops them, delivered in order and
counted in the same overflowDropped and staleDropped. It is the only policy. The object
form takes the same fallback field, and a reliable event cannot carry one.
An event that declares nothing has consented to nothing. A contract that contains one cannot be wired to a fallback at all: the line that adds the fallback fails to compile and names the event (§2.5).
1.5 Direction
Most events travel both ways under one name. An event only one side sends can say so, and
the other side’s emit then refuses it in the types, the sender’s on refuses to listen for
it, and a peer that sends it the wrong way anyway is dropped at the receiver and counted in
stats().directionDropped:
import { defineContract, fromClient, fromServer, type MapOf, reliable, unreliable } from 'transport-io'
export const directed = defineContract({ chat: reliable<{ from: string; body: string }>(), users: fromServer(reliable<{ names: readonly string[] }>()), cursor: fromClient(unreliable<{ x: number; y: number }>({ fallback: 'newest' })),})
export interface DirectedMap extends MapOf<typeof directed> {}fromServer and fromClient wrap reliable or unreliable, with a fallback declaration
or without; a call or a stream cannot take one, since a client asks and a server answers.
MapOf carries the direction as from, and SentBy<M, side> and ReceivedBy<M, side> are
the event names each side may send and receive. A caller with no compiler meets the same
refusal at emit as WT_VALIDATION_FAILED.
This removes the cost of the harmless case, an event the client could send and the server never listens for. It does not remove the modelling tax: an event both sides carry still has one payload shape for both directions.
2. Client
Each transport module exports a construct-and-connect function that resolves to a connected client. This is what an application writes:
import { browserClient } from 'transport-io/browser-transport'
export async function open(url: string): Promise<void> { const client = await browserClient<AppMap>({ contract, url }) client.emit('chat', { room: 'lobby', body: 'hello' }) client.disconnect()}| function | module | connection options |
|---|---|---|
browserClient<M>(options) | transport-io/browser-transport | url, certificateHash?, probe? |
devClient<M>(options) | transport-io/dev-transport | endpoint?, query? |
http3Client<M>(options) | transport-io/node-transport | url, certificateHash, probe? |
Each takes every ClientOptions field except connect, plus its transport’s own options.
query on devClient and connectDev is added to the WebTransport URL’s query, which is
where a listener’s authorize reads a token (§3.3) and which the page cannot otherwise
reach, since the URL comes from the dev manifest. An object, a URLSearchParams, or a
function returning either, sync or async. The function is called on every attempt, the first
and each reconnect, so a token refreshed since the last one is the one sent.
import { devClient, fetchDevManifest } from 'transport-io/dev-transport'
declare function currentToken(): Promise<string>
export const signedIn = await devClient<AppMap>({ contract, reconnect: { minMs: 500, maxMs: 30_000 }, query: async () => ({ token: await currentToken() }),})
export const manifest = await fetchDevManifest()fetchDevManifest(options?) is the fetch connectDev makes, checked the same way: { sha256, url, expiresAt? }, loopback only, WT_CERT_EXPIRED for a certificate past its validity. It
is for development tooling that needs the hash or the URL without connecting. Outside a
browser there is no page origin, so endpoint has to be an absolute loopback URL.
M is never inferred from contract (D100). Omitting the argument falls to Registered:
either the application registered a map, or the first emit fails with the sentence naming
the fix.
Which module you import decides where the code runs. transport-io/browser-transport uses
the platform’s own WebTransport and loads nothing native. transport-io/node-transport
loads the QUIC binding and must only be imported from a file named *.node.ts - a lint rule
enforces that, because the binding segfaults Bun on exit.
2.0 Assembling it yourself
new Client({ contract, connect }) takes the seam directly. The one-call form hands back a
client that is already connected, so anything that needs the client before that constructs it
itself.
Three cases qualify:
- A transport of your own.
connectis the seam. Supply any function returning aConnection. - React.
TransportProvidertakes an unconnected client and connects it in an effect, so the client has to exist synchronously, inside auseStateinitialiser. - Rendering connection state.
connectingis observable only if you are holding the thing doing the connecting. Both pages inexamples/chatshow a status indicator, and use the seam form for exactly that reason.
import { Client, type ClientOptions } from 'transport-io'
// Supplied by the transport seam, so this module never imports a transport.declare const openConnection: ClientOptions['connect']
export const client = new Client<AppMap>({ contract, connect: openConnection })
export async function main(): Promise<void> { await client.connect() client.emit('chat', { room: 'lobby', body: 'hello' }) client.emit('cursor', { x: 12, y: 40 }) const off = client.on('chat', (payload) => { console.log(payload.room, payload.body) }) off()}emit is fire and forget on both lanes; which lane is decided by the contract. A wrong
event name or a wrong payload shape fails to compile, and the error names the event:
Argument of type '"chatt"' is not assignable to parameter of type '"chat" | "cursor"'.new Client(...) performs no I/O. It touches neither window nor WebTransport, so
importing this module on a server - which Next.js will do - is safe. Feature detection
happens inside connect().
connect() and disconnect() are idempotent and refcounted, so two components sharing
one client cannot tear down each other’s connection. React StrictMode mounts twice in
development.
2.1 call()
An event declaring returns is callable. Each call opens its own bidirectional stream, so a
stalled call blocks nothing else.
export async function saveIt(): Promise<number> { const { revision } = await client.call('save', { text: 'hello' }) return revision}There is no default timeout. A peer that vanishes with no close is noticed by the transport, within about 30 seconds; the session then closes and every pending call rejects. For a slow but live responder:
export async function saveWithDeadline(): Promise<number> { const res = await client.call('save', { text: 'hi' }, { signal: AbortSignal.timeout(5_000) }) return res.revision}Aborting resets the QUIC stream. It is immediate, costs no application message, and the
responder’s ctx.signal fires without the client sending anything.
A session is capped at 256 concurrent streams, shared by call() and stream(). The 257th
open is refused with WT_TOO_MANY_STREAMS and the session stays up. A call holds its
slot for a round trip; a
stream() holds one for as long as it runs, which is the unit that matters once §2.3 is in
use.
2.2 Responding
import { createServer } from 'transport-io'
export async function serve(): Promise<void> { const server = createServer<AppMap>({ contract }) server.handle('save', async ({ text }) => ({ revision: text.length })) await server.listen()}ctx.signal fires when the caller aborts. A handler that returns promptly does not need to
consult it; one that does long work should, so the work stops when nobody is waiting for it.
ctx.peer is the ServerPeer that made the call. A responder is registered once and answers
every peer, so this is the only thing that says who is asking, and it is what lets a call
join its own caller to a room:
import type { Server } from 'transport-io'
export function installSave(server: Server<AppMap>): void { server.handle('save', async ({ text }, ctx) => { // The caller is known here, so the responder can act on it. await ctx.peer.join('editors') return { revision: text.length } })}peer.id is a value the server assigned itself and identifies nobody. Authenticate the
payload, then act on the peer.
A handler that throws produces a CALL_ERROR frame. Throw a TransportError to choose the
code; anything else becomes WT_HANDLER_ERROR.
2.3 stream()
An event declaring yields instead of returns answers with a sequence. The client
gets an async iterable, the server writes an async generator.
import type { Server } from 'transport-io'
export async function serveTokens(server: Server<AppMap>): Promise<void> { server.handle('ask', async function* ({ prompt }) { for (const token of prompt.split(' ')) { yield token } })}
export async function render(show: (token: string) => void): Promise<void> { for await (const token of client.stream('ask', { prompt: 'one two three' })) { show(token) }}The loop ends when the server stops. The handler above never consults ctx.signal; the
responder checks before asking the generator for another value.
Three ways to stop early, and a stopped stream is a QUIC stream reset either way: the
handler’s ctx.signal fires and any finally inside the generator runs.
break, when something inside the loop decides:
export async function untilAnswered(): Promise<string> { let text = '' for await (const token of client.stream('ask', { prompt: 'yes or no?' })) { text += token if (/\b(yes|no)\b/i.test(text)) break } return text}cancel(), when the decision is made outside the loop, which is what a stop button is:
export async function stoppable(stop: { onclick: () => void }): Promise<void> { const gen = client.stream('ask', { prompt: 'a b c' }) stop.onclick = () => gen.cancel() await gen.forEach(async (token) => void console.log(token))}An AbortSignal, for a deadline:
export async function withDeadline(): Promise<number> { let n = 0 const s = client.stream('ask', { prompt: 'a b c' }, { signal: AbortSignal.timeout(5_000) }) for await (const _ of s) n++ return n}Helpers. toArray() takes the whole sequence. forEach(fn) awaits fn before pulling
the next element, so a slow callback slows the producer. take(n) is for the first n of a
feed that would otherwise not end, and closes the stream at n; it is not how a token stream
ends, because a token stream ends when the server stops. They behave sequentially, and
cancel() is this library’s own (D99).
export async function whole(): Promise<string[]> { return await client.stream('ask', { prompt: 'a b c' }).toArray()}
export async function sample(): Promise<string[]> { return await client.stream('ask', { prompt: 'a b c' }).take(5).toArray()}An error partway through is delivered after the elements that preceded it: the loop yields
what arrived, then throws. toArray() rejects and discards the partial. A cancelled stream
ends with WT_ABORTED, the same as an AbortSignal.
Backpressure is accounted for. The generator does not resume until its frame has been accepted, and the responder may be at most 32 frames ahead of what the consumer has taken (D93).
yields and returns are mutually exclusive, and the choice is made in the contract.
call() on a streaming event refuses and names stream(). stream() on a call event
refuses and names call().
A streaming call holds one of the session’s 256 stream slots for as long as it runs, not for a round trip. Ten concurrent generations use ten slots for minutes at a time.
2.4 Observable state
import type { ClientState } from 'transport-io'
export function watch(client: Client, log: (s: ClientState) => void): () => void { log(client.getSnapshot()) return client.subscribe(() => log(client.getSnapshot()))}import type { FallbackReason, Status, Transport } from 'transport-io'
declare const state: ClientStatedeclare const status: Statusexport const fields: [ Status, string | null, readonly string[], Transport | null, FallbackReason | null,] = [state.status, state.sessionId, state.rooms, state.transport, state.fallbackReason]export const known: Status[] = ['idle', 'connecting', 'connected', 'closing', 'closed']void statustransport is what carries the current session, null until connected. fallbackReason
says why that session is on a fallback transport, and is null on a native one. lastError
is why the last attempt failed or the last session closed, where the close code was an
error. refused is { reason } when the server’s authorize refused this client, beside a
status of closed, and null otherwise (§3.3); both clear when the next attempt starts.
getSnapshot() returns the same reference until something changes, so it is safe to hand
to useSyncExternalStore.
Handlers attach to the client, not to a session: client.on registered before connect()
receives everything from the first session and from every session a reconnect produces.
client.onSession(cb) runs cb once for every session the client gets, with the snapshot
as it connected: the first, and each one a reconnect produces. It returns the unsubscribe.
A reconnect is a new session, so this is where rooms are rejoined and what was missed is
fetched. It runs before anything from that session reaches a handler: the client holds what
the server sent after its handshake until every callback has returned, so state a callback
clears before its first await is cleared before the session’s first event. The server’s
onSession has the same guarantee, so a handler registered there cannot miss the peer’s
first event.
reconnect: { minMs, maxMs } in ClientOptions makes the client come back on its own after
a connected session closes: a wait of minMs, doubled on each failed attempt up to maxMs
and randomised between half of that and all of it, then an attempt from the native connector
again. Off unless given. The first connect() is not retried and settles as it always did;
disconnect() stops a reconnect that is waiting. A refusal stops it too: refused is set,
and nothing is retried until the application calls disconnect() and connect().
export function resilient(connect: ClientOptions['connect']): Client<AppMap> { const client = new Client<AppMap>({ contract, connect, reconnect: { minMs: 500, maxMs: 30_000 } }) client.onSession((state) => console.log(`session ${state.sessionId} on ${state.transport}`)) return client}2.5 A fallback transport
withFallback<M>(options) builds a client with a second transport behind the first.
options is ClientOptions plus fallback, a connector for a transport that carries the
reliable lane only. The native connector is tried first on every connect. The fallback is used
when the runtime has no WebTransport, WT_NO_SUPPORT, and when the WebTransport handshake
fails, WT_HANDSHAKE_FAILED or WT_UDP_UNREACHABLE: the WebSocket is dialled, and a session
on it reports fallbackReason: 'unreachable'. It is used as well when the WebTransport
session connects and then nothing arrives before the application handshake,
WT_HANDSHAKE_TIMEOUT after 5 s: the session is closed, the WebSocket is dialled, and a
session on it reports 'unsupported', the runtime having no WebTransport it can use against
this server. That is Safari, 5 s after every connect and every reconnect; a server stuck
before its first frame, or a path that drops stream data after the handshake, produce the
same signal and fall back the same way. If the WebSocket fails as well, the WebTransport
error is thrown as it was, so a dead server reports the primary transport. Any other failure
is thrown without asking the fallback: a certificate past its validity, or a dev connector
outside the dev command, is configuration. A wrong pinned hash fails the handshake as a
blocked path does, and falls back the same way.
It returns FallbackClient<M>: everything Client<M> has except call() and stream(),
which live on native. native is null while the session is a fallback or not connected,
so the check is one the compiler will not let you skip, and nothing about it is discovered at
runtime.
The line that adds the fallback compiles only when every unreliable event in the contract declares what it accepts there (§1.4). Otherwise the error names the event:
Property ''fallback refused'' is missing in type '{ contract: ...; connect: ...; fallback: ...; }' but required in type '{ readonly 'fallback refused': "event 'cursor' is unreliable and declares no fallback"; }'.The session refuses at connect as well, for a caller with no compiler:
WT_RELIABILITY_REFUSED, before the handshake. The server side is server.withFallback,
under the same type (§3).
The one fallback transport is the emit lane over a WebSocket, PROTOCOL.md §3.3:
import { defineContract, type MapOf, reliable, unreliable, withFallback } from 'transport-io'import { connectBrowser } from 'transport-io/browser-transport'import { connectWebSocket } from 'transport-io/websocket-transport'
export const contract = defineContract({ chat: reliable<{ from: string; body: string }>(), cursor: unreliable<{ x: number; y: number }>({ fallback: 'newest' }),})export interface AppMap extends MapOf<typeof contract> {}
export const client = withFallback<AppMap>({ contract, connect: () => connectBrowser({ url: 'https://example.com:4433/' }), fallback: () => connectWebSocket({ url: 'wss://example.com/transport-io' }),})A wss:// origin needs a certificate the platform trusts; a browser pins no hash for a
WebSocket, so in local development the listener is ws:// on loopback. A session on the
fallback sends a keepalive after 15 s with nothing sent and closes after 45 s with nothing
received, so a dead TCP path is noticed within that; keep any proxy’s idle timeout above
15 s.
2.6 Observing frames
client.observe(observer, options?) calls observer with one record for every frame in and
out, every call stream opening and closing, and every drop stats() counts. It returns the
unsubscribe. Subscribe once, before or after connect(): the subscription carries over to
every session a reconnect produces. A fallback client has it too.
Nothing is emitted when nobody subscribes. A client with no observer builds no record and calls nothing: each site is one branch, and its cost per frame is not measurable. An observer costs 25 ns a record, and 50 to 81 ns with previews on, whatever the payload weighs, so a logger is affordable in production. The code is about a kilobyte gzipped in every bundle, subscribed or not.
import type { FrameRecord } from 'transport-io'
export function logDrops(client: Client<AppMap>): () => void { return client.observe((record: FrameRecord) => { if (record.kind.endsWith('-dropped') || record.kind === 'stale-received') { console.warn(`${record.kind}: ${record.event} #${record.sequence}`) } })}import type { FrameKind, FrameObserver, ObserveOptions } from 'transport-io'
declare const record: FrameRecordexport const shape: { at: number session: number kind: FrameKind dir: 'in' | 'out' lane: 'reliable' | 'unreliable' event: string | null stream: number | null size: number sequence: number | null preview: string | null} = record// And back, so a field added to the record fails here until this page has it.export const same: FrameRecord = shapeexport const kinds: FrameKind[] = [ 'handshake', 'emit', 'datagram', 'request', 'response', 'error', 'credit', 'join', 'leave', 'open', 'close', 'overflow-dropped', 'stale-dropped', 'stale-received', 'direction-dropped',]export const observer: FrameObserver = (r) => void r.kindexport const options: ObserveOptions = { preview: true }Every field is a number, a string or null, and every one is read-only: a record is shared
by every subscriber, so nobody edits it.
| Field | |
|---|---|
at | The client’s clock, in milliseconds: Date.now(), unless the client was given now. |
session | 1 for the first session, 2 for the next. A reconnect is a new session. |
kind | handshake, emit, datagram, request, response, error, credit, join, leave; open and close for a call stream; overflow-dropped, stale-dropped, stale-received and direction-dropped, each named after the stats() counter it explains. |
dir | in or out. For open and close, which side opened the stream. |
lane | From the contract, so a datagram on a fallback session is still unreliable. |
event | The event’s name. A response names its call, which the wire does not. null for a frame that carries no event, for an event id this contract does not have, and for the open and close of a stream the peer opened, which has not said what it is for yet. |
stream | 0 is the emit stream, and everything on a fallback session. 1 and up is a call stream, numbered in the order this session’s streams open, and is not the QUIC stream id. null for a datagram. |
size | Bytes on the wire, header included. On a fallback session a datagram’s size includes the frame that wraps it. 0 for open and close, which are not frames. |
sequence | A datagram’s sequence number. null otherwise. |
preview | null unless this subscriber asked for previews. See below. |
A drop is a second record, never a replacement. A datagram that overflowed the ring was
recorded as datagram when it was emitted, and overflow-dropped follows with the same
sequence. The records show this client’s own drops: the network’s loss, and what the server
dropped on its way here, are not visible from this side. A gap in sequence is not proof of
loss either, since the server numbers an event across every room and this client may not have
been in all of them.
A record holds no payload, so keeping records keeps nothing else alive. { preview: true } adds the first PREVIEW_MAX_BYTES, 256, of each JSON payload as text, or the first
32 bytes of a bytes() payload as hex. It is cut by bytes, so it is rarely valid JSON and may
end in a replacement character: show it, never parse it. Only a subscriber that asks receives
one, whoever else is subscribed, so a logger does not start seeing payloads because a panel is
open.
Do not make a preview of your own by slicing a string. JSON.stringify(payload).slice(0, 256) looks like the same thing, and the engine keeps the whole string alive behind the slice.
A ring of 1,000 such previews of 64 KiB payloads held 66 MB, the same as keeping the payloads,
where 1,000 records with previews held 0.4 MB. The preview here is decoded from the first
bytes, which is why it is safe to keep.
An observer runs inside the session, synchronously, once per frame, so it does one cheap thing: append to a bounded list, bump a counter. Records arrive in the order the session saw the frames, and an inbound frame is recorded as it arrives, before any handler runs for it. A subscriber gets nothing from before it subscribed. One that throws is ignored, and the next subscriber still runs.
3. Server
type Conn = Awaited<ReturnType<ClientOptions['connect']>>
export async function start(incoming: { sessions(): AsyncIterable<Conn> }): Promise<void> { const server = createServer<AppMap>({ contract })
server.onSession((peer) => { void peer.join('lobby') peer.on('chat', (payload) => { void server.to('lobby').emit('chat', payload) }) peer.on('cursor', (payload) => { void server.to('lobby').except(peer.id).emit('cursor', payload) }) })
await server.listen(incoming)}Passing a connection source hands listen() the accept loop. A rejected accept is counted in
server.acceptErrors and passed to onAcceptError if one is given; it does not stop the
loop, and it does not vanish. Call listen() with no argument and drive accept() yourself
when a connection has to be inspected before it is accepted.
server.withFallback(source) accepts sessions from a transport that carries the reliable lane
only, after listen(). Its parameter type is the gate from §2.5: the call compiles only when
every unreliable event in the contract declares a fallback (§1.4). A session that reaches
accept from such a source with an undeclared event is refused with WT_RELIABILITY_REFUSED
and counted, for callers with no compiler. peer.transport says what carries each peer.
The source is listenWebSocket, from transport-io/websocket-node-transport, in a
*.node.ts file:
import { listenWebSocket } from 'transport-io/websocket-node-transport'
declare const cert: stringdeclare const privKey: string
export async function fallbackListener(port: number) { return await listenWebSocket({ port, cert, privKey, path: '/transport-io' })}With cert and privKey it terminates wss:// in the process; without them it is ws://,
for a reverse proxy that terminates TLS in front of it, or for loopback in development. It
answers any HTTP request on its port, which makes it the probe target that turns a failed
handshake into WT_UDP_UNREACHABLE (§3.3).
3.1 Rooms are server-authoritative
A client cannot join by sending anything; only peer.join() on the server has that effect.
The client learns its membership from a notification, which is why ClientState.rooms is
accurate without the client ever asking. An application wanting client-initiated
subscription implements it as a call whose handler authorises the payload and then joins
ctx.peer, which is the shape the reconnect
guide spells out.
import type { RoomTarget, ServerPeer } from 'transport-io'
export async function moveRooms(peer: ServerPeer<AppMap>): Promise<readonly string[]> { await peer.join('lobby') await peer.leave('lobby') return peer.rooms}
export function narrow(target: RoomTarget<AppMap>, exclude: string): RoomTarget<AppMap> { return target.except(exclude)}Server, ServerPeer and RoomTarget each take the map, and each falls back to the
registered one if you opt into registration.
server.memberCount(room) returns how many local peers are in a room, counted on this
node rather than across the adapter. It is a number for a health endpoint or a log line, not
a presence feature: it cannot see peers connected to another node, and it says nothing about
who they are.
emit on a room returns a promise because it crosses the adapter, but delivery to local
members does not wait for it.
A broadcast to a room with no local members is not an error. Membership lives in the adapter, and no node assumes it knows a room’s full membership.
3.2 Per-peer accounting
export function report(peer: ServerPeer): string { const s = peer.stats() return `depth=${s.queueDepth} overflow=${s.overflowDropped} stale=${s.staleDropped} staleRx=${s.staleReceived}`}overflowDropped means a burst outran a bounded queue; staleDropped means a peer stalled
and the frames aged out. Both are our drops: the transport reports neither loss nor
congestion, so no count here is the network’s.
3.3 Who is connecting, and when they leave
A listener decides each peer at the door with authorize, which receives the request that
opened the session: its path, its query, the peerAddress and its headers. A page can
put nothing but the path and the query on a WebTransport request, so the query is where a
token travels. The browser adds origin to the headers itself, and on the WebSocket listener
they are the upgrade request’s, cookies included. No origin is checked for you: if only
your own pages may connect, compare headers.origin in authorize. What authorize returns is
peer.data, typed by the server’s second type argument. null refuses the peer, and
refuse(reason) refuses it and says why.
import { createServer, defineContract, type MapOf, reliable } from 'transport-io'import { listenHttp3 } from 'transport-io/node-transport'
const contract = defineContract({ chat: reliable<{ body: string }>() })interface AppMap extends MapOf<typeof contract> {}interface User { name: string}
declare const cert: stringdeclare const privKey: stringdeclare function userFor(token: string | null): Promise<User | null>
export async function main(): Promise<void> { const server = createServer<AppMap, User>({ contract }) server.onSession((peer) => { void peer.join(`user:${peer.data.name}`) }) await server.listen( await listenHttp3({ port: 4433, cert, privKey, authorize: ({ query }) => userFor(query.get('token')), }), )}A refused peer’s session closes as WT_UNAUTHORIZED (§10.2 code 1007) before the server’s
frame 0, so it never receives the event table. The reason is a short code the client
compares, 1 to 123 bytes, and refuse throws on a longer one; null is the reason
'refused'. An authorize that throws has decided nothing: the session closes without that
code, and a client may try again.
import { refuse } from 'transport-io'
declare function lookup(token: string | null): Promise<{ name: string; banned: boolean } | null>
export async function door({ query }: { query: URLSearchParams }) { const user = await lookup(query.get('token')) if (user === null) return refuse('expired') if (user.banned) return refuse('banned') return { name: user.name }}On the client, connect() rejects with a RefusedError: a TransportError whose code is
WT_UNAUTHORIZED and whose reason is the server’s. The snapshot carries refused: { reason } beside a status of closed. A refusal is final: it does not dial the fallback,
and a client that reconnects on its own stops, since the same request would be refused again.
The way out is a credential that will pass, then disconnect() and connect(). A server may
also close a live session with peer.close(CloseCode.WT_UNAUTHORIZED, reason), a token that
expired, and the client treats it the same.
import { type Client, RefusedError } from 'transport-io'
export async function openOrSignIn( client: Client<AppMap>, signIn: (why: string) => void,): Promise<void> { try { await client.connect() } catch (e) { if (e instanceof RefusedError) signIn(e.reason) else throw e }}A server whose listener has no authorize has peer.data of undefined; the
property is assignable, so per-peer state can live there either way. listenDev and
listenWebSocket take the same authorize; the WebSocket one sees cookies, since the
upgrade is an ordinary HTTP request.
A departure is visible twice. server.onDisconnecting((peer, info) => …) runs when the
connection has closed and before the peer leaves its rooms, so peer.rooms still says where
it was. peer.closed is a promise that settles after the rooms are left, so a
memberCount read after it reflects the departure. info is the close code and reason.
A peer that vanishes with no close, a killed tab or a dead network, departs the same way
once the transport notices, which takes up to 25 seconds over WebTransport: code 0, and a
reason that begins connection lost.
export function watchDepartures(server: Server<AppMap>): void { server.onDisconnecting((peer) => { for (const room of peer.rooms) console.log(`${peer.id} leaving ${room}`) }) server.onSession((peer) => { void peer.closed.then(() => console.log(`${peer.id} gone`)) })}3.4 Certificates, and the one error everyone hits first
Omit certificateHash and the connection is validated against the platform’s CA store like
any other HTTPS origin. That is the production path. Pass one only to pin a self-signed
certificate locally, which is what transport-io dev sets up.
When a handshake fails, the browser gives the same WebTransportError - message
Opening handshake failed., code: 0, no own properties - for a wrong hash, an expired
certificate, and a server that is not listening. Measured in Chromium; all three are
identical.
connectBrowser therefore raises WT_HANDSHAKE_FAILED, whose remedy lists those three in
the order worth ruling out, and keeps the browser’s error as cause. It does not name a
single cause, because it cannot know which one it is.
It does check the one fact that is available. After the failure, and only then, it asks
whether the same origin answers over HTTPS: one HEAD to /.well-known/transport-io, any
status counts, two seconds at most. If it does, the server is up and only the QUIC path is
failing, which is what a firewall, a VPN or a platform with no UDP ingress looks like, and
the error is WT_UDP_UNREACHABLE instead. If nothing answers, the error stays
WT_HANDSHAKE_FAILED and its message says the origin was silent; an origin that listens only
on UDP looks like that and is healthy. probe overrides the target and probe: false
disables it. connectDev disables it, because the dev manifest has already proven the server
answers. connectHttp3 makes the same split.
connectDev can know, and does. The dev server publishes the certificate’s expiry with its
hash, so an expired certificate raises WT_CERT_EXPIRED before any connection is attempted,
naming the command that mints a new one.
4. Errors
import { TransportError, type TransportErrorCode } from 'transport-io'
export function describe(e: unknown): string { if (e instanceof TransportError) { const code: TransportErrorCode = e.code return `${code}: ${e.remedy}` } return 'unknown'}Every error carries a stable code and a remedy sentence saying what to do about it. A
bare TypeError is never thrown from this library’s own surface.
code, and reason on a RefusedError, are for branching. message is the code, what
happened and the remedy in one string, and with remedy and cause it is for logs: every
string here is English addressed to a developer, so a user is shown the application’s own
sentence, chosen by code. message can name the address that was dialled and never its
query, so a token in the URL is not in it. Codes and their numeric
wire values are specified in PROTOCOL.md §10, and a test asserts the two agree.
Two worth knowing:
WT_DATAGRAM_TOO_LARGEis raised by this library before the transport sees the write, because the transport accepts an oversized datagram, discards it, and reports success.WT_RELIABILITY_REFUSEDmeans the session negotiated reliable-only transport and was refused, because the unreliable lane would otherwise become reliable and ordered; or a session on a fallback transport whose contract has an unreliable event with no fallback declared (§1.4).
5. Adapters
import { MemoryAdapter, type Adapter, type PeerId } from 'transport-io'
export const adapter: Adapter = new MemoryAdapter('node-1')
export async function fanOut(a: Adapter, room: string, peer: PeerId): Promise<void> { await a.join(room, peer) await a.broadcast(room, new Uint8Array([1, 2, 3]), { lane: 'reliable', except: [peer] })}MemoryAdapter ships in core and is the default, so installing this library and running it
needs no infrastructure and no configuration.
If you write an adapter, run the conformance suite against HostileAdapter too. It is
exported from transport-io/testing and behaves like a bus rather than a map: it
serialises every frame through bytes, adds latency, delivers the publisher its own
messages, reorders deliveries, and fails on command. An adapter that only passes against
MemoryAdapter has not been tested.
import { HostileAdapter } from 'transport-io/testing'
export const hostile = new HostileAdapter('node-1', { latencyMs: 1, reorder: true, duplicate: true,})hostile.failNextBroadcast = true // core must degrade, not crashEvery method is async even in memory, frames cross as bytes rather than live objects, and
any method may reject - a rejected broadcast leaves local members served and the session
up. Core degrades rather than crashing.
A node receiving its own publish back is normal, and core dedupes by originating node rather than relying on the adapter to suppress it.
The same entry has loopbackPair(), an in-memory pair of connections for a test with no
server process: one goes to server.accept, the other is what a client’s connect returns.
The third element is the link, and link.drop() loses the connection with no close from
either side, which is how to test what your application does when a peer vanishes.
6. Framework binding surface
The exact set of core APIs a framework binding consumes. A change to anything listed here is a change to the binding contract, and breaking it should be visible rather than discovered downstream.
| API | why a binding needs it |
|---|---|
new Client(options) | Constructible without I/O, so it can be created in a module or a provider. |
client.connect() / client.disconnect() | Idempotent and refcounted, for effect setup and teardown under StrictMode. |
client.subscribe(listener) | The subscribe half of useSyncExternalStore. Returns an unsubscribe function. |
client.getSnapshot() | The getSnapshot half. Returns a referentially stable frozen object. |
client.on(event, handler) | Returns an unsubscribe function, making effect cleanup a one-liner. |
TransportError.code | Lets a binding branch on a stable code rather than a message. |
client.getSnapshot().transport | What carries the session, so a binding can report a call as unavailable on a fallback before it is made. native on a FallbackClient is the same fact as an object. |
Core imports no framework, not even as a type-only import, and holds no module-level singleton or global mutable state.
A binding built on this surface is a few lines:
import { useSyncExternalStore } from 'react'import type { Client, Status } from 'transport-io'
export function useConnectionStatus(client: Client): Status { return useSyncExternalStore( (cb) => client.subscribe(cb), () => client.getSnapshot().status, () => 'idle' as const, )}That block compiles like every other one here. It is also what @transport-io/react does for
you: useConnection is this plus
the connection calls, a stable return and a server snapshot.
7. Register (optional)
A map can be registered once, globally, and then omitted everywhere:
import { Client, defineContract, type MapOf, reliable } from 'transport-io'
export const contract = defineContract({ chat: reliable<{ body: string }>() })export interface AppMap extends MapOf<typeof contract> {}
declare module 'transport-io' { interface Register { map: AppMap }}
// `Client`, `Server`, `ServerPeer` and `RoomTarget` now default to `AppMap`.declare const client: Clientclient.emit('chat', { body: 'hi' })Register must stay an interface: only interfaces can be augmented by declare module, and
a type alias fails every registration with “Duplicate identifier”. Without a registration,
Registered resolves to a sentinel whose only key is the instruction, so the first emit
fails with a message naming this block rather than with never.
It changes no hover (D100). What it removes is the type argument at construction.
It is global. One slot per process. Two contracts in the same process conflict, and the type a file sees depends on which module was loaded rather than on what that file imported. The explicit form has neither property: the type follows the import.
Nothing requires it, including @transport-io/react, which binds hooks to a map with
createHooks<AppMap>().