Skip to content

Repository files navigation

SFU

License: MIT/Apache 2.0 Discord Twitter

SFU in Rust with Sans-IO

Overview

sfu is a Selective Forwarding Unit built on the modern sans-IO rtc core. The library crate contains no sockets, no threads, and no clock of its own — it is a pure state machine. The caller owns all I/O: it feeds datagrams and signaling in, and drains datagrams and signaling out. The bundled chat example provides one such I/O layer (an HTTPS / WebSocket signaling server + a UDP media socket).

Sans-IO core

Sfu is the public entry point and implements sansio::Protocol<TaggedBytesMut, Infallible, SFUEvent>:

Method Plane Meaning
handle_read(TaggedBytesMut) / poll_write() -> TaggedBytesMut media push an incoming UDP datagram in / drain an outgoing datagram
handle_event(SFUEvent) / poll_event() -> SFUEvent signaling push a signaling request in / drain a signaling response or server-initiated event
handle_timeout(Instant) / poll_timeout() -> Instant clock advance the caller-supplied clock / ask for the next deadline

The read/write planes carry raw datagrams (Rin = Wout = TaggedBytesMut): an inbound datagram is demultiplexed to a client and fed to that client's RTCPeerConnection, and the datagrams every client produces are drained back out. The application plane is unused (Rout = Win = Infallible) — there is no payload above the SFU; inbound media (RTP) and keyframe requests (RTCP PLI/FIR) are forwarded internally between clients via ForwardTable, never surfaced to the caller. All signaling is the event plane (Ein = Eout = SFUEvent).

The engine is constructed with the local media address it will advertise in answers: Sfu::new(id, local_addr).

Architecture — three nested sans-IO layers

Sfu, Room, and Client each implement the same Protocol, so signaling is simply routed down and events bubble back up:

Sfu   ── owns rooms: HashMap<RoomId, Room>, a Demuxer, the local_addr, transmits/events
 └─ Room   ── owns clients: HashMap<ClientId, Client> and its own Demuxer
     └─ Client ── wraps exactly one rtc RTCPeerConnection (via the PeerConnection trait)
  • SFUEvent (src/event.rs) is the unified signaling currency: Join, SessionDescription, IceCandidate, Leave, plus the Ok/Err replies. Every variant carries a request_id, room_id, and client_id, which is how Sfu routes it to the right Room and Client.

  • Demuxer (src/demuxer.rs) maps an incoming datagram to a (RoomId, ClientId): by learned 4-tuple affinity once media flows, and otherwise by parsing the STUN USERNAME local-ufrag during ICE. Both Sfu and Room hold one — Sfu demuxes to the room, Room demuxes to the client.

    A RoomId is a Uuid, which the ufrag has to carry as text. room::encode_local_ufrag and room::decode_local_ufrag are the matched pair that does it, formatting base64_room_id "/" client_id "+" random_suffix. The room id uses standard base64 (+ and /) rather than base64url, because RFC 8839 limits a ufrag to ALPHA / DIGIT / "+" / "/"- and _ are not legal there. Since the encoded room id can therefore contain both separators, decoding splits from the right.

  • ForwardTable (src/forward.rs) is the media routing graph, owned by Room. It is keyed by ForwardKey { publisher, mid } — the publisher's m-line mid, not the SSRC, is the stable dedup key across renegotiations — and maps each publishing track to the subscriber senders it fans out to, with an SSRC index for routing inbound RTP. Room::reconcile diffs the desired forwarding matrix whenever membership or published tracks change, adding a sendonly transceiver per new subscriber and tearing down stale ones.

  • RtcpForwarderInterceptor (src/rtcp_forwarder.rs) is installed as the outermost interceptor on every client so that a subscriber's keyframe requests (RTCP PLI/FIR) about a forwarded stream reach the application via poll_read(). Room relays them to the publisher (Client::request_keyframe), which is what makes a newly-subscribed track light up promptly.

Routing over one shared socket (ICE-lite). Because every client is multiplexed over the same UDP socket, the SFU has to tell datagrams apart. Each client is built in ICE-lite mode with a local ufrag that encodes its address: "{room_id}/{client_id} +{random}". A browser's STUN binding request carries USERNAME = local_ufrag:remote_ufrag, so the Demuxer recovers (room_id, client_id) straight from the local ufrag, then caches the 4-tuple for the DTLS/SRTP phase. The SFU's answer also advertises a host ICE candidate synthesized from local_addr.

For example, an SFUEvent::Join creates the Room and the Client (default media engine

  • codecs, the default interceptor chain plus the RtcpForwarder, and a setting engine carrying the ICE-lite creds above); an SFUEvent::SessionDescription offer is answered by the client (set_remote_description → add the local_addr host candidate → create_answerset_local_description) and the resulting answer is emitted back out through poll_event, while publishing a track prompts server-initiated subscribe re-offers to the other clients; an SFUEvent::Leave tears the client down, prunes its forwarding entries, and reaps the room once empty.

Building

Toolchain

Use a Rust toolchain with Edition 2024 support.

Build & test

# Fetch the submodules first
git submodule update --init --recursive
cargo clippy
cargo fmt
cargo build
cargo test --lib     # run the unit tests in-memory (join/leave/offer→answer/forwarding)

Run the chat example

The chat example wraps the sans-IO core with real I/O: a custom HTTPS / WebSocket signaling server and one UDP media socket per media port. Every WebRTC flow is multiplexed over the media socket(s); clients are identified by their remote address (and STUN ufrag).

# HTTPS (self-signed cert in examples/util), the default
cargo run --example chat

# run on loopback (127.0.0.1) with HTTPS, e.g. for local testing
cargo run --example chat -- --force-local-loop

Then open the printed URL in a browser (it serves examples/chat.html). Useful flags: --host (default 127.0.0.1), --signal-port (default 8080), --media-port-min/--media-port-max (default 34783495), -f/--force-local-loop, --debug, and --level <error|warn|info|debug|trace>.

Integration tests

tests/ drives the SFU end-to-end: each test is a headless WebRTC client (built on the async webrtc crate) that speaks the chat server's TLS-WebSocket signaling protocol, so a chat server must be running first. data_channel_test covers register/connect/data-channel; rtp_test publishes VP8 tracks and asserts the SFU forwards RTP, intact and in order, to every other peer in the room.

# 1. start the SFU on loopback in the background
cargo run --example chat -- -f --level info &

# 2. run the integration tests against it
cargo test --test '*'

# 3. stop the background chat server
kill $(pgrep -f "examples/chat") || true

CI runs the same flow in a container (see .github/workflows/tests.yml): it boots the chat server and the tests together and collects logs/sfu.log + logs/test.log.

Notes for Contributors

  • The active implementation is the root sfu library crate; keep it sans-IO — no sockets, threads, or Instant::now() inside src/. All real I/O belongs in examples (or downstream binaries).
  • The library depends only on rtc (the sans-IO WebRTC stack, vendored at webrtc/rtc/rtc) and sansio. apprtc/, signaling/, and webrtc/ are git submodules; the chat example carries its own TLS-WebSocket signaling in examples/signaling/.
  • The architecture is fully operational end-to-end: signaling (join → offer/answer, server-initiated subscribe re-offers), datagram routing (handle_read demux → PC, poll_write drain), RTP fan-out via ForwardTable, and RTCP keyframe (PLI/FIR) relay from subscribers back to publishers. Remaining work includes refining Demuxer affinity expiry and eviction policies.

Open Source License

This project uses dual licensing under MIT or Apache-2.0.

Contributing

Contributors and pull requests are welcome.

About

Sans-IO SFU implementation in Rust

Resources

Stars

75 stars

Watchers

6 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages