Skip to content

WebRTC Broker (Signaling + TURN)

Weld can be the broker for browser-to-browser WebRTC: it relays the connection handshake (signaling) and, when a direct peer-to-peer link can't form, relays the media/data itself (a built-in STUN/TURN server). One Weld binary is the whole server side of a WebRTC app — no external coturn, no signalling sidecar.

There are three pieces, and you can use them independently:

  1. Signaling — a WebSocket hub relays SDP offers/answers and ICE candidates between peers. No new syntax; it's just a hub.
  2. TURN relay — a turn { } block runs a STUN/TURN server on a background thread for NAT-traversal fallback.
  3. ICE config — the ice_servers(id, ttl) builtin hands browsers a ready-made RTCConfiguration with short-lived credentials for that relay.

Signaling with a hub

Peers can't connect until they've exchanged an SDP offer/answer and a trickle of ICE candidates. That exchange goes over a normal hub: each peer joins a room, and the broker forwards each message to the other peers in that room. Nothing WebRTC-specific is needed — identify/join for presence and rooms, and a filtered .send to relay.

weld
type Joined = { peer: string }                          # a peer arrived in the room
type Left   = { peer: string }                          # a peer dropped
type Sdp    = { from: string, kind: string, sdp: string }   # kind: "offer" | "answer"
type Ice    = { from: string, candidate: string }       # a trickled ICE candidate

hub Signal {
    connect (query room: string, query peer: string) injects (me: string, at: string) {
        me = peer
        at = room
        identify(peer)
        join(room)
        # Announce arrival, which is everyone else's cue to createOffer() toward this peer.
        Signal.clients.filter(c => c.subscribed(room) && c.identity != peer)
        .send(Joined { peer: peer })
    }

    method sdp(to: string, kind: string, sdp: string) {       # relay an offer/answer
        Signal.clients.filter(c => c.subscribed(at) && c.identity == to)
        .send(Sdp { from: me, kind: kind, sdp: sdp })
    }

    method ice(to: string, candidate: string) {               # relay one ICE candidate
        Signal.clients.filter(c => c.subscribed(at) && c.identity == to)
        .send(Ice { from: me, candidate: candidate })
    }

    disconnect {                                              # presence: tell the room
        Signal.clients.filter(c => c.subscribed(at)).send(Left { peer: me })
    }
}

A generated TypeScript client gives the browser a typed SignalHub to drive this. See examples/signaling.weld for the full version (with a peers() roster call).

The TURN relay

Most peers connect directly once they've swapped candidates. But when both sides are behind a symmetric NAT, the direct path fails and traffic must be relayed through a server the peers can both reach — a TURN relay. Declare one with a turn { } block:

weld
turn {
    realm  "weld.example"
    secret env("TURN_SECRET")     # shared with ice_servers() to mint credentials
    port   3478                   # STUN/TURN control+data port (UDP)
    ports  49160..49200           # relay allocation range
    relay_ip env("PUBLIC_IP")     # the address peers reach the relay at
}

The relay implements STUN Binding and TURN (allocations, permissions, channel bindings, Send/Data indications) per RFC 5389 / 8656. It runs on its own background thread and its own UDP port — it doesn't share the HTTP listener. It's pure Zig (std.crypto for HMAC-SHA1/MD5); no OpenSSL, and the binary stays self-contained.

WARNING

relay_ip is the address the relay advertises to peers (the XOR-RELAYED-ADDRESS), so in production it must be your server's public IP — usually env("PUBLIC_IP"). It defaults to 127.0.0.1, which only works for local testing. The TURN relay is served by the default (threaded) runtime; it is not supported under the experimental --green runtime.

Configuration

FieldDefaultMeaning
realm"weld"The authentication realm sent in the challenge (a string literal).
secret— (required)The shared secret for TURN-REST credentials, a string expression (usually env(...)).
port3478The STUN/TURN control+data UDP port.
ports49160..49200The inclusive range of UDP ports used for relay allocations.
relay_ip"127.0.0.1"The advertised relayed-address IP, a string expression. Set to your public IP.

Handing browsers their ICE config

A browser needs an RTCConfiguration listing the STUN/TURN URLs and, for TURN, a username and credential. Weld mints these with ice_servers(id, ttl):

weld
route GET "/ice" (query id: string) -> string {
    type "application/json"
    respond ice_servers(id, 3600)     # creds valid for 1 hour
}

It returns JSON shaped exactly like RTCConfiguration:

json
{
  "iceServers": [
    { "urls": "stun:1.2.3.4:3478" },
    {
      "urls": "turn:1.2.3.4:3478?transport=udp",
      "username": "1893456000:alice",
      "credential": "hT9k…="
    }
  ]
}

so the browser wires it up directly:

js
const cfg = await (await fetch("/ice?id=" + myId)).json();
const pc = new RTCPeerConnection(cfg);

The credential is the standard TURN-REST scheme: username is "<expiry>:<id>" and credential is base64(HMAC-SHA1(secret, username)), derived from the turn block's secret. Because the relay validates with the same secret, one binary both mints the credentials a browser uses and accepts them — there's nothing to keep in sync. ice_servers(...) requires a turn { } block (it's an error without one).

Putting it together

A complete broker is a signaling hub, a turn block, and an /ice route in one file. The browser fetches /ice, opens an RTCPeerConnection, and uses the SignalHub to exchange the offer/answer and candidates; if the direct connection fails, the peers fall back to the relay automatically. See examples/signaling.weld and examples/turn.weld.

Deferred

turns: (TURN-over-TLS) is not yet supported — it's the one piece that would require in-process TLS. Plain turn: over UDP is what browsers use for the common case.