API reference

The complete public surface of @siltrun/client. This is the locked API — every example on this site uses exactly these names.

import { joinRoom } from "@siltrun/client";
function joinRoom(url: string, options: JoinOptions): Promise<Room>;

Connects to a room and resolves a Room. The promise resolves only after the join snapshot has arrived, so room.peers is already populated with everyone else in the room the instant joinRoom returns.

const room = await joinRoom("https://your-relay.example/room/lobby", {
id: "alice",
});

url is the WebTransport URL of the room (an https:// origin plus the room path).

You can also pass a plain room-info URL. If it answers GET <url>/.well-known/silt with { wtEndpoint, certHash }, joinRoom dials that wtEndpoint and pins the certHash for you; on any failure it falls through to treating the URL as the direct WebTransport endpoint. That’s how the demo discovers a local relay’s self-signed cert without hardcoding a hash.

interface JoinOptions {
id: string;
certHash?: string;
reconnect?: boolean;
snapshotTimeoutMs?: number;
connectTimeoutMs?: number;
reconnectBaseMs?: number;
reconnectMaxMs?: number;
maxReconnectAttempts?: number;
}
OptionTypeDefaultDescription
idstring(required)Stable, client-supplied identity. Reconnecting = calling joinRoom again with the same id.
certHashstringSHA-256 cert hash (hex) to pin via serverCertificateHashes, for local/dev secure contexts.
reconnectbooleantrueAuto-reconnect with the same id on transport loss.
snapshotTimeoutMsnumber5000How long to wait for the join snapshot before joinRoom rejects.
connectTimeoutMsnumber8000Overall bound on the pre-snapshot stages (handshake, stream open, hello write). A stall in any stage rejects with the stage named, instead of hanging forever.
reconnectBaseMsnumber300First reconnect delay; doubles per attempt.
reconnectMaxMsnumber10000Ceiling on the reconnect backoff.
maxReconnectAttemptsnumber10Reconnect attempts before giving up with status "failed". A silent infinite redial loop is impossible by design.

The object returned by joinRoom.

room.url; // readonly string — the room URL you connected to
room.id; // readonly string — your stable identity
room.peers; // Peer[] — the OTHER peers in the room, populated on join
room.status; // ConnectionStatus — current connection lifecycle state
room.lastError; // unknown — the last connect/transport error, never swallowed

room.peers holds the other peers and their latest presence. It’s kept in sync as peers join, leave, and update presence. It does not include yourself.

room.status reflects the connection lifecycle ("connecting", "connected", "reconnecting", "failed", "closed"). It transitions in step with the "status" event below. When something fails, the underlying error rides on room.lastError rather than being swallowed.

room.presence.set(state: PeerState): void;

Publishes your latest presence on the unreliable, latest-wins datagram lane. Call it as often as you like. If the connection isn’t ready (before connect or between reconnects) the call is a harmless no-op — the last value you set is re-published automatically on reconnect. Write failures are ignored (the lane is droppable by design).

room.presence.set({ x: 10, y: 20, heading: 90 });
room.events.send(event: RoomEvent): Promise<void>;

Sends a discrete message on the reliable, ordered lane. Returns the write promise. Throws "room not connected" if the room isn’t currently connected, so a critical message can’t be silently dropped.

room.events.send({ type: "absorb", target: "bob" });
room.on(type, callback): () => void;

Subscribes to a room event. Returns an unsubscribe function — call it to remove the listener.

const off = room.on("presence", (peerId, state) => { /* ... */ });
off(); // stop listening
EventCallback signatureFires when
"presence"(peerId: string, state: PeerState) => voidanother peer publishes presence.
"event"(peerId: string, event: RoomEvent) => voidanother peer sends a reliable event.
"join"(peer: Peer) => voida new peer joins the room.
"leave"(peerId: string, reason: LeaveReason) => voida peer leaves. reason is "left" (clean) or "timeout".
"status"(update: StatusUpdate) => voidthe connection lifecycle changes. See Connection status.
"state"(update: StateUpdate) => voida compute room broadcasts authoritative state. Never fires in a relay room.
room.close(): Promise<void>;

Cleanly leaves the room: sends a bye (so others see leave with reason "left") and closes the transport. Disables auto-reconnect.

await room.close();

Connection failures are never swallowed. The "status" event reports every transition in the connection lifecycle, so a stalled connect or an exhausted reconnect surface loudly instead of hanging or redialing forever.

room.on("status", (update) => {
switch (update.status) {
case "reconnecting": showBanner("reconnecting…"); break;
case "connected": hideBanner(); break;
case "failed": showBanner("can't reach the room"); break;
case "closed": showBanner("disconnected"); break;
}
});
type ConnectionStatus =
| "connecting"
| "connected"
| "reconnecting"
| "failed"
| "closed";
interface StatusUpdate {
status: ConnectionStatus;
error?: unknown; // the underlying failure, when there is one
attempt?: number; // reconnect attempt number (1-based), on "reconnecting"/"failed"
}
  • Auto-reconnect (the default) emits "reconnecting" on each attempt, backing off from reconnectBaseMs to reconnectMaxMs, and lands on "failed" after maxReconnectAttempts.
  • The initial joinRoom still rejects on a failed first connect (and emits "failed"); the "status" event is for everything that happens after you’re in the room.

A relay room — the default — relays presence and events between peers and runs no logic of your own. A compute room additionally runs server-authoritative code and broadcasts an authoritative state, latest-wins, on its own datagram lane. The demo’s referee runs in a compute room today.

You don’t opt in from the client: the room announces its mode in the join snapshot. In a compute room, room.presence.set becomes your input to the authoritative simulation, and the room’s state arrives on the "state" event.

room.on("state", (update) => {
render(update.state); // the authoritative state at update.tick
});
interface StateUpdate {
tick: number; // the tick this state is authoritative at
state: unknown; // your authoritative state shape
}

In a relay room, "state" never fires and presence.set behaves exactly as documented above. See Relay and compute rooms.

type PeerState = unknown;
type RoomEvent = unknown;
type LeaveReason = "left" | "timeout";
interface Peer {
id: string;
state: PeerState | null; // null until the peer has sent presence
}

PeerState and RoomEvent are unknown — they’re whatever you put in them. Define your own shapes and narrow on the way out:

interface Cursor { x: number; y: number; heading: number }
room.presence.set({ x, y, heading } satisfies Cursor); // your own shape
room.on("presence", (peerId, state) => {
const cursor = state as Cursor;
});