<h1 align="center">
<a href="https://sfu.rs"><img src="https://raw.githubusercontent.com/webrtc-rs/webrtc-rs.github.io/master/res/sfu-rs.png" alt="SFU"></a>
<br>
</h1>
<p align="center">
<a href="https://github.com/webrtc-rs/sfu/actions">
<img src="https://github.com/webrtc-rs/sfu/workflows/cargo/badge.svg">
</a>
<a href="https://codecov.io/gh/webrtc-rs/sfu">
<img src="https://codecov.io/gh/webrtc-rs/sfu/branch/master/graph/badge.svg">
</a>
<a href="https://deps.rs/repo/github/webrtc-rs/sfu">
<img src="https://deps.rs/repo/github/webrtc-rs/sfu/status.svg">
</a>
<a href="https://crates.io/crates/sfu">
<img src="https://img.shields.io/crates/v/sfu.svg">
</a>
<a href="https://docs.rs/sfu">
<img src="https://docs.rs/sfu/badge.svg">
</a>
<a href="https://doc.rust-lang.org/1.6.0/complement-project-faq.html#why-dual-mitasl2-license">
<img src="https://img.shields.io/badge/license-MIT%2FApache--2.0-blue" alt="License: MIT/Apache 2.0">
</a>
<a href="https://discord.gg/4Ju8UHdXMs">
<img src="https://img.shields.io/discord/800204819540869120?logo=discord" alt="Discord">
</a>
<a href="https://twitter.com/WebRTCrs">
<img src="https://img.shields.io/twitter/url/https/twitter.com/webrtcrs.svg?style=social&label=%40WebRTCrs" alt="Twitter">
</a>
</p>
<p align="center">
SFU in Rust with Sans-IO
</p>
## 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`](examples/chat.rs) 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>`](https://docs.rs/sansio):
| `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`](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`](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`](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`](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_answer`
→ `set_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
```bash
# 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).
```bash
# 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 `3478`–`3495`),
`-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`](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.
```bash
# 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
CI runs the same flow in a container (see [`.github/workflows/tests.yml`](.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.