Skip to content
Moku
@moku-labs/room pages

transport

Default engine. WebRTC peers, the typed Wire, signaling adapters, chunking and the heartbeat.

On this page

What it does

Wired by default. transport is the floor every other plugin stands on. It runs the RTCPeerConnections, owns the Wire and talks to the signaling adapter. A game rarely calls it. It is the plugin you configure.

How it works

  • Signaling. Joins the adapter under the room code. The host offers, phones wait. A non-persistent session is left once connected.
  • Open retries. A channel not open after openTimeoutMs is torn down and retried, up to 3 times. Then ice-failed.
  • Chunks. A frame over maxMessageBytes is split and reassembled.
  • Backpressure. Sends pause while the channel's buffer is over about 64 KiB.
  • Heartbeat. Mandatory, because iOS does not report a closed channel. No pong for heartbeatTimeoutMs means dead.

Config

pluginConfigs.transport.

OptionTypeDefaultDescription
signalingSignalingpublicRendezvous()The rendezvous adapter.
iceServersreadonly RTCIceServer[] | IceServersProvider | "auto""auto"See ICE servers.
iceTransportPolicyRTCIceTransportPolicy"all""relay" forces TURN only. With "all", the page URL ?ice=relay does the same for one session.
heartbeatIntervalMsnumber2000Ping interval.
heartbeatTimeoutMsnumber6000Silence before a peer is dead.
openTimeoutMsnumber3000Wait for a channel to open. Also the longest wait for an ICE provider.
maxMessageBytesnumber14336Chunk threshold.

Adapters

AdapterSignatureDescription
publicRendezvous(options?: { backbone?: "nostr" | "torrent"; relayRedundancy?: number }) => SignalingTrystero, loaded lazily. relayRedundancy has a floor of 3.
inMemory(options?: { server?: boolean }) => SignalingOne process. See Testing.
serverSignaling(url: string) => SignalingOne WebSocket to your hub. Loaded lazily.

Write your own by implementing Signaling: one join(code, opts) that returns a session with onPeer, onPeerLeave, send, onSignal and leave.

API

Mounted at app.transport, reachable with ctx.require(transportPlugin).

MethodSignatureDescription
connect(opts: ConnectOpts) => Promise<void>Joins signaling. Resolves when the session is live, not when peers connect.
wire() => WireThe same Wire each time.
disconnect(peerId: PeerId) => voidCloses one peer.
peers() => readonly PeerId[]Connected peers.
close() => Promise<void>Closes everything, leaves signaling.
reclaimToken() => string | nullThe hub's re-entry token, if any.
onPeerConnected(cb: (peerId: PeerId) => void) => () => voidOne slot. session uses it. Setting it breaks the roster.
onPeerLost(cb: (peerId: PeerId) => void) => () => voidOne slot. session uses it. Same warning.

Wire has send(peerId, frame), broadcast(frame) and on(handler). on fans out to all handlers, and returns the unsubscribe. A Frame is a closed union: a custom frame type does not exist. Send game data through intents and slices.

Events

Emits

EventPayloadWhen
room:network-warning{ reason }ice-failed, rendezvous-unreachable, channel-closed, room-evicted.

Listens to

None.

Depends on

Nothing.

Example

src/network.ts
import { createApp, stagePlugin } from "@moku-labs/room";

export const app = createApp({
  plugins: [stagePlugin],
  pluginConfigs: {
    transport: {
      iceServers: async () => {
        const response = await fetch("/my-turn-credentials");
        return response.ok ? ((await response.json()) as RTCIceServer[]) : undefined;
      },
      heartbeatTimeoutMs: 9000
    }
  }
});

await app.start();
console.log(app.transport.peers());
  1. Called on each connect. Runs in parallel with the signaling join.
  2. undefined falls back to public STUN.
  • pluginConfigs merges shallowly. Each field replaces the default whole.
  • "relay" with no TURN server never connects. That is by design, for testing the relay path.
  • connect resolving proves nothing about peers. Wait for room:peer-joined, or the joinRoom promise.
  • The default adapter uses public relays. The room code scopes the rendezvous. Anyone with the code can join.