apprtc 0.4.0

AppRTC P2P/SFU Server in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
<h1 align="center">
 <a href="https://webrtc.rs"><img src="https://raw.githubusercontent.com/webrtc-rs/webrtc-rs.github.io/master/res/apprtc.png" alt="WebRTC.rs"></a>
 <br>
</h1>
<p align="center">
 <a href="https://github.com/webrtc-rs/apprtc/actions">
  <img src="https://github.com/webrtc-rs/apprtc/workflows/cargo/badge.svg">
 </a>
 <a href="https://codecov.io/gh/webrtc-rs/apprtc">
  <img src="https://codecov.io/gh/webrtc-rs/apprtc/branch/master/graph/badge.svg">
 </a>
 <a href="https://deps.rs/repo/github/webrtc-rs/apprtc">
  <img src="https://deps.rs/repo/github/webrtc-rs/apprtc/status.svg">
 </a>
 <a href="https://crates.io/crates/apprtc">
  <img src="https://img.shields.io/crates/v/apprtc.svg">
 </a>
 <a href="https://docs.rs/apprtc">
  <img src="https://docs.rs/apprtc/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">
 <strong>AppRTC P2P/SFU Server in Rust</strong>
</p>

AppRTC is a WebRTC reference application and signaling server in the `webrtc-rs` ecosystem. The Rust implementation
supports the AppRTC-compatible P2P V1 flow and the token-authenticated V2 P2P/SFU flow. The room-selection page defaults
to V2 with a checked **V2 P2P/SFU** checkbox; unchecking it falls back to the legacy V1 flow. V2 uses service-minted UUID room
ids (shared as room links) with numeric `u64` client ids, namespaced HTTP routes,
signaling-issued admission tokens, explicit WebSocket registration acknowledgement, signal epochs, symmetric WebSocket
offer/answer/trickle-ICE relay, reconnect grace, and survivor promotion.

The first two V2 members use a direct P2P connection. When a third member joins, signaling selects a ready SFU worker
with sufficient advertised capacity, waits for all three worker-side joins, commits a new signal epoch, and tells the
existing browsers to create fresh SFU peer connections while their P2P connection remains active. The third browser
joins directly in SFU mode. Browsers use the polite-peer perfect-negotiation path against authoritative SFU subscribe
offers and republish after an offer collision. When an SFU room shrinks back to at most two members and stays there for
a short dwell (`--downgrade-dwell`, default 2s), signaling automatically downgrades it to direct P2P: it commits P2P
with a new signal epoch, tears down the members' SFU legs, and tells the browsers to negotiate directly again. Each
browser keeps its SFU connection on screen while the direct one negotiates, so the grid gives way to the full-screen
stage only once direct media is ready.

The Rust implementation replaces the previous unified Go Collider. The legacy implementation is retained only on the
repository's `go` branch.

## Architecture

This repository is the single `apprtc` Cargo package. The Sans-I/O protocol crates it builds on are separate
repositories, vendored as git submodules and consumed as path dependencies:

| Crate                                                    | Source                             | Responsibility                                                                                                                                                                                                                                                                                                        |
|----------------------------------------------------------|------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`apprtc`].                                            | this repository                    | Builds the `apprtc`, `signaling`, and `sfu` binaries. It owns the HTTP room API, configuration parameters, Jinja templates, the gRPC client for the signaling authority, and every runtime adapter: CLI parsing, TLS listeners, logging, graceful shutdown, browser WebSocket I/O, private gRPC, UDP media I/O, and the Sans-I/O SFU driver. |
| [`signaling`]signaling                                 | submodule, `webrtc-rs/signaling`   | Authoritative V1 and V2 P2P/SFU room, client, worker, lifecycle, transition, browser-protocol, token/epoch, replay, and reconnect state — a pure Sans-I/O crate with no sockets, threads, clock, or entropy source of its own.                                                                                          |
| [`signaling-proto`]signaling/signaling-proto           | inside the `signaling` submodule   | Generated Protobuf and tonic contract shared by the `apprtc` web server, signaling, and SFU workers.                                                                                                                                                                                                                   |
| [`sfu`]sfu and [`rtc`]sfu/webrtc/rtc                 | submodule, `webrtc-rs/sfu`         | Sans-I/O WebRTC media engine and stack driven by the `sfu` binary.                                                                                                                                                                                                                                                    |

Only `apprtc` is built from this repository's `Cargo.toml`; each submodule keeps its own manifest and tests.

The three binaries are separate processes and may run on different machines. `apprtc` is the web server: it serves
HTTP(S) and the browser application from [`web/`](web), and uses concurrent unary gRPC calls over one reusable HTTP/2
channel to submit V1 and V2 admission, removal, occupancy, V1 injection, and status operations to signaling. Browser
WebSocket traffic connects directly to signaling and never passes through `apprtc`. Each SFU process owns one
reconnecting bidirectional `OpenSfuSession` gRPC stream to signaling; browser media travels directly to the SFU over
ICE/DTLS/SRTP and never passes through `apprtc` or signaling.

The signaling state is composed from Sans-I/O protocols. `Collider` owns two independent tables — the V1
string-keyed one and the UUID-keyed V2 one that also holds SFU worker state:

```text
Collider
├── RoomTable (V1)          signaling/src/room_table.rs
│   └── Room                signaling/src/room.rs
│       └── Client          signaling/src/client.rs
└── V2 RoomTable            signaling/src/v2.rs — rooms, members, epochs, transitions, worker registry
    └── SFU session model   signaling/src/sfu.rs — commands, results, events, lifecycle IDs
```

`Collider` and the V1 layers implement the `sansio::Protocol` trait; the V2 table exposes the same deterministic
`handle_*`/`poll_timeout`/`poll_action` shape and is driven by `Collider`. Nothing in the crate owns a socket, thread,
clock, or entropy source, so the whole signaling state machine is testable in memory.

The `apprtc` package keeps each runtime responsibility in a dedicated module:

```text
src/
├── lib.rs                module declarations only
├── bin/                  the apprtc, signaling, and sfu entry points
├── tls.rs                shared certificate loading and TLS listener support
├── room_server.rs        HTTP room API, page routes, and static-asset serving
├── params.rs             AppRTC room/ICE parameter construction
├── templates.rs          Jinja index/full/grid page templates loaded from the web root
├── config.rs             web-server configuration
├── dashboard.rs          /status counters
├── grpc_client.rs        reusable gRPC client for the signaling authority
├── grpc_server.rs        private signaling gRPC service adapter
├── sfu_server.rs         signaling stream, UDP media shards, and Sans-I/O SFU adapter
├── signaling_server.rs   command channel and single-owner Collider event loop
└── ws_server.rs          public browser TCP/TLS, HTTP upgrade, and WebSocket sessions
```

`room_server.rs` through `grpc_client.rs` are the web server; the rest are the signaling and SFU runtime adapters. The
browser application the web server serves lives under [`web/`](web) (`html/`, `js/`, `css/`, `images/`).

[`src/ws_server.rs`](src/ws_server.rs) accepts browser `/ws` connections and converts WebSocket lifecycle
events and text frames into driver commands. [`src/grpc_server.rs`](src/grpc_server.rs) adapts private
gRPC requests to the same command channel. [`src/signaling_server.rs`](src/signaling_server.rs) owns the
single Collider event loop, serializes every browser and authority operation, fires protocol timeouts, and routes
outputs back to the WebSocket or gRPC caller. Tasks sleep on async I/O, deadlines, or shutdown without polling.

Successful V1 registration is intentionally silent, and a disconnected registered client remains eligible to
reconnect for 10 seconds before its membership is removed. The reconnect grace applies to P2P (V1 and V2) members
only; an SFU member whose WebSocket drops is treated as an immediate leave so its forwarded media stops and the
other participants' grid tiles are removed without delay.

## Current P2P behavior

V1 preserves the legacy AppRTC contract:

- Room and client IDs are opaque non-empty strings.
- A room contains at most two clients; a third join returns `FULL`.
- The first client is the initiator and the second is the callee.
- Removing a client promotes the survivor to initiator.
- Offers and trickle ICE candidates sent before the peer joins are queued.
- The second `/join` response returns queued messages in `params.messages`.
- The stock AppRTC asymmetric signaling flow is preserved: the initiator sends early signaling through `/message`, while
  the callee normally sends through WebSocket.
- A WebSocket disconnect starts a 10-second reconnect grace period instead of removing the client immediately (P2P only;
  SFU members leave immediately on disconnect).
- Root-path and `/_internal` POST/DELETE fallback routes are both supported.
- V1 identifiers are not restricted to numeric values.

P2P V2 adds:

- A checked **V2 P2P/SFU** room-selection checkbox by default; unchecking it selects the legacy V1 flow.
- `/v2/r/{roomid}`, `/v2/join/{roomid}`, `/v2/leave/{roomid}/{clientid}`, and `/v2/params` routes.
- Room ids are UUIDv8 values minted by the service and carried in links as 22-character base64url tokens; client ids remain canonical decimal `u64`.
- A signaling-issued admission token bound to the room/client pair.
- Explicit `{control:"registered"}` acknowledgement before signaling starts.
- An `epoch` on every browser `send` frame; stale or malformed epochs are dropped.
- Symmetric WebSocket relay for offers, answers, and trickle-ICE candidates; V2 does not use `/message` or the V1
  WebSocket POST fallback.
- Authenticated leave using `Authorization: Bearer <admission_token>` and `p2p-promote` for the surviving participant.

SFU-capable V2 adds:

- Capacity-aware selection of a ready SFU worker when the third member joins. Eligible workers are ordered by assigned
  clients, then assigned rooms, then `instance_id`; the room remains pinned to the selected worker.
- An ordered `JoinMember` barrier for all room members before signaling commits `Upgrading` to `SFU` and increments the
  signal epoch.
- A fresh browser SFU peer connection while the existing P2P connection remains active; the old P2P connection closes
  only after SFU ICE connects.
- Grid-based remote participants: the peer video/audio fills the window as a responsive grid of per-publisher tiles, the
  only UI change from P2P — the self-view and call controls keep their P2P positions. Each tile groups a peer's
  forwarded video and audio, and a peer's tile is removed from the grid when it leaves the room (reconciled from the
  negotiated transceivers after each SFU re-offer).
- SFU publish/subscribe SDP and full trickle-ICE exchange through the same browser V2 WebSocket envelope.
- Reliable worker events, command result correlation and deduplication, health/capacity reporting, same-instance
  reconnect synchronization, and command replay.
- Ordered worker-side joins and leaves for members admitted to or removed from an existing SFU room.
- Automatic SFU→P2P downgrade: once an SFU room has sat at no more than two members for the `--downgrade-dwell` window
  (default 2s) with no transition in flight, signaling commits P2P with a new signal epoch, releases the worker
  assignment, sends each member's worker leave, elects the lowest client id as the direct offerer, and pushes an
  `sfu-downgrade` control so each browser renegotiates directly. The dwell absorbs brief churn so a third participant
  leaving and rejoining does not flap the room between modes.
- A symmetric browser handoff in both directions: the outgoing peer connection stays on screen while the incoming one
  negotiates with the same local tracks. Upgrading keeps the P2P stage until SFU ICE connects; downgrading keeps the
  grid
  (holding the retired SFU connection's last frames) until direct media is playable, then returns to the full-screen
  stage. Neither transition reloads the page, replaces the WebSocket, or reacquires the camera and microphone.
- V2 perfect negotiation in the browser: it is polite toward the SFU, rolls back a colliding local offer, answers the
  SFU offer with its `requestid`, and creates a fresh publish offer afterward.

Cross-worker migration of an established room is intentionally deferred. A disconnected worker may resume its rooms only
by reconnecting with the same process-incarnation `instance_id` during the recovery grace period; otherwise signaling
fails those rooms with `room-failed` rather than moving live WebRTC transports.

## Current limitations

- The JavaScript `SignalingChannel` does not yet automatically reconnect a closed browser WebSocket. The signaling
  authority preserves P2P membership during its 10-second grace, but exploiting that grace currently requires a new
  registration attempt by the client.
- An SFU-member WebSocket disconnect intentionally initiates immediate member leave rather than browser reconnect grace.
- A committed room whose SFU worker is lost becomes `Failed` and emits `room-failed`; automatic failed-room cleanup and
  transparent browser rejoin are not implemented.
- Advertised worker capacity is enforced when selecting a worker for the initial three-member upgrade. Later joins
  remain pinned to that worker and are serialized through `JoinMember`, but the authority does not currently pre-check
  `max_clients` again.
- Service gRPC supports server-authenticated TLS but not mTLS/client authentication. Restrict the gRPC listener to
  trusted hosts.

## Build and test

Use a Rust toolchain with Edition 2024 support.

The `signaling` and `sfu` crates are git submodules, so initialize them before the first build:

```bash
git submodule update --init --recursive
cargo build
cargo test --lib --bins
cargo clippy --all-targets -- -D warnings
cargo fmt --all -- --check
```

These commands cover the `apprtc` package only — it is the sole member of this repository's Cargo workspace. Each
submodule owns its manifest and test suite, so run theirs from inside it:

```bash
cd signaling && cargo test --workspace --lib   # signaling and signaling-proto
cd sfu && cargo test                           # SFU media engine
```

The integration tests are black-box clients of real standalone `apprtc` and signaling TLS servers:

```bash
# 1. Start signaling.
cargo run --bin signaling -- --host-ip 127.0.0.1 --port 8081 \
  --grpc-port 50051 --tls &

# 2. Start one SFU worker.
cargo run --bin sfu -- --host-ip 127.0.0.1 \
  --media-port-min 35000 --media-port-max 35000 \
  --grpc-url https://127.0.0.1:50051 --insecure-tls &

# 3. Start the apprtc web server.
cargo run --bin apprtc -- --host-ip 127.0.0.1 --port 8080 --web-root web \
  --public-url https://127.0.0.1:8080 --ws-url wss://127.0.0.1:8081/ws \
  --grpc-url https://127.0.0.1:50051 --insecure-tls --tls &

# 4. Run the integration tests.
cargo test --test '*' -- --nocapture

# 5. Stop all three services.
kill $(pgrep -f "target/debug/(apprtc|signaling|sfu)") || true
```

[`scripts/start.sh`](scripts/start.sh) and [`scripts/stop.sh`](scripts/stop.sh) run that sequence locally over TLS,
rotating and writing per-process logs into `/tmp/logs/`.

CI performs the same sequence with a release build in `.github/workflows/tests.yml` and uploads the service logs when
the job finishes. The black-box suite covers V1 compatibility, V2 P2P relay, the real apprtc→signaling→SFU third-member
join barrier, the P2P→SFU→P2P mode round trip, three-client SFU data channels, and RTP/RTCP media forwarding.

### Reading the logs as a sequence diagram

[`scripts/log2seq.py`](scripts/log2seq.py) merges the three (or more) INFO logs by timestamp and renders one sequence
diagram whose lanes are each browser client, `apprtc`, signaling, and each SFU worker:

```bash
scripts/log2seq.py \
  --apprtc /tmp/logs/apprtc.log \
  --signaling /tmp/logs/signaling.log \
  --sfu /tmp/logs/sfu.log \
  -f html -o seq.html
```

`-f mermaid` (the default) prints a Markdown-embeddable diagram; `-f html` writes a self-contained page where every
offer/answer has a clickable `[+]` that expands its full SDP inline. `--room` limits the output to one room, and
`--sfu` accepts several worker logs.

## Run over HTTP and WebSocket

Run signaling, one SFU worker, and apprtc separately from the repository root:

```bash
cargo run --bin signaling -- --host-ip 127.0.0.1 --port 8081 \
  --grpc-port 50051
cargo run --bin sfu -- --host-ip 127.0.0.1 \
  --media-port-min 35000 --media-port-max 35000 \
  --grpc-url http://127.0.0.1:50051
cargo run --bin apprtc -- --host-ip 127.0.0.1 --port 8080 --web-root web \
  --public-url http://127.0.0.1:8080 --ws-url ws://127.0.0.1:8081/ws \
  --grpc-url http://127.0.0.1:50051
```

`apprtc` prints:

```text
AppRTC listening on http://127.0.0.1:8080/
```

Open [http://127.0.0.1:8080](http://127.0.0.1:8080) in a browser.

`--host-ip` controls the bind address for each process. In the signaling process it applies to both the browser
WebSocket listener and the private gRPC listener; `--port` and `--grpc-port` select their respective ports. `apprtc`'s
`--public-url` controls the browser-facing HTTP origin, `--ws-url` controls the browser-facing WebSocket URL and must
include `/ws`, and `--grpc-url` independently selects the private signaling gRPC origin.

## Run over HTTPS and secure WebSocket

Add `--tls` to serve real HTTPS and WSS from the same listener:

```bash
cargo run --bin signaling -- \
  --host-ip 127.0.0.1 --port 8081 --tls \
  --grpc-port 50051
cargo run --bin sfu -- \
  --host-ip 127.0.0.1 \
  --media-port-min 35000 --media-port-max 35000 \
  --grpc-url https://127.0.0.1:50051 --insecure-tls
cargo run --bin apprtc -- \
  --host-ip 127.0.0.1 \
  --port 8080 \
  --web-root web \
  --public-url https://127.0.0.1:8080 \
  --ws-url wss://127.0.0.1:8081/ws \
  --grpc-url https://127.0.0.1:50051 \
  --insecure-tls \
  --tls \
  --debug \
  --level info
```

Without certificate options, AppRTC uses the bundled development certificate at [
`scripts/cert.pem`](scripts/cert.pem). Its subject alternative names include `localhost`, `127.0.0.1`, and
`::1`, but it is self-signed. Trust that certificate in the browser or operating-system trust store before opening the
page; otherwise HTTPS and WSS clients will reject it with `CertificateUnknown` or an equivalent certificate-authority
error.

For a deployment, supply a certificate issued by a trusted authority. Both options must be supplied together:

```bash
cargo run --bin signaling -- \
  --host-ip 0.0.0.0 --port 443 --tls \
  --grpc-port 50051 \
  --certificate /path/to/fullchain.pem \
  --private-key /path/to/privkey.pem

cargo run --bin sfu -- \
  --host-ip 0.0.0.0 --media-public-ip 203.0.113.20 \
  --media-port-min 3478 --media-port-max 3497 \
  --grpc-url https://sfu.example.com:50051

cargo run --bin apprtc -- \
  --host-ip 0.0.0.0 --public-url https://apprtc.example.com --port 443 --web-root web \
  --ws-url wss://sfu.example.com/ws --grpc-url https://sfu.example.com:50051 --tls \
  --certificate /path/to/fullchain.pem --private-key /path/to/privkey.pem
```

## Command-line options

Run `cargo run --bin apprtc -- --help`, `cargo run --bin signaling -- --help`, or `cargo run --bin sfu -- --help` for
the authoritative lists.

| Option                         |                  Default | Description                                                                                      |
|--------------------------------|-------------------------:|--------------------------------------------------------------------------------------------------|
| `--host-ip <HOST-IP>`          |              `127.0.0.1` | Local TCP or UDP bind address (all binaries).                                                    |
| `--public-url <URL>`           |                     none | Required browser-facing HTTP(S) origin (`apprtc`).                                               |
| `-p, --port <PORT>`            |            `8080`/`8081` | `apprtc` HTTP(S), signaling WS(S), or SFU redirect (`--redirect-url`) port.                      |
| `--web-root <PATH>`            |                    `web` | Static asset directory served by `apprtc`.                                                       |
| `--tls`                        |                      off | Serve `apprtc` HTTPS, both signaling TLS listeners, or the optional SFU HTTPS redirect.          |
| `--certificate <PATH>`         |      bundled certificate | PEM certificate chain used with `--tls` by the relevant listener.                                |
| `--private-key <PATH>`         |              bundled key | PEM private key used with `--tls` by the relevant listener.                                      |
| `--ws-url <URL>`               |                     none | Public browser signaling WebSocket URL ending in `/ws` (`apprtc`).                               |
| `--grpc-url <URL>`             | `http://127.0.0.1:50051` | Private signaling gRPC origin (`apprtc` and `sfu`).                                              |
| `--insecure-tls`               |                      off | Disable gRPC verification for local self-signed TLS (`apprtc`, `sfu`).                           |
| `--grpc-port <PORT>`           |                  `50051` | Private gRPC listener port (`signaling`).                                                        |
| `--downgrade-dwell <SECONDS>`  |                      `2` | Seconds an SFU room dwells at two members before downgrading to direct P2P (`signaling`).        |
| `--ice-server-url <URLS>`      |                    empty | ICE server URLs (`apprtc`).                                                                      |
| `--ice-server-base-url <URL>`  |            apprtc origin | External ICE credential service origin (`apprtc`).                                               |
| `--ice-server-api-key <KEY>`   |                    empty | API key for the ICE credential service (`apprtc`).                                               |
| `--header-message <TEXT>`      |                    empty | Banner displayed by the web application (`apprtc`).                                              |
| `--bypass-join-confirmation`   |                      off | Skip the browser ready-to-join prompt (`apprtc`).                                                |
| `--media-public-ip <IP>`       |              `--host-ip` | ICE candidate address advertised by `sfu`; set only when it differs from the bind address (NAT). |
| `--redirect-url <URL>`         |                    empty | When set, `sfu` runs a server on `--host-ip:--port` that redirects every request here.           |
| `--media-port-min <PORT>`      |                   `3478` | First UDP media port owned by `sfu`.                                                             |
| `--media-port-max <PORT>`      |                   `3497` | Last UDP media port owned by `sfu`.                                                              |
| `--max-rooms <COUNT>`          |                   `1000` | SFU room capacity advertised to signaling.                                                       |
| `--max-clients <COUNT>`        |                  `10000` | SFU client capacity advertised to signaling.                                                     |
| `--instance-id <ID>`           |          generated value | Optional SFU process-incarnation ID; normally omit it.                                           |
| `-d, --debug`                  |                      off | Enable application logging (all binaries).                                                       |
| `-l, --level <LEVEL>`          |                   `info` | Log filter (all binaries).                                                                       |
| `-o, --output-log-file <PATH>` |                   stdout | Write formatted logs to a file (all binaries).                                                   |

Example ICE configuration:

```bash
cargo run --bin apprtc -- \
  --ice-server-url stun:stun.l.google.com:19302 \
  --ice-server-url turn:turn.example.com:3478
```

## HTTP API

| Method and path                                   | Behavior                                                                               |
|---------------------------------------------------|----------------------------------------------------------------------------------------|
| `GET /`                                           | Render the room-selection page.                                                        |
| `GET /r/{roomid}`                                 | Render the call page or the full-room page when occupancy is two.                      |
| `POST /join/{roomid}`                             | Generate an eight-digit client ID, admit it, and return legacy AppRTC room parameters. |
| `POST /message/{roomid}/{clientid}`               | Queue or relay the raw signaling message and return `{ "result": "SUCCESS" }`.         |
| `POST /leave/{roomid}/{clientid}`                 | Remove the client and return the legacy empty success response.                        |
| `GET /params`                                     | Return room-independent AppRTC parameters.                                             |
| `GET` or `POST /v1alpha/iceconfig`                | Return the configured ICE server list.                                                 |
| `GET /status`                                     | Return uptime and WebSocket/HTTP counters.                                             |
| `POST /{roomid}/{clientid}`                       | V1 `wss_post_url` fallback: inject a raw signaling message.                            |
| `DELETE /{roomid}/{clientid}`                     | V1 `wss_post_url` fallback: remove the client.                                         |
| `POST` or `DELETE /_internal/{roomid}/{clientid}` | Compatibility alias for the fallback bridge.                                           |
| `GET /v2/r/{roomid}`                              | Render the V2 P2P/SFU call page; `roomid` must be a base64url room token (UUIDv8).     |
| `POST /v2/join/{roomid}`                          | Admit a V2 member; `roomid` is a room token and a third member initiates the SFU join barrier. |
| `POST /v2/leave/{roomid}/{clientid}`              | Remove a V2 member using its bearer admission token; `roomid` is a room token.          |
| `GET /v2/params`                                  | Return room-independent V2 parameters and ICE configuration.                           |

Static files under `web/js`, `web/css`, `web/images`, and `web/html` are served by the same process.

### Join response

A successful join returns the legacy shape consumed by `web/js/call.js`:

```json
{
  "result": "SUCCESS",
  "params": {
    "room_id": "example-room",
    "client_id": "12345678",
    "is_initiator": "true",
    "wss_url": "ws://127.0.0.1:8081/ws",
    "wss_post_url": "http://127.0.0.1:8080"
  }
}
```

The actual `params` object also contains peer-connection constraints, ICE configuration, media constraints, room links,
loopback settings, and UI configuration.

## V1 WebSocket protocol

Browsers connect to `/ws` and send a registration frame first:

```json
{
  "cmd": "register",
  "roomid": "example-room",
  "clientid": "12345678"
}
```

A successful V1 registration has no acknowledgement. The registered client sends an opaque signaling payload with:

```json
{
  "cmd": "send",
  "msg": "{\"type\":\"candidate\",\"label\":0,\"id\":\"0\",\"candidate\":\"candidate:...\"}"
}
```

The peer receives the payload without the signaling authority parsing or modifying the inner JSON:

```json
{
  "msg": "{\"type\":\"candidate\",\"label\":0,\"id\":\"0\",\"candidate\":\"candidate:...\"}",
  "error": ""
}
```

Protocol errors are sent once before the socket is closed:

```json
{
  "msg": "",
  "error": "Client not registered"
}
```

The inner `msg` string may contain an SDP offer, SDP answer, trickle ICE candidate, end-of-candidates marker, or `bye`
object. V1 signaling treats it as an opaque UTF-8 string.

## V2 WebSocket protocol

V2 uses the same `/ws` endpoint but requires a room token, a numeric client id, an admission token, and
`ver: 2`. A room token is a UUIDv8 minted by the service, rendered as 22 unpadded base64url characters — the same
string that appears in the room link — and signaling rejects any other spelling of it with `{"error":"INVALID_ROOM_ID"}`:

```json
{
  "cmd": "register",
  "roomid": "grYp2g1QjrKVXUZLph46kA",
  "clientid": "101",
  "ver": 2,
  "token": "admission-token"
}
```

Successful registration returns an authoritative snapshot before any queued signaling:

```json
{
  "control": "registered",
  "roomid": "grYp2g1QjrKVXUZLph46kA",
  "epoch": "0",
  "mode": "p2p",
  "is_initiator": true
}
```

Every V2 signaling message carries the current epoch. The inner `msg` remains an opaque JSON string and fully supports
SDP, trickle ICE, and end-of-candidates:

```json
{
  "cmd": "send",
  "epoch": "0",
  "msg": "{\"type\":\"candidate\",\"label\":0,\"id\":\"0\",\"candidate\":\"candidate:...\"}"
}
```

The server may send `p2p-promote`, `sfu-upgrade`, `sfu-downgrade`, and `room-failed` controls. `sfu-upgrade` and
`sfu-downgrade` carry the room's new epoch; `sfu-downgrade` and `p2p-promote` also carry `is_initiator`, which elects
the
single direct offerer:

```json
{
  "control": "sfu-downgrade",
  "roomid": "grYp2g1QjrKVXUZLph46kA",
  "epoch": "2",
  "is_initiator": true
}
```

In SFU mode, subscribe offers contain a decimal-string `requestid`; the browser echoes it in the corresponding answer.

## Multiple SFU workers

Each SFU process opens one `OpenSfuSession` stream and must have a unique process-incarnation `instance_id` (the binary
generates one when `--instance-id` is omitted). Signaling considers only connected workers whose latest health state is
`Ready` and whose advertised room/client capacity can accept the initial three-member assignment.

Among eligible workers, signaling chooses the lowest tuple `(assigned_clients, assigned_rooms, instance_id)`. This is
least-loaded placement, not round-robin. Once selected, every member and all signaling/media state for that room remain
affine to that worker. Later rooms can be placed on other workers, but an established room is not split or automatically
migrated.

## Status endpoint

`GET /status` preserves the Collider-compatible response:

```json
{
  "upsec": 12.5,
  "openws": 2,
  "totalws": 5,
  "wserrors": 0,
  "httperrors": 0
}
```

## License

This project is dual-licensed under the MIT and Apache-2.0 licenses.

## Contributing

Contributors and pull requests are welcome.