apprtc 0.1.0

AppRTC P2P/SFU Signaling Server in Rust
apprtc-0.1.0 is not a library.
Visit the last successful build: apprtc-0.0.2

AppRTC is a WebRTC reference application and signaling server in the webrtc-rs ecosystem. The current Rust implementation provides the complete AppRTC-compatible P2P V1 room and signaling flow: HTTP join/message/leave APIs, initiator election, two-member rooms, queued signaling messages, tokenless browser WebSocket registration, reconnect grace, fallback POST/DELETE signaling, HTML templates, static web assets, ICE configuration, and HTTP/HTTPS plus WS/WSS serving.

The current binaries support P2P V1. The repository also contains the Sans-I/O SFU implementation and the architecture for P2P/SFU call modes, but V2 mode transitions and SFU worker integration are not yet enabled.

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

Architecture

The workspace has four Rust crates:

Crate Responsibility
apprtc Standalone appweb and signaling binaries: CLI parsing, TLS listeners, logging, graceful shutdown, and the async WebSocket I/O driver that hosts the Sans-I/O signaling core.
signaling-proto Generated Protobuf contract shared by AppWeb and signaling for their private control WebSocket.
appweb AppRTC HTTP room API, configuration parameters, Jinja templates, static web assets, and the in-process client for the signaling authority.
signaling Authoritative room/client state, V1 browser protocol, message queueing and relay, and reconnect deadlines — a pure Sans-I/O crate with no sockets, no threads, and no clock of its own.

AppWeb and signaling are separate processes and may run on different machines. AppWeb serves HTTP(S) and uses a Protobuf control WebSocket at /app to submit admit, remove, occupancy, inject, and status operations to signaling. Browser WebSocket traffic connects directly to signaling and never passes through AppWeb.

The signaling state is composed from Sans-I/O protocols:

Collider
└── RoomTable
    └── Room
        └── Client

Every layer implements the sansio::Protocol trait, so the whole signaling state machine is deterministic and testable in memory, without sockets or a wall clock.

All I/O lives at the binary level, in apprtc/src/signaling_server.rs, keeping the SFU chat example's architecture in async form on Tokio: an accept loop (accept_loop) performs the optional TLS handshake and the browser /ws or private /app WebSocket upgrade, spawning one session task per connection, while a single event-loop task (event_loop) owns the Collider — serializing every browser input through it, firing its timeouts, and routing its outputs back to each session over channels. Sessions and the event loop sleep on tokio::select! and wake immediately on input, output, deadline, or shutdown — no blocking calls and no polling intervals.

Successful V1 registration is intentionally silent, and a disconnected registered client remains eligible to reconnect for 10 seconds before its membership is removed.

Current P2P V1 behavior

  • 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.
  • Root-path and /_internal POST/DELETE fallback routes are both supported.
  • V1 identifiers are not restricted to numeric values. Numeric u64 validation belongs to the future V2 protocol.

Build and test

Use a Rust toolchain with Edition 2024 support.

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

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

# 1. Start signaling.
cargo run -p apprtc --bin signaling -- --host-ip 127.0.0.1 --port 8081 --tls &

# 2. Start AppWeb.
cargo run -p apprtc --bin appweb -- --host-ip 127.0.0.1 --port 8080 --web-root appweb \
  --public-url https://127.0.0.1:8080 --signaling-url wss://127.0.0.1:8081/ws \
  --signaling-insecure-tls --tls &

# 3. Run the integration tests.
cargo test -p apprtc --test '*' -- --nocapture

# 4. Stop both servers.
kill $(pgrep -f "target/debug/(appweb|signaling)") || true

CI performs the same sequence with a release build in .github/workflows/tests.yml and uploads the server log when the job finishes.

Run over HTTP and WebSocket

Run signaling and AppWeb separately from the repository root:

cargo run -p apprtc --bin signaling -- --host-ip 127.0.0.1 --port 8081
cargo run -p apprtc --bin appweb -- --host-ip 127.0.0.1 --port 8080 --web-root appweb \
  --public-url http://127.0.0.1:8080 --signaling-url ws://127.0.0.1:8081/ws

AppWeb prints:

AppWeb listening on http://127.0.0.1:8080/

Open http://127.0.0.1:8080 in a browser.

--host-ip and --port control each local listener. AppWeb's --public-url controls the browser-facing HTTP origin; --signaling-url controls the browser-facing WebSocket URL and must include /ws. AppWeb derives the same signaling origin's private /app endpoint for its Protobuf control connection.

Run over HTTPS and secure WebSocket

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

cargo run -p apprtc --bin signaling -- \
  --host-ip 127.0.0.1 --port 8081 --tls
cargo run -p apprtc --bin appweb -- \
  --host-ip 127.0.0.1 \
  --port 8080 \
  --web-root appweb \
  --public-url https://127.0.0.1:8080 \
  --signaling-url wss://127.0.0.1:8081/ws \
  --signaling-insecure-tls \
  --tls \
  --debug \
  --level info

Without certificate options, AppRTC uses the bundled development certificate at apprtc/cert/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:

cargo run -p apprtc --bin signaling -- \
  --host-ip 0.0.0.0 --public-url wss://sfu.example.com --port 443 --tls \
  --certificate /path/to/fullchain.pem \
  --private-key /path/to/privkey.pem

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

Command-line options

Run cargo run -p apprtc --bin appweb -- --help or cargo run -p apprtc --bin signaling -- --help for the authoritative lists.

Option Default Description
--host-ip <HOST-IP> 127.0.0.1 Local listener bind address (both binaries).
--public-url <URL> listener address/scheme Browser-facing HTTP(S) origin (appweb) or WS(S) origin (signaling).
-p, --port <PORT> 8080/8081 AppWeb HTTP(S) or signaling WS(S) listening port.
--web-root <PATH> appweb Static asset directory (appweb).
--tls off Serve HTTPS/WSS instead of HTTP/WS.
--certificate <PATH> bundled certificate PEM certificate chain used with --tls.
--private-key <PATH> bundled key PEM private key used with --tls.
--signaling-url <URL> none Browser signaling URL ending in /ws; AppWeb derives /app.
--signaling-insecure-tls off Disable verification for local self-signed signaling TLS (appweb).
--appid, --signaling-token appweb-1, empty AppWeb control identity and token (appweb).
--ice-server-url <URLS> empty ICE server URLs (appweb).
--ice-server-base-url <URL> AppWeb origin External ICE credential service origin (appweb).
--ice-server-api-key <KEY> empty API key for the ICE credential service (appweb).
--header-message <TEXT> empty Banner displayed by the web application (appweb).
--bypass-join-confirmation off Skip the browser ready-to-join prompt (appweb).
-d, --debug off Enable application logging (both binaries).
-l, --level <LEVEL> info Log filter (both binaries).
-o, --output-log-file <PATH> stdout Write formatted logs to a file (both binaries).

Example ICE configuration:

cargo run -p 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.

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

Join response

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

{
  "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:

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

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

{
  "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:

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

Protocol errors are sent once before the socket is closed:

{
  "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.

Status endpoint

GET /status preserves the Collider-compatible response:

{
  "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.