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:
- Signaling — a WebSocket hub relays SDP offers/answers and ICE candidates between peers. No new syntax; it's just a hub.
- TURN relay — a
turn { }block runs a STUN/TURN server on a background thread for NAT-traversal fallback. - ICE config — the
ice_servers(id, ttl)builtin hands browsers a ready-madeRTCConfigurationwith 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.
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:
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
| Field | Default | Meaning |
|---|---|---|
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(...)). |
port | 3478 | The STUN/TURN control+data UDP port. |
ports | 49160..49200 | The 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):
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:
{
"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:
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.