Skip to content

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()
}
functionmoduleconnection options
browserClient<M>(options)transport-io/browser-transporturl, certificateHash?, probe?
devClient<M>(options)transport-io/dev-transportendpoint?, query?
http3Client<M>(options)transport-io/node-transporturl, 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. connect is the seam. Supply any function returning a Connection.
  • React. TransportProvider takes an unconnected client and connects it in an effect, so the client has to exist synchronously, inside a useState initialiser.
  • Rendering connection state. connecting is observable only if you are holding the thing doing the connecting. Both pages in examples/chat show 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: ClientState
declare const status: Status
export 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 status

transport 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: FrameRecord
export 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 = shape
export 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.kind
export 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
atThe client’s clock, in milliseconds: Date.now(), unless the client was given now.
session1 for the first session, 2 for the next. A reconnect is a new session.
kindhandshake, 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.
dirin or out. For open and close, which side opened the stream.
laneFrom the contract, so a datagram on a fallback session is still unreliable.
eventThe 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.
stream0 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.
sizeBytes 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.
sequenceA datagram’s sequence number. null otherwise.
previewnull 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: string
declare 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: string
declare const privKey: string
declare 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_LARGE is raised by this library before the transport sees the write, because the transport accepts an oversized datagram, discards it, and reports success.
  • WT_RELIABILITY_REFUSED means 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 crash

Every 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.

APIwhy 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.codeLets a binding branch on a stable code rather than a message.
client.getSnapshot().transportWhat 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: Client
client.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>().