feat: Rust SFU data plane — backend-hosted rooms, plug-and-play sidecar binary

rust/ becomes a standalone static binary (blax-webrtc-sfu, tokio-free
std-thread loop on str0m): terminates ICE/DTLS/SRTP, forwards Opus between
room peers without decoding, records per-peer Ogg/Opus (ffprobe-verified),
server-side mute/kick, stats + peer_connected/peer_left usage events, all
driven by PHP over a JSON-lines Unix control socket. Renegotiation rides the
browser data channel (str0m chat model) — signaling only carries the initial
offer/answer.

PHP side: RustMediaEngine (MediaEngine contract + room-aware API) is
plug-and-play — first use downloads the sha256-verified prebuilt binary
(BinaryManager, RoadRunner pattern) and spawns it detached, flock-guarded
(SidecarSupervisor). webrtc:install / webrtc:sidecar provided for explicit
control; config gains the webrtc.sfu block. Replaces the ext-php-rs
Str0mMediaEngine seam.

Tests: 61 PHP (incl. a real PHP<->Rust E2E that spawns the sidecar from
PHPUnit) + 7 Rust (incl. an end-to-end control-socket integration test).
build-release.sh produces the static-musl release + .sha256 (verified:
static-pie, 9.2MB, runs).

Card #1083 (rel #1051 #1060 #1064).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-10 11:29:26 +02:00
parent fa0908eb99
commit aca88c3dbc
31 changed files with 3749 additions and 195 deletions

4
.gitignore vendored
View File

@ -7,6 +7,6 @@ composer.lock
.idea/ .idea/
.vscode/ .vscode/
# Rust core build artifacts # Rust build artifacts (Cargo.lock IS committed — bin crate, reproducible releases)
/rust/target/ /rust/target/
/rust/Cargo.lock /rust/dist/

View File

@ -8,6 +8,35 @@ Blax Software's backward-compatibility guarantee.
### Added ### Added
- **Backend-hosted rooms — the Rust SFU sidecar** (`rust/`, binary
`blax-webrtc-sfu`): str0m terminates ICE/DTLS/SRTP server-side and forwards
Opus between room peers without decoding; per-peer **Ogg/Opus recording**
written from the decrypted stream; server-side **mute/kick**, `stats`
(connected/muted/usage seconds) and pushed `peer_connected`/`peer_left`
events. Driven over a JSON-lines Unix control socket. Renegotiation rides
each browser's data channel (str0m `chat` model) — signaling only carries the
initial offer/answer.
- **`Media\RustMediaEngine`** — implements `MediaEngine` (composite
`room/peer` ids) plus the room-aware API (`addPeer`/`removePeer`/`mutePeer`/
`stats`/`drainEvents`). **Plug-and-play**: first use auto-downloads the
sha256-verified prebuilt static binary (`Rust\BinaryManager`, RoadRunner
pattern) and spawns it detached + flock-guarded (`Rust\SidecarSupervisor`).
- Commands `webrtc:install` (pre-fetch the binary) and `webrtc:sidecar`
(foreground runner for docker/supervisord; disable `auto_spawn` there).
- `config/webrtc.php` gained the `sfu` block (socket, udp/public ip+port,
auto_install/auto_spawn, download/checksum URLs, versioned install path).
- `rust/build-release.sh` builds the static-musl release binary + `.sha256`.
- Tests: 61 PHP (control protocol framing, event buffering, checksum
install/mismatch, supervisor args + a REAL PHP↔Rust E2E that spawns the
sidecar from PHPUnit) and 7 Rust (proto round-trips, Ogg/Opus writer
[ffprobe-verified], end-to-end control-socket integration).
### Removed
- `Media\Str0mMediaEngine` (the ext-php-rs extension seam) — replaced by
`RustMediaEngine` + the sidecar: no PHP-version-bound extension, no crash
sharing a process with PHP, works with the prebuilt-base-image deploy model.
- **Typed rooms** (`Rooms\RoomType`, derived from the room name prefix like - **Typed rooms** (`Rooms\RoomType`, derived from the room name prefix like
laravel-websockets channels): `public`, `private-*` (authorized), laravel-websockets channels): `public`, `private-*` (authorized),
`presence-*` (authorized + member list), `open-presence-*` (public + member `presence-*` (authorized + member list), `open-presence-*` (public + member

View File

@ -124,15 +124,62 @@ rec.start(250)
Recordings finalize on leave/disconnect. Swap `FileRecordingStore` for your own `RecordingStore` (S3, DB blob) and bind a `CallEventListener` (`WEBRTC_EVENTS`) to persist call records — participants, duration, and the finalized recording locator — even for calls you don't record. Recordings finalize on leave/disconnect. Swap `FileRecordingStore` for your own `RecordingStore` (S3, DB blob) and bind a `CallEventListener` (`WEBRTC_EVENTS`) to persist call records — participants, duration, and the finalized recording locator — even for calls you don't record.
### Server-terminated media (AI bridge / SFU) ### Backend-hosted rooms — the Rust SFU (recommended for production)
When the server must be *in* the media path, configure a `MediaEngine`. `NullMediaEngine` (default) is signaling-only; `Str0mMediaEngine` is backed by a Rust `str0m` core via `ext-php-rs` (see [`rust/README.md`](rust/README.md)): Mesh is fine for two browsers; for **hosted rooms** — server in the media path, per-peer recording without client cooperation, server-side mute/kick, usage metering, room sizes beyond ~5 — switch the media engine to the bundled Rust SFU (Selective Forwarding Unit: it receives each peer's audio once and forwards it to the others *without decoding*):
```dotenv ```dotenv
WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine" WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\RustMediaEngine"
``` ```
The engine-backed path uses `Signaling\SignalingHandler` (offer/ice/record/connect/bridge/close) + `Server\SignalingServer`; it's independent of the browser relay above and shares the same `RoomManager`. That's the whole setup — it is **plug-and-play**. On first use the engine downloads the prebuilt, sha256-verified `blax-webrtc-sfu` static binary for your platform (the RoadRunner pattern) and spawns it detached, flock-guarded so concurrent workers race exactly one spawn. Explicit control when you want it:
```bash
php artisan webrtc:install # pre-download the binary (deploy step)
php artisan webrtc:sidecar # run it in the FOREGROUND (docker service /
# supervisor; set WEBRTC_SFU_AUTO_SPAWN=false there)
```
PHP stays the control plane. Sessions look like:
```php
use Blax\WebRtc\Media\RustMediaEngine;
$engine = app(RustMediaEngine::class);
// Browser sends its SDP offer over YOUR signaling (WS/HTTP) — answer it:
$answer = $engine->addPeer('lobby', $userId, $sdpOffer, recordPath: "/rec/lobby/$userId.ogg");
$engine->mutePeer('lobby', $userId, true); // room stops hearing them
$engine->removePeer('lobby', $userId); // kick
$engine->stats(); // rooms → peers → connected/muted/seconds
$engine->drainEvents(); // peer_connected / peer_left (usage seconds)
```
Recordings are standard **Ogg/Opus** (ffmpeg/browser playable), written by the sidecar from the decrypted SRTP — no browser MediaRecorder upload needed on this path.
**Browser contract** (one thing beyond a normal WebRTC client): create a *data channel* before offering. Only the initial offer/answer passes through your signaling; when peers join later, the SFU renegotiates new audio tracks **over that data channel** directly:
```js
const pc = new RTCPeerConnection()
pc.addTrack(mic.getAudioTracks()[0], mic)
const dc = pc.createDataChannel('signaling')
dc.onmessage = async (e) => { // SFU offers new tracks as peers join
await pc.setRemoteDescription(JSON.parse(e.data))
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
dc.send(JSON.stringify(answer))
}
pc.ontrack = (e) => playRemoteAudio(e.streams[0])
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const { answer } = await yourSignaling.addPeer('lobby', offer.sdp) // → $engine->addPeer(...)
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
```
Ops notes: pin `WEBRTC_SFU_UDP_PORT` and publish it (UDP) on your container, and set `WEBRTC_SFU_PUBLIC_IP` to the address browsers can reach — it's advertised in the ICE host candidate, which is also why no TURN server is required for the common case. See the `sfu` block in `config/webrtc.php` for every knob (socket path, auto_install/auto_spawn, download/checksum URLs, log).
The legacy raw-TCP `Signaling\SignalingHandler` + `Server\SignalingServer` path still exists for driving a `MediaEngine` without a browser relay and shares the same `RoomManager`.
### Use it on your own WebSocket (no bundled server) ### Use it on your own WebSocket (no bundled server)
@ -172,7 +219,7 @@ Membership lives in a swappable `RoomStore`. The default `ArrayRoomStore` keeps
## Status ## Status
**Working package.** Browser-to-browser (mesh) calls, typed rooms (public / private / presence / open-presence) with authorization + presence, and full per-participant **call recording** (mic streamed over the WS, stored to disk) all work today and are tested (incl. a real WebSocket round-trip). The only remaining piece is *server-terminated SRTP* media (an in-path SFU/recorder for AI bridging or huge groups) — that's the `Str0mMediaEngine` + `rust/` core, the next build. **Working package.** Browser-to-browser (mesh) calls, typed rooms (public / private / presence / open-presence) with authorization + presence, full per-participant **call recording**, and **backend-hosted rooms on the Rust SFU sidecar** (server-terminated ICE/DTLS/SRTP, forwarding without decode, native Ogg/Opus recording, mute/kick/stats/usage events, plug-and-play binary management) — all tested, including a real PHP↔Rust round-trip that spawns the sidecar from PHPUnit. Next: the duplex AI bridge inside the SFU data plane (today `OpenAiRealtimeBridge` covers turn-based AI participants) and browser E2E automation.
## Architecture ## Architecture
@ -189,7 +236,9 @@ laravel-webrtc (this)
│ └─ RelaySignalingHandler browser P2P calls over the loop │ └─ RelaySignalingHandler browser P2P calls over the loop
└─ MediaEngine (optional) …or terminate media server-side └─ MediaEngine (optional) …or terminate media server-side
└─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/) └─ RustMediaEngine backend-hosted rooms on the bundled
└─ blax-webrtc-sfu Rust SFU sidecar (str0m) — JSON control
socket, plug-and-play binary (rust/)
``` ```
The domain layer runs on **any** transport (the bundled server, or a host WS like learn-atc's fork-per-message WebSocket). The bundled server + media engine are opt-in. The domain layer runs on **any** transport (the bundled server, or a host WS like learn-atc's fork-per-message WebSocket). The bundled server + media engine are opt-in.

View File

@ -3,6 +3,7 @@
use Blax\WebRtc\Authorization\DefaultRoomAuthorizer; use Blax\WebRtc\Authorization\DefaultRoomAuthorizer;
use Blax\WebRtc\Events\NullCallEventListener; use Blax\WebRtc\Events\NullCallEventListener;
use Blax\WebRtc\Media\NullMediaEngine; use Blax\WebRtc\Media\NullMediaEngine;
use Blax\WebRtc\Rooms\Stores\ArrayRoomStore;
return [ return [
/* /*
@ -27,11 +28,55 @@ return [
| Media engine | Media engine
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| The backend that terminates media (ICE/DTLS/SRTP/RTP/Opus), records, and | The backend that terminates media (ICE/DTLS/SRTP/RTP/Opus), records, and
| bridges to external realtime providers. NullMediaEngine = signaling only; | bridges to external realtime providers. NullMediaEngine = signaling only
| Str0mMediaEngine requires the blax_webrtc Rust extension (see rust/). | (peer-to-peer mesh); RustMediaEngine = BACKEND-HOSTED rooms on the Rust
| SFU sidecar (plug-and-play see the `sfu` block below).
*/ */
'media_engine' => env('WEBRTC_MEDIA_ENGINE', NullMediaEngine::class), 'media_engine' => env('WEBRTC_MEDIA_ENGINE', NullMediaEngine::class),
/*
|--------------------------------------------------------------------------
| Rust SFU sidecar (RustMediaEngine)
|--------------------------------------------------------------------------
| The `blax-webrtc-sfu` binary from this package's rust/ directory: one
| process per host that terminates ICE/DTLS/SRTP and forwards audio between
| room peers (no decoding), records Ogg/Opus per peer, and reports usage.
|
| Plug-and-play defaults: the first RustMediaEngine call DOWNLOADS the
| checksummed prebuilt binary for this platform (auto_install) and SPAWNS
| it detached, flock-guarded (auto_spawn). For supervised deployments run
| `php artisan webrtc:sidecar` as a service and disable auto_spawn.
|
| udp_port 0 = ephemeral (fine same-host); pin it (and publish it on the
| container) for production, and set public_ip to the address browsers
| can reach it is advertised in the ICE host candidate.
*/
'sfu' => [
'socket' => env('WEBRTC_SFU_SOCKET', storage_path('app/webrtc/sfu.sock')),
'udp_ip' => env('WEBRTC_SFU_UDP_IP', '0.0.0.0'),
'udp_port' => (int) env('WEBRTC_SFU_UDP_PORT', 0),
'public_ip' => env('WEBRTC_SFU_PUBLIC_IP'),
'auto_spawn' => (bool) env('WEBRTC_SFU_AUTO_SPAWN', true),
'auto_install' => (bool) env('WEBRTC_SFU_AUTO_INSTALL', true),
'spawn_timeout' => (float) env('WEBRTC_SFU_SPAWN_TIMEOUT', 10),
'log' => env('WEBRTC_SFU_LOG', storage_path('logs/webrtc-sfu.log')),
// Binary resolution: explicit path > local rust/target build > install.
'binary' => env('WEBRTC_SFU_BINARY'),
'install_path' => env('WEBRTC_SFU_INSTALL_PATH', storage_path('app/webrtc/bin')),
'version' => env('WEBRTC_SFU_VERSION'), // default: the version pinned by this package
'download_url' => env(
'WEBRTC_SFU_DOWNLOAD_URL',
'https://git.blax.at/blax-software/laravel-webrtc/releases/download/{version}/blax-webrtc-sfu-{platform}',
),
// sha256 verification is REQUIRED when set; null opts out explicitly.
'checksum_url' => env(
'WEBRTC_SFU_CHECKSUM_URL',
'https://git.blax.at/blax-software/laravel-webrtc/releases/download/{version}/blax-webrtc-sfu-{platform}.sha256',
),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Recording (store the full call audio) | Recording (store the full call audio)
@ -67,7 +112,7 @@ return [
| worker) binds a Cache/Redis-backed RoomStore so state survives across | worker) binds a Cache/Redis-backed RoomStore so state survives across
| workers that is how a host runs this relay on its existing WS. | workers that is how a host runs this relay on its existing WS.
*/ */
'room_store' => env('WEBRTC_ROOM_STORE', \Blax\WebRtc\Rooms\Stores\ArrayRoomStore::class), 'room_store' => env('WEBRTC_ROOM_STORE', ArrayRoomStore::class),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

824
rust/Cargo.lock generated Normal file
View File

@ -0,0 +1,824 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "anyhow"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "blax-webrtc-sfu"
version = "0.1.0"
dependencies = [
"anyhow",
"env_logger",
"log",
"ogg",
"serde",
"serde_json",
"str0m",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.2.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
dependencies = [
"crc-catalog",
]
[[package]]
name = "crc-catalog"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "env_filter"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "fastrand"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e"
dependencies = [
"defmt",
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "ogg"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdab8dcd8d4052eaacaf8fb07a3ccd9a6e26efadb42878a413c68fc4af1dee2b"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl"
version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
"foreign-types",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "regex"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "sctp-proto"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4dea4fe3384a24652f065296ac333c810dfd0c5b39b98a2214762c16aaadc3c"
dependencies = [
"bytes",
"crc",
"fxhash",
"log",
"rand",
"slab",
"thiserror 1.0.69",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"sha1-asm",
]
[[package]]
name = "sha1-asm"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "286acebaf8b67c1130aedffad26f594eff0c1292389158135327d2e23aed582b"
dependencies = [
"cc",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "str0m"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6110f29fbdda0516ae4ca52b62d802fbb6f874275db717a340ebb0ddd86e51c"
dependencies = [
"combine",
"crc",
"fastrand",
"hmac",
"libc",
"once_cell",
"openssl",
"openssl-sys",
"sctp-proto",
"serde",
"sha1",
"thiserror 1.0.69",
"tracing",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zerocopy"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@ -1,26 +1,26 @@
[package] [package]
name = "blax_webrtc" name = "blax-webrtc-sfu"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "Rust media core (str0m) for blax-software/laravel-webrtc, exposed to PHP via ext-php-rs." description = "Rust SFU data plane for blax-software/laravel-webrtc: terminates ICE/DTLS/SRTP (str0m), forwards Opus RTP between room peers, records, and is driven by PHP over a JSON control socket."
publish = false publish = false
# Built as a PHP extension (cdylib). `cargo php install` loads it into your PHP. [[bin]]
[lib] name = "blax-webrtc-sfu"
crate-type = ["cdylib"] path = "src/main.rs"
[dependencies] [dependencies]
# Sans-IO WebRTC (ICE/DTLS/SRTP/RTP), built for SFU + recording use cases. # Sans-IO WebRTC (ICE/DTLS/SRTP/RTP) — the media core.
str0m = "0.6" str0m = "0.6"
# Zend bindings so PHP can drive the core (SDP/ICE/control across FFI). serde = { version = "1", features = ["derive"] }
ext-php-rs = "0.12" serde_json = "1"
anyhow = "1"
# Added as the core grows: log = "0.4"
# opus = "..." # audio (de)coding for recording / transcoding env_logger = "0.11"
# hound / ogg = "..." # write recordings to disk ogg = "0.9"
# tokio = "..." # the OpenAI-realtime bridge (server-side WebSocket)
[profile.release] [profile.release]
lto = true lto = true
codegen-units = 1 codegen-units = 1
strip = true

View File

@ -1,39 +1,65 @@
# blax_webrtc — Rust media core # blax-webrtc-sfu — the Rust data plane
The timing-critical WebRTC media path for `blax-software/laravel-webrtc`, so that The sidecar binary behind `Blax\WebRtc\Media\RustMediaEngine`: **backend-hosted
PHP never runs per-20ms real-time DSP. rooms**. PHP/Laravel owns the control plane (rooms, auth, signaling
orchestration, billing hooks); this process owns everything per-packet.
## Why Rust here - **ICE** (Interactive Connectivity Establishment — finds a working network
path to each browser), **DTLS** (the UDP TLS handshake that negotiates keys)
and **SRTP** (the encrypted media packets) are terminated here via
[str0m](https://github.com/algesten/str0m), a sans-IO Rust WebRTC stack.
- **SFU forwarding** (Selective Forwarding Unit): each peer's Opus audio is
received once and forwarded to the other peers of its room **without
decoding** — one loop thread, one shared UDP socket, `rtc.accepts()`
demultiplexing.
- **Recording**: per-peer Ogg/Opus written straight from the depayloaded
stream — playable by ffmpeg/browsers, no transcoding, no client cooperation.
- **Negotiation**: the initial offer/answer arrives via the control socket
(from PHP signaling). Renegotiation as peers join rides each browser's
WebRTC **data channel** directly — PHP never relays it (see str0m's `chat`
example, which this follows).
Handling **connections** concurrently in PHP is a solved problem (Swoole, ## Control protocol (PHP ⇄ sidecar)
FrankenPHP, ReactPHP). What is *not* proven is sustained per-packet media DSP
(decrypt SRTP + Opus + RTP jitter every 20ms) glitch-free in single-threaded PHP.
That work belongs in a language built for it. `str0m` is **sans-IO** (no threads
or async of its own — you feed it bytes), which makes it ideal to embed and drive
from PHP.
- `str0m` owns ICE / DTLS / SRTP / RTP / Opus. JSON-lines over a Unix socket. Requests carry an `id` echoed by the reply;
- `ext-php-rs` exposes a thin function surface to PHP. notices are pushed without one.
- PHP (`Str0mMediaEngine`) forwards SDP/ICE/control across the FFI boundary and
calls back into Laravel for persistence (recordings, transmission rows).
## Build ```
→ {"id":1,"cmd":"add_peer","room":"lobby","peer":"alice","offer":"v=0…","record":"/rec/alice.ogg"}
```bash ← {"id":1,"ok":true,"answer":"v=0…"}
# one-time: cargo + the ext-php-rs helper → {"id":2,"cmd":"mute_peer","room":"lobby","peer":"alice","muted":true}
cargo install cargo-php → {"id":3,"cmd":"record_start","room":"lobby","peer":"alice","path":"/rec/a.ogg"} (record_stop too)
→ {"id":4,"cmd":"remove_peer","room":"lobby","peer":"alice"}
cd rust → {"id":5,"cmd":"stats"} ← rooms → peers → {connected, muted, seconds}
cargo php install --release # compiles the cdylib, installs it as the `blax_webrtc` extension → {"id":6,"cmd":"ping"} / {"id":7,"cmd":"shutdown"}
php -m | grep blax_webrtc # verify it loaded ← {"event":"peer_connected","room":"lobby","peer":"alice"}
← {"event":"peer_left","room":"lobby","peer":"alice","seconds":42.5}
``` ```
Then set `WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine`. ## Build & test
## Status ```bash
cargo build && cargo test # 7 tests incl. an end-to-end control-socket run
./build-release.sh # static musl release + .sha256 into dist/
```
Scaffold. `src/lib.rs` documents the intended FFI functions On NixOS: `nix-shell -p gcc pkg-config openssl perl gnumake musl --run ./build-release.sh`.
(`blax_webrtc_offer`, `..._add_ice_candidate`, `..._start_recording`,
`..._connect_peers`, `..._bridge`, `..._close`) that `Str0mMediaEngine` forwards ## Releasing
to. Implement them against `str0m`'s RTP/Frame API, then flip the PHP engine off
`NullMediaEngine`. `BinaryManager::VERSION` (PHP) pins the release tag it downloads from. To ship:
build `dist/` artifacts (linux-x86_64 at minimum), attach both files to the
Forgejo release with that tag, and bump `VERSION` when the protocol changes.
PHP resolves binaries in this order: `webrtc.sfu.binary` override → a local
`target/{release,debug}` build (dev) → the downloaded install.
## CLI
```
blax-webrtc-sfu --socket /run/sfu.sock --udp-ip 0.0.0.0 --udp-port 41000 --public-ip 203.0.113.9
```
`--udp-port 0` (default) picks an ephemeral port — fine on one host, pin it in
production and publish it (UDP) on the container. `--public-ip` is what
browsers are told to reach in the ICE host candidate; it defaults to the
default-route interface address.

45
rust/build-release.sh Executable file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Build the release artifacts for a laravel-webrtc release tag:
# blax-webrtc-sfu-<platform> (static musl binary on Linux)
# blax-webrtc-sfu-<platform>.sha256 (checksum BinaryManager verifies)
#
# Usage: ./build-release.sh [target-triple]
# default target: x86_64-unknown-linux-musl (fully static — runs on any
# Linux, glibc or musl, any distro; str0m's vendored-openssl builds into it).
#
# Attach both files to the Forgejo release whose tag matches
# BinaryManager::VERSION — that is where webrtc:install / auto_install look.
set -euo pipefail
cd "$(dirname "$0")"
TARGET="${1:-x86_64-unknown-linux-musl}"
case "$TARGET" in
x86_64-unknown-linux-*) PLATFORM="linux-x86_64" ;;
aarch64-unknown-linux-*) PLATFORM="linux-aarch64" ;;
x86_64-apple-darwin) PLATFORM="darwin-x86_64" ;;
aarch64-apple-darwin) PLATFORM="darwin-aarch64" ;;
*) echo "unmapped target triple: $TARGET" >&2; exit 1 ;;
esac
if command -v rustup >/dev/null; then
rustup target add "$TARGET" >/dev/null
fi
# musl static linking needs a musl C toolchain for vendored OpenSSL.
if [[ "$TARGET" == *musl* ]] && ! command -v musl-gcc >/dev/null; then
echo "musl-gcc not found — install musl-tools (debian) / musl (nix: nix-shell -p musl gcc pkg-config perl gnumake)" >&2
fi
cargo build --release --target "$TARGET"
mkdir -p dist
OUT="dist/blax-webrtc-sfu-$PLATFORM"
cp "target/$TARGET/release/blax-webrtc-sfu" "$OUT"
(cd dist && sha256sum "$(basename "$OUT")" > "$(basename "$OUT").sha256")
echo
echo "Release artifacts:"
ls -la dist/
echo
echo "Upload both files to the release tagged $(grep -m1 '^version' Cargo.toml | cut -d'"' -f2 | sed 's/^/v/')."

106
rust/src/control.rs Normal file
View File

@ -0,0 +1,106 @@
//! Unix-socket control plane: JSON-lines requests in, replies + pushed events out.
//!
//! Each connection gets a reader thread (parses requests, forwards them to the
//! SFU loop, relays the reply) and a writer thread (serializes replies AND
//! broadcast notices onto the stream, one JSON object per line).
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex};
use std::thread;
use log::warn;
use crate::proto::{Notice, Reply, Request};
use crate::sfu::CtrlMsg;
/// Every connected control client's outbound line channel (for notices).
pub type Subscribers = Arc<Mutex<Vec<Sender<String>>>>;
/// Fan notices out to every connected control client.
pub fn broadcast_notices(rx: mpsc::Receiver<Notice>, subscribers: Subscribers) {
for notice in rx {
let json = serde_json::to_string(&notice).expect("notice to serialize");
subscribers
.lock()
.unwrap()
.retain(|tx| tx.send(json.clone()).is_ok());
}
}
/// Accept control connections forever (runs on its own thread).
pub fn serve(listener: UnixListener, commands: Sender<CtrlMsg>, subscribers: Subscribers) {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let commands = commands.clone();
let subscribers = subscribers.clone();
thread::spawn(move || handle_connection(stream, commands, subscribers));
}
Err(e) => warn!("control accept failed: {e}"),
}
}
}
fn handle_connection(stream: UnixStream, commands: Sender<CtrlMsg>, subscribers: Subscribers) {
let write_half = match stream.try_clone() {
Ok(s) => s,
Err(e) => {
warn!("control connection clone failed: {e}");
return;
}
};
// One writer thread serializes replies + notices; dies when senders drop
// or the peer closes (write error).
let (out_tx, out_rx) = mpsc::channel::<String>();
subscribers.lock().unwrap().push(out_tx.clone());
let writer = thread::spawn(move || {
let mut stream = write_half;
for line in out_rx {
if writeln!(stream, "{line}").and_then(|()| stream.flush()).is_err() {
break;
}
}
});
let reader = BufReader::new(stream);
for line in reader.lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let request: Request = match serde_json::from_str(&line) {
Ok(request) => request,
Err(e) => {
let reply = Reply::error(0, format!("bad request: {e}"));
let _ = out_tx.send(serde_json::to_string(&reply).unwrap());
continue;
}
};
let (reply_tx, reply_rx) = mpsc::channel();
if commands
.send(CtrlMsg {
request,
reply: reply_tx,
})
.is_err()
{
break; // SFU loop gone (shutdown)
}
match reply_rx.recv() {
Ok(reply) => {
let _ = out_tx.send(serde_json::to_string(&reply).unwrap());
}
Err(_) => break,
}
}
drop(out_tx);
let _ = writer.join();
}

View File

@ -1,57 +1,13 @@
//! blax_webrtc — the Rust media core for `blax-software/laravel-webrtc`. //! blax-webrtc-sfu — the Rust data plane for `blax-software/laravel-webrtc`.
//! //!
//! Owns the timing-critical WebRTC media path (ICE / DTLS / SRTP / RTP / Opus) //! PHP/Laravel owns the CONTROL plane (rooms, auth, signaling orchestration,
//! via `str0m` (a sans-IO Rust WebRTC implementation built for SFU + recording), //! billing hooks) and drives this sidecar over a JSON-lines Unix socket. This
//! and exposes a thin surface to PHP via `ext-php-rs`. PHP drives signaling and //! crate owns everything per-packet: ICE (connectivity), DTLS (key exchange),
//! orchestration; the per-20ms media DSP runs here in Rust, so it never stalls //! SRTP (encrypted media), RTP forwarding between room peers (SFU — no
//! the PHP event loop or fights PHP's GC. //! decoding), per-peer Ogg/Opus recording, and connection stats/usage events.
//!
//! STATUS: scaffold. The functions below are the intended FFI surface that
//! `BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` forwards to. They are not
//! implemented yet — this file documents the seam and keeps the crate buildable
//! once the dependencies are added.
//!
//! Build (once implemented):
//! cd rust && cargo php install --release
//! which compiles the cdylib and installs it as the `blax_webrtc` PHP extension.
// use ext_php_rs::prelude::*; pub mod control;
// pub mod proto;
// /// Accept a browser SDP offer for `peer_id`; return the answer SDP. pub mod recording;
// #[php_function] pub mod server;
// pub fn blax_webrtc_offer(peer_id: &str, sdp_offer: &str) -> String { pub mod sfu;
// todo!("create a str0m Rtc, apply the remote offer, return the local answer")
// }
//
// /// Feed a trickle-ICE candidate for `peer_id`.
// #[php_function]
// pub fn blax_webrtc_add_ice_candidate(peer_id: &str, candidate: &str) {
// todo!("parse + add the candidate to the peer's str0m Rtc")
// }
//
// /// Start recording `peer_id`'s inbound audio to `path` (Opus/WAV).
// #[php_function]
// pub fn blax_webrtc_start_recording(peer_id: &str, path: &str) {
// todo!("tee the peer's decoded audio frames to a file writer")
// }
//
// #[php_function]
// pub fn blax_webrtc_stop_recording(peer_id: &str) { todo!() }
//
// /// Forward media between two peers (party-to-party / group calls).
// #[php_function]
// pub fn blax_webrtc_connect_peers(peer_a: &str, peer_b: &str) { todo!() }
//
// /// Bridge `peer_id` to an external realtime provider (e.g. OpenAI Realtime over
// /// a server-side WebSocket). The browser never sees the provider.
// #[php_function]
// pub fn blax_webrtc_bridge(peer_id: &str, options_json: &str) { todo!() }
//
// /// Close + free a peer.
// #[php_function]
// pub fn blax_webrtc_close(peer_id: &str) { todo!() }
//
// #[php_module]
// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
// module
// }

55
rust/src/main.rs Normal file
View File

@ -0,0 +1,55 @@
use std::net::IpAddr;
use std::path::PathBuf;
use anyhow::{bail, Context};
use blax_webrtc_sfu::server::{self, Config};
const USAGE: &str = "blax-webrtc-sfu — Rust SFU data plane for blax-software/laravel-webrtc
USAGE:
blax-webrtc-sfu [OPTIONS]
OPTIONS:
--socket <path> Control socket path [default: /tmp/blax-webrtc-sfu.sock]
--udp-ip <ip> Media UDP bind ip [default: 0.0.0.0]
--udp-port <port> Media UDP port [default: 0 = ephemeral]
--public-ip <ip> IP advertised to browsers in ICE candidates
[default: the default-route interface address]
-h, --help Show this help
";
fn main() -> anyhow::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let mut config = Config {
socket: PathBuf::from("/tmp/blax-webrtc-sfu.sock"),
udp_ip: IpAddr::from([0, 0, 0, 0]),
udp_port: 0,
public_ip: None,
};
let mut args = std::env::args().skip(1);
while let Some(flag) = args.next() {
let mut value = |name: &str| {
args.next()
.with_context(|| format!("{name} requires a value"))
};
match flag.as_str() {
"--socket" => config.socket = PathBuf::from(value("--socket")?),
"--udp-ip" => config.udp_ip = value("--udp-ip")?.parse().context("--udp-ip")?,
"--udp-port" => {
config.udp_port = value("--udp-port")?.parse().context("--udp-port")?
}
"--public-ip" => {
config.public_ip = Some(value("--public-ip")?.parse().context("--public-ip")?)
}
"-h" | "--help" => {
print!("{USAGE}");
return Ok(());
}
other => bail!("unknown option: {other}\n\n{USAGE}"),
}
}
server::run(config)
}

179
rust/src/proto.rs Normal file
View File

@ -0,0 +1,179 @@
//! JSON-lines control protocol between PHP (`RustMediaEngine`) and this sidecar.
//!
//! Requests carry an `id` the reply echoes back; events have no `id` and are
//! pushed to every connected control client.
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct Request {
pub id: u64,
#[serde(flatten)]
pub command: Command,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum Command {
/// Liveness probe (also used by PHP to detect an already-running sidecar).
Ping,
/// Accept a browser SDP offer for `peer` in `room`; reply carries the answer.
/// Further renegotiation (new tracks) rides the WebRTC data channel directly.
AddPeer {
room: String,
peer: String,
offer: String,
#[serde(default)]
record: Option<String>,
},
/// Kick a peer (disconnects its transport; emits `peer_left`).
RemovePeer { room: String, peer: String },
/// Server-side mute: stop forwarding the peer's audio to the room.
MutePeer {
room: String,
peer: String,
muted: bool,
},
/// Start recording a peer's inbound audio to `path` (Ogg/Opus).
RecordStart {
room: String,
peer: String,
path: String,
},
/// Stop and finalize a peer's recording.
RecordStop { room: String, peer: String },
/// Snapshot of rooms/peers (connection state, mute, connected seconds).
Stats,
/// Graceful stop.
Shutdown,
}
#[derive(Debug, Serialize)]
pub struct Reply {
pub id: u64,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub answer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rooms: Option<Vec<RoomStats>>,
}
impl Reply {
pub fn ok(id: u64) -> Self {
Reply {
id,
ok: true,
answer: None,
error: None,
rooms: None,
}
}
pub fn answer(id: u64, sdp: String) -> Self {
Reply {
answer: Some(sdp),
..Reply::ok(id)
}
}
pub fn error(id: u64, error: impl Into<String>) -> Self {
Reply {
ok: false,
error: Some(error.into()),
..Reply::ok(id)
}
}
pub fn stats(id: u64, rooms: Vec<RoomStats>) -> Self {
Reply {
rooms: Some(rooms),
..Reply::ok(id)
}
}
}
#[derive(Debug, Serialize)]
pub struct RoomStats {
pub room: String,
pub peers: Vec<PeerStats>,
}
#[derive(Debug, Serialize)]
pub struct PeerStats {
pub peer: String,
pub connected: bool,
pub muted: bool,
pub seconds: f64,
}
/// Server-pushed notifications (no `id`).
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Notice {
PeerConnected { room: String, peer: String },
PeerLeft { room: String, peer: String, seconds: f64 },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_add_peer() {
let req: Request = serde_json::from_str(
r#"{"id":7,"cmd":"add_peer","room":"r1","peer":"p1","offer":"v=0...","record":"/tmp/rec/p1.ogg"}"#,
)
.unwrap();
assert_eq!(req.id, 7);
match req.command {
Command::AddPeer {
room,
peer,
offer,
record,
} => {
assert_eq!(room, "r1");
assert_eq!(peer, "p1");
assert_eq!(offer, "v=0...");
assert_eq!(record.as_deref(), Some("/tmp/rec/p1.ogg"));
}
other => panic!("wrong command: {other:?}"),
}
}
#[test]
fn record_defaults_to_none() {
let req: Request =
serde_json::from_str(r#"{"id":1,"cmd":"add_peer","room":"r","peer":"p","offer":"x"}"#)
.unwrap();
match req.command {
Command::AddPeer { record, .. } => assert!(record.is_none()),
other => panic!("wrong command: {other:?}"),
}
}
#[test]
fn reply_serializes_compactly() {
let json = serde_json::to_string(&Reply::answer(3, "v=0".into())).unwrap();
assert_eq!(json, r#"{"id":3,"ok":true,"answer":"v=0"}"#);
let json = serde_json::to_string(&Reply::error(4, "no such peer")).unwrap();
assert_eq!(json, r#"{"id":4,"ok":false,"error":"no such peer"}"#);
}
#[test]
fn notice_has_event_tag() {
let json = serde_json::to_string(&Notice::PeerLeft {
room: "r".into(),
peer: "p".into(),
seconds: 1.5,
})
.unwrap();
assert_eq!(
json,
r#"{"event":"peer_left","room":"r","peer":"p","seconds":1.5}"#
);
}
}

150
rust/src/recording.rs Normal file
View File

@ -0,0 +1,150 @@
//! Server-side per-peer call recording: Opus frames (as depayloaded by str0m)
//! muxed into an Ogg container, playable by browsers/ffmpeg without transcoding.
use std::fs::File;
use std::io::Error;
use std::path::Path;
use ogg::{PacketWriteEndInfo, PacketWriter};
const SAMPLE_RATE: f64 = 48_000.0;
/// Standard Opus pre-skip (3840 samples = 80ms at 48kHz).
const PRE_SKIP: u16 = 3840;
pub struct OggOpusWriter {
writer: PacketWriter<'static, File>,
serial: u32,
/// RTP media time (seconds) of the first frame — granule positions are
/// computed relative to it, so pauses (DTX) keep the timeline correct.
base_time: Option<f64>,
granule: u64,
finished: bool,
}
impl OggOpusWriter {
pub fn create(path: &Path, channels: u8) -> Result<Self, Error> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut writer = PacketWriter::new(File::create(path)?);
// Stream serial only needs to be unique within the file; one stream per file.
let serial: u32 = 0x626c_6178; // "blax"
writer.write_packet(opus_head(channels), serial, PacketWriteEndInfo::EndPage, 0)?;
writer.write_packet(opus_tags(), serial, PacketWriteEndInfo::EndPage, 0)?;
Ok(OggOpusWriter {
writer,
serial,
base_time: None,
granule: 0,
finished: false,
})
}
/// Append one Opus frame. `time_seconds` is the frame's RTP media time
/// (`MediaData::time.as_seconds()`).
pub fn write_frame(&mut self, frame: &[u8], time_seconds: f64) -> Result<(), Error> {
if self.finished {
return Ok(());
}
let base = *self.base_time.get_or_insert(time_seconds);
let elapsed = (time_seconds - base).max(0.0);
// Granule = absolute decoded sample position at 48kHz. One 20ms frame = 960
// samples; deriving from RTP time keeps sync even with DTX/packet loss.
let granule = (elapsed * SAMPLE_RATE).round() as u64 + 960;
self.granule = granule.max(self.granule);
self.writer.write_packet(
frame.to_vec(),
self.serial,
PacketWriteEndInfo::NormalPacket,
self.granule,
)
}
/// Close the logical Ogg stream (writes the end-of-stream page).
pub fn finish(&mut self) -> Result<(), Error> {
if self.finished {
return Ok(());
}
self.finished = true;
self.writer.write_packet(
Vec::new(),
self.serial,
PacketWriteEndInfo::EndStream,
self.granule,
)
}
}
impl Drop for OggOpusWriter {
fn drop(&mut self) {
let _ = self.finish();
}
}
/// <https://datatracker.ietf.org/doc/html/rfc7845#section-5.1>
fn opus_head(channels: u8) -> Vec<u8> {
let mut head = Vec::with_capacity(19);
head.extend_from_slice(b"OpusHead");
head.push(1); // version
head.push(channels);
head.extend_from_slice(&PRE_SKIP.to_le_bytes());
head.extend_from_slice(&48_000u32.to_le_bytes()); // input sample rate
head.extend_from_slice(&0i16.to_le_bytes()); // output gain
head.push(0); // channel mapping family
head
}
fn opus_tags() -> Vec<u8> {
let vendor = b"blax-webrtc-sfu";
let mut tags = Vec::new();
tags.extend_from_slice(b"OpusTags");
tags.extend_from_slice(&(vendor.len() as u32).to_le_bytes());
tags.extend_from_slice(vendor);
tags.extend_from_slice(&0u32.to_le_bytes()); // user comment count
tags
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_a_playable_ogg_opus_skeleton() {
let dir = std::env::temp_dir().join("blax-webrtc-sfu-test");
let path = dir.join("rec.ogg");
let _ = std::fs::remove_file(&path);
let mut w = OggOpusWriter::create(&path, 1).unwrap();
// Two fake 20ms frames.
w.write_frame(&[0xFC, 0xFF, 0xFE], 10.0).unwrap();
w.write_frame(&[0xFC, 0xFF, 0xFE], 10.02).unwrap();
w.finish().unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..4], b"OggS", "ogg capture pattern");
let head = bytes
.windows(8)
.position(|win| win == b"OpusHead")
.expect("OpusHead present");
let tags = bytes
.windows(8)
.position(|win| win == b"OpusTags")
.expect("OpusTags present");
assert!(head < tags);
}
#[test]
fn granule_tracks_rtp_time() {
let dir = std::env::temp_dir().join("blax-webrtc-sfu-test");
let mut w = OggOpusWriter::create(&dir.join("granule.ogg"), 1).unwrap();
w.write_frame(&[0], 5.0).unwrap();
assert_eq!(w.granule, 960);
w.write_frame(&[0], 6.0).unwrap(); // 1s gap (DTX) => 48000 samples later
assert_eq!(w.granule, 48_960);
}
}

85
rust/src/server.rs Normal file
View File

@ -0,0 +1,85 @@
//! Wires the pieces together: UDP media socket + Unix control socket + SFU loop.
//! Shared by `main.rs` and the integration tests.
use std::net::{IpAddr, SocketAddr, UdpSocket};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use anyhow::{bail, Context};
use log::info;
use crate::control;
use crate::sfu::Sfu;
pub struct Config {
/// Unix socket path for the JSON control protocol.
pub socket: PathBuf,
/// Bind address for the shared media UDP socket.
pub udp_ip: IpAddr,
/// UDP port (0 = ephemeral).
pub udp_port: u16,
/// IP advertised to browsers in the host ICE candidate. Defaults to the
/// default-route interface address (or the bind ip when it is specific).
pub public_ip: Option<IpAddr>,
}
/// Run the sidecar until a `shutdown` control command. Blocking.
pub fn run(config: Config) -> anyhow::Result<()> {
// Refuse to clobber a live sidecar; clean up a stale socket file.
if config.socket.exists() {
if UnixStream::connect(&config.socket).is_ok() {
bail!(
"another sidecar is already listening on {}",
config.socket.display()
);
}
std::fs::remove_file(&config.socket).context("removing stale control socket")?;
}
if let Some(dir) = config.socket.parent() {
std::fs::create_dir_all(dir).context("creating control socket directory")?;
}
let socket = UdpSocket::bind((config.udp_ip, config.udp_port)).context("binding UDP")?;
let local = socket.local_addr()?;
let public_ip = config
.public_ip
.or_else(|| (!config.udp_ip.is_unspecified()).then_some(config.udp_ip))
.or_else(default_route_ip)
.unwrap_or_else(|| IpAddr::from([127, 0, 0, 1]));
let advertise = SocketAddr::new(public_ip, local.port());
let (cmd_tx, cmd_rx) = mpsc::channel();
let (notice_tx, notice_rx) = mpsc::channel();
let subscribers: control::Subscribers = Arc::new(Mutex::new(Vec::new()));
let listener = UnixListener::bind(&config.socket).context("binding control socket")?;
{
let subscribers = subscribers.clone();
thread::spawn(move || control::serve(listener, cmd_tx, subscribers));
}
thread::spawn(move || control::broadcast_notices(notice_rx, subscribers));
info!(
"blax-webrtc-sfu ready — control {} · media udp {} (advertising {})",
config.socket.display(),
local,
advertise
);
Sfu::new(socket, advertise, cmd_rx, notice_tx).run();
let _ = std::fs::remove_file(&config.socket);
Ok(())
}
/// The local IP of the default route (no packets are sent — UDP connect only
/// resolves the source address).
fn default_route_ip() -> Option<IpAddr> {
let probe = UdpSocket::bind("0.0.0.0:0").ok()?;
probe.connect("8.8.8.8:80").ok()?;
Some(probe.local_addr().ok()?.ip())
}

807
rust/src/sfu.rs Normal file
View File

@ -0,0 +1,807 @@
//! The SFU (Selective Forwarding Unit) media loop.
//!
//! One thread owns every peer's str0m `Rtc` plus the single shared UDP socket
//! (peers are demultiplexed via `rtc.accepts()`). Media is forwarded between
//! peers of the same room WITHOUT decoding. Commands arrive from the control
//! socket over an mpsc channel; notices flow back the other way.
//!
//! The negotiation model follows str0m's canonical SFU example (`chat.rs`):
//! the initial browser offer/answer goes through the control plane (PHP
//! signaling); every renegotiation after that (new tracks as peers join) is
//! exchanged over the WebRTC DATA CHANNEL directly — PHP never relays it.
use std::collections::VecDeque;
use std::io::ErrorKind;
use std::net::{SocketAddr, UdpSocket};
use std::ops::Deref;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use log::{debug, info, warn};
use str0m::change::{SdpAnswer, SdpOffer, SdpPendingOffer};
use str0m::channel::{ChannelData, ChannelId};
use str0m::media::{Direction, KeyframeRequest, KeyframeRequestKind, MediaData, MediaKind, Mid, Rid};
use str0m::net::{Protocol, Receive};
use str0m::{Candidate, Event, IceConnectionState, Input, Output, Rtc};
use crate::proto::{Command, Notice, PeerStats, Reply, Request, RoomStats};
use crate::recording::OggOpusWriter;
/// A control request paired with where to send its reply.
pub struct CtrlMsg {
pub request: Request,
pub reply: Sender<Reply>,
}
pub struct Sfu {
socket: UdpSocket,
/// The candidate address advertised to browsers (public ip + UDP port).
advertise: SocketAddr,
commands: Receiver<CtrlMsg>,
notices: Sender<Notice>,
clients: Vec<Client>,
}
impl Sfu {
pub fn new(
socket: UdpSocket,
advertise: SocketAddr,
commands: Receiver<CtrlMsg>,
notices: Sender<Notice>,
) -> Self {
Sfu {
socket,
advertise,
commands,
notices,
clients: Vec::new(),
}
}
/// Run until a `shutdown` command arrives.
pub fn run(mut self) {
let mut to_propagate: VecDeque<Propagated> = VecDeque::new();
let mut buf = vec![0; 2000];
loop {
self.reap_dead_clients();
match self.drain_commands() {
ControlFlow::Continue => {}
ControlFlow::Shutdown => {
info!("shutdown command received, stopping SFU loop");
return;
}
}
// Poll clients until they all return a timeout.
let mut timeout = Instant::now() + Duration::from_millis(100);
for client in self.clients.iter_mut() {
let t = poll_until_timeout(client, &mut to_propagate, &self.socket);
timeout = timeout.min(t);
}
// If we have an item to propagate, do that before sleeping.
if let Some(p) = to_propagate.pop_front() {
self.propagate(p);
continue;
}
let duration = (timeout - Instant::now()).max(Duration::from_millis(1));
self.socket
.set_read_timeout(Some(duration))
.expect("setting socket read timeout");
if let Some(input) = read_socket_input(&self.socket, &mut buf) {
// rtc.accepts() demultiplexes the shared UDP socket to the right peer.
if let Some(client) = self.clients.iter_mut().find(|c| c.accepts(&input)) {
client.handle_input(input);
} else {
// Common: first STUN can arrive before add_peer completes.
debug!("no client accepts UDP input: {:?}", input);
}
}
// Drive time forward in all clients.
let now = Instant::now();
for client in &mut self.clients {
client.handle_input(Input::Timeout(now));
}
self.notify_connected();
}
}
fn drain_commands(&mut self) -> ControlFlow {
loop {
let msg = match self.commands.try_recv() {
Ok(msg) => msg,
Err(TryRecvError::Empty) => return ControlFlow::Continue,
Err(TryRecvError::Disconnected) => {
warn!("control channel disconnected, stopping");
return ControlFlow::Shutdown;
}
};
let id = msg.request.id;
let reply = match msg.request.command {
Command::Ping => Reply::ok(id),
Command::Shutdown => {
let _ = msg.reply.send(Reply::ok(id));
return ControlFlow::Shutdown;
}
Command::AddPeer {
room,
peer,
offer,
record,
} => self.add_peer(id, room, peer, &offer, record.as_deref()),
Command::RemovePeer { room, peer } => self.with_client(id, &room, &peer, |c| {
c.rtc.disconnect();
Reply::ok(id)
}),
Command::MutePeer { room, peer, muted } => {
self.with_client(id, &room, &peer, |c| {
c.muted = muted;
Reply::ok(id)
})
}
Command::RecordStart { room, peer, path } => {
self.with_client(id, &room, &peer, |c| match c.start_recording(&path) {
Ok(()) => Reply::ok(id),
Err(e) => Reply::error(id, format!("recording failed: {e}")),
})
}
Command::RecordStop { room, peer } => self.with_client(id, &room, &peer, |c| {
c.stop_recording();
Reply::ok(id)
}),
Command::Stats => Reply::stats(id, self.stats()),
};
let _ = msg.reply.send(reply);
}
}
fn add_peer(
&mut self,
id: u64,
room: String,
peer: String,
offer_sdp: &str,
record: Option<&str>,
) -> Reply {
let offer = match SdpOffer::from_sdp_string(offer_sdp) {
Ok(offer) => offer,
Err(e) => return Reply::error(id, format!("bad offer sdp: {e}")),
};
let mut rtc = Rtc::builder().build();
let candidate = match Candidate::host(self.advertise, "udp") {
Ok(candidate) => candidate,
Err(e) => return Reply::error(id, format!("bad host candidate: {e}")),
};
rtc.add_local_candidate(candidate);
let answer = match rtc.sdp_api().accept_offer(offer) {
Ok(answer) => answer,
Err(e) => return Reply::error(id, format!("offer rejected: {e}")),
};
// A rejoin with the same peer id replaces the old connection.
if let Some(old) = self
.clients
.iter_mut()
.find(|c| c.room == room && c.peer == peer)
{
info!("replacing existing peer {room}/{peer}");
old.rtc.disconnect();
}
let mut client = Client::new(room.clone(), peer.clone(), rtc);
if let Some(path) = record {
if let Err(e) = client.start_recording(path) {
warn!("recording for {room}/{peer} failed to start: {e}");
}
}
// Open the tracks already present in the room towards the new peer.
for track in self
.clients
.iter()
.filter(|c| c.room == room)
.flat_map(|c| c.tracks_in.iter())
{
client.handle_track_open(Arc::downgrade(&track.id));
}
info!("peer {room}/{peer} added ({} in room)", 1 + self
.clients
.iter()
.filter(|c| c.room == room)
.count());
self.clients.push(client);
Reply::answer(id, answer.to_sdp_string())
}
fn with_client(
&mut self,
id: u64,
room: &str,
peer: &str,
f: impl FnOnce(&mut Client) -> Reply,
) -> Reply {
match self
.clients
.iter_mut()
.find(|c| c.room == room && c.peer == peer)
{
Some(client) => f(client),
None => Reply::error(id, format!("no such peer: {room}/{peer}")),
}
}
fn stats(&self) -> Vec<RoomStats> {
let mut rooms: Vec<RoomStats> = Vec::new();
for client in &self.clients {
let peer = PeerStats {
peer: client.peer.clone(),
connected: client.connected_at.is_some(),
muted: client.muted,
seconds: client
.connected_at
.map(|t| t.elapsed().as_secs_f64())
.unwrap_or(0.0),
};
match rooms.iter_mut().find(|r| r.room == client.room) {
Some(room) => room.peers.push(peer),
None => rooms.push(RoomStats {
room: client.room.clone(),
peers: vec![peer],
}),
}
}
rooms
}
/// Emit `peer_connected` notices for clients that just reached Connected.
fn notify_connected(&mut self) {
for client in &mut self.clients {
if client.connected_at.is_some() && !client.notified_connected {
client.notified_connected = true;
let _ = self.notices.send(Notice::PeerConnected {
room: client.room.clone(),
peer: client.peer.clone(),
});
}
}
}
fn reap_dead_clients(&mut self) {
if self.clients.iter().all(|c| c.rtc.is_alive()) {
return;
}
let clients = std::mem::take(&mut self.clients);
for mut client in clients {
if client.rtc.is_alive() {
self.clients.push(client);
continue;
}
client.stop_recording();
info!("peer {}/{} left", client.room, client.peer);
let _ = self.notices.send(Notice::PeerLeft {
room: client.room.clone(),
peer: client.peer.clone(),
seconds: client
.connected_at
.map(|t| t.elapsed().as_secs_f64())
.unwrap_or(0.0),
});
}
}
/// Send one propagated item to the other clients in the origin's room.
fn propagate(&mut self, propagated: Propagated) {
let Some(client_id) = propagated.client_id() else {
return;
};
let Some(origin) = self.clients.iter().find(|c| c.id == client_id) else {
return; // origin left in the meantime
};
let (room, origin_muted) = (origin.room.clone(), origin.muted);
for client in &mut self.clients {
if client.id == client_id || client.room != room {
continue;
}
match &propagated {
Propagated::TrackOpen(_, track_in) => client.handle_track_open(track_in.clone()),
Propagated::MediaData(_, data) => {
if !origin_muted {
client.handle_media_data_out(client_id, data)
}
}
Propagated::KeyframeRequest(_, req, origin_id, mid_in) => {
// Only the origin client handles the keyframe request.
if *origin_id == client.id {
client.handle_keyframe_request(*req, *mid_in)
}
}
Propagated::Noop | Propagated::Timeout(_) => {}
}
}
}
}
enum ControlFlow {
Continue,
Shutdown,
}
/// Poll all the output from the client until it returns a timeout.
fn poll_until_timeout(
client: &mut Client,
queue: &mut VecDeque<Propagated>,
socket: &UdpSocket,
) -> Instant {
loop {
if !client.rtc.is_alive() {
return Instant::now();
}
let propagated = client.poll_output(socket);
if let Propagated::Timeout(t) = propagated {
return t;
}
queue.push_back(propagated)
}
}
fn read_socket_input<'a>(socket: &UdpSocket, buf: &'a mut Vec<u8>) -> Option<Input<'a>> {
buf.resize(2000, 0);
match socket.recv_from(buf) {
Ok((n, source)) => {
buf.truncate(n);
let Ok(contents) = buf.as_slice().try_into() else {
return None;
};
Some(Input::Receive(
Instant::now(),
Receive {
proto: Protocol::Udp,
source,
destination: socket.local_addr().unwrap(),
contents,
},
))
}
Err(e) => match e.kind() {
ErrorKind::WouldBlock | ErrorKind::TimedOut => None,
_ => panic!("UdpSocket read failed: {e:?}"),
},
}
}
struct Client {
id: ClientId,
room: String,
peer: String,
rtc: Rtc,
pending: Option<SdpPendingOffer>,
cid: Option<ChannelId>,
tracks_in: Vec<TrackInEntry>,
tracks_out: Vec<TrackOut>,
chosen_rid: Option<Rid>,
muted: bool,
recorder: Option<OggOpusWriter>,
connected_at: Option<Instant>,
notified_connected: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClientId(u64);
impl Deref for ClientId {
type Target = u64;
fn deref(&self) -> &Self::Target {
&self.0
}
}
struct TrackIn {
origin: ClientId,
mid: Mid,
kind: MediaKind,
}
struct TrackInEntry {
id: Arc<TrackIn>,
last_keyframe_request: Option<Instant>,
}
struct TrackOut {
track_in: Weak<TrackIn>,
state: TrackOutState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TrackOutState {
ToOpen,
Negotiating(Mid),
Open(Mid),
}
impl TrackOut {
fn mid(&self) -> Option<Mid> {
match self.state {
TrackOutState::ToOpen => None,
TrackOutState::Negotiating(m) | TrackOutState::Open(m) => Some(m),
}
}
}
impl Client {
fn new(room: String, peer: String, rtc: Rtc) -> Client {
static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
let next_id = ID_COUNTER.fetch_add(1, Ordering::SeqCst);
Client {
id: ClientId(next_id),
room,
peer,
rtc,
pending: None,
cid: None,
tracks_in: vec![],
tracks_out: vec![],
chosen_rid: None,
muted: false,
recorder: None,
connected_at: None,
notified_connected: false,
}
}
fn start_recording(&mut self, path: &str) -> Result<(), std::io::Error> {
self.recorder = Some(OggOpusWriter::create(Path::new(path), 1)?);
Ok(())
}
fn stop_recording(&mut self) {
if let Some(mut recorder) = self.recorder.take() {
let _ = recorder.finish();
}
}
fn accepts(&self, input: &Input) -> bool {
self.rtc.accepts(input)
}
fn handle_input(&mut self, input: Input) {
if !self.rtc.is_alive() {
return;
}
if let Err(e) = self.rtc.handle_input(input) {
warn!("client {}/{} disconnected: {:?}", self.room, self.peer, e);
self.rtc.disconnect();
}
}
fn poll_output(&mut self, socket: &UdpSocket) -> Propagated {
if !self.rtc.is_alive() {
return Propagated::Noop;
}
// Incoming tracks from room peers cause new entries in tracks_out that
// need SDP negotiation with this client (over its data channel).
if self.negotiate_if_needed() {
return Propagated::Noop;
}
match self.rtc.poll_output() {
Ok(output) => self.handle_output(output, socket),
Err(e) => {
warn!(
"client {}/{} poll_output failed: {:?}",
self.room, self.peer, e
);
self.rtc.disconnect();
Propagated::Noop
}
}
}
fn handle_output(&mut self, output: Output, socket: &UdpSocket) -> Propagated {
match output {
Output::Transmit(transmit) => {
if let Err(e) = socket.send_to(&transmit.contents, transmit.destination) {
warn!("UDP send_to failed: {e:?}");
}
Propagated::Noop
}
Output::Timeout(t) => Propagated::Timeout(t),
Output::Event(e) => match e {
Event::Connected => {
self.connected_at = Some(Instant::now());
Propagated::Noop
}
Event::IceConnectionStateChange(v) => {
if v == IceConnectionState::Disconnected {
self.rtc.disconnect();
}
Propagated::Noop
}
Event::MediaAdded(e) => self.handle_media_added(e.mid, e.kind),
Event::MediaData(data) => self.handle_media_data_in(data),
Event::KeyframeRequest(req) => self.handle_incoming_keyframe_req(req),
Event::ChannelOpen(cid, _) => {
self.cid = Some(cid);
Propagated::Noop
}
Event::ChannelData(data) => self.handle_channel_data(data),
_ => Propagated::Noop,
},
}
}
fn handle_media_added(&mut self, mid: Mid, kind: MediaKind) -> Propagated {
let track_in = TrackInEntry {
id: Arc::new(TrackIn {
origin: self.id,
mid,
kind,
}),
last_keyframe_request: None,
};
let weak = Arc::downgrade(&track_in.id);
self.tracks_in.push(track_in);
Propagated::TrackOpen(self.id, weak)
}
fn handle_media_data_in(&mut self, data: MediaData) -> Propagated {
if !data.contiguous {
self.request_keyframe_throttled(data.mid, data.rid, KeyframeRequestKind::Fir);
}
if !self.muted {
if let Some(recorder) = &mut self.recorder {
if data.params.spec().codec.is_audio() {
if let Err(e) = recorder.write_frame(&data.data, data.time.as_seconds()) {
warn!("recording write failed for {}/{}: {e}", self.room, self.peer);
self.recorder = None;
}
}
}
}
Propagated::MediaData(self.id, data)
}
fn request_keyframe_throttled(
&mut self,
mid: Mid,
rid: Option<Rid>,
kind: KeyframeRequestKind,
) {
let Some(mut writer) = self.rtc.writer(mid) else {
return;
};
let Some(track_entry) = self.tracks_in.iter_mut().find(|t| t.id.mid == mid) else {
return;
};
if track_entry
.last_keyframe_request
.map(|t| t.elapsed() < Duration::from_secs(1))
.unwrap_or(false)
{
return;
}
_ = writer.request_keyframe(rid, kind);
track_entry.last_keyframe_request = Some(Instant::now());
}
fn handle_incoming_keyframe_req(&self, mut req: KeyframeRequest) -> Propagated {
let Some(track_out) = self.tracks_out.iter().find(|t| t.mid() == Some(req.mid)) else {
return Propagated::Noop;
};
let Some(track_in) = track_out.track_in.upgrade() else {
return Propagated::Noop;
};
req.rid = self.chosen_rid;
Propagated::KeyframeRequest(self.id, req, track_in.origin, track_in.mid)
}
fn negotiate_if_needed(&mut self) -> bool {
if self.cid.is_none() || self.pending.is_some() {
// No data channel yet, or a negotiation is already in flight.
return false;
}
let mut change = self.rtc.sdp_api();
for track in &mut self.tracks_out {
if let TrackOutState::ToOpen = track.state {
if let Some(track_in) = track.track_in.upgrade() {
let stream_id = track_in.origin.to_string();
let mid =
change.add_media(track_in.kind, Direction::SendOnly, Some(stream_id), None);
track.state = TrackOutState::Negotiating(mid);
}
}
}
if !change.has_changes() {
return false;
}
let Some((offer, pending)) = change.apply() else {
return false;
};
let Some(mut channel) = self.cid.and_then(|id| self.rtc.channel(id)) else {
return false;
};
let json = serde_json::to_string(&offer).expect("offer to serialize");
if channel.write(false, json.as_bytes()).is_err() {
return false;
}
self.pending = Some(pending);
true
}
fn handle_channel_data(&mut self, d: ChannelData) -> Propagated {
if let Ok(offer) = serde_json::from_slice::<'_, SdpOffer>(&d.data) {
self.handle_offer(offer);
} else if let Ok(answer) = serde_json::from_slice::<'_, SdpAnswer>(&d.data) {
self.handle_answer(answer);
}
Propagated::Noop
}
fn handle_offer(&mut self, offer: SdpOffer) {
let Ok(answer) = self.rtc.sdp_api().accept_offer(offer) else {
warn!("client {}/{} offer rejected", self.room, self.peer);
return;
};
// Cancel any pending negotiation; it is redone after this offer.
for track in &mut self.tracks_out {
if let TrackOutState::Negotiating(_) = track.state {
track.state = TrackOutState::ToOpen;
}
}
let Some(mut channel) = self.cid.and_then(|id| self.rtc.channel(id)) else {
return;
};
let json = serde_json::to_string(&answer).expect("answer to serialize");
let _ = channel.write(false, json.as_bytes());
}
fn handle_answer(&mut self, answer: SdpAnswer) {
if let Some(pending) = self.pending.take() {
if self.rtc.sdp_api().accept_answer(pending, answer).is_err() {
warn!("client {}/{} answer rejected", self.room, self.peer);
return;
}
for track in &mut self.tracks_out {
if let TrackOutState::Negotiating(m) = track.state {
track.state = TrackOutState::Open(m);
}
}
}
}
fn handle_track_open(&mut self, track_in: Weak<TrackIn>) {
self.tracks_out.push(TrackOut {
track_in,
state: TrackOutState::ToOpen,
});
}
fn handle_media_data_out(&mut self, origin: ClientId, data: &MediaData) {
// Map the incoming media to the outgoing track towards this client.
let Some(mid) = self
.tracks_out
.iter()
.find(|o| {
o.track_in
.upgrade()
.filter(|i| i.origin == origin && i.mid == data.mid)
.is_some()
})
.and_then(|o| o.mid())
else {
return;
};
if data.rid.is_some() && data.rid != Some("h".into()) {
// Simulcast layer selection (video); pass rid=None or the "h" layer.
return;
}
if self.chosen_rid != data.rid {
self.chosen_rid = data.rid;
}
let Some(writer) = self.rtc.writer(mid) else {
return;
};
let Some(pt) = writer.match_params(data.params) else {
return;
};
if let Err(e) = writer.write(pt, data.network_time, data.time, data.data.clone()) {
warn!("client {}/{} write failed: {:?}", self.room, self.peer, e);
self.rtc.disconnect();
}
}
fn handle_keyframe_request(&mut self, req: KeyframeRequest, mid_in: Mid) {
let has_incoming_track = self.tracks_in.iter().any(|i| i.id.mid == mid_in);
if !has_incoming_track {
return;
}
let Some(mut writer) = self.rtc.writer(mid_in) else {
return;
};
if let Err(e) = writer.request_keyframe(req.rid, req.kind) {
debug!("request_keyframe failed: {:?}", e);
}
}
}
/// Events propagated between clients of a room.
#[allow(clippy::large_enum_variant)]
enum Propagated {
Noop,
Timeout(Instant),
TrackOpen(ClientId, Weak<TrackIn>),
MediaData(ClientId, MediaData),
KeyframeRequest(ClientId, KeyframeRequest, ClientId, Mid),
}
impl Propagated {
fn client_id(&self) -> Option<ClientId> {
match self {
Propagated::TrackOpen(c, _)
| Propagated::MediaData(c, _)
| Propagated::KeyframeRequest(c, _, _, _) => Some(*c),
_ => None,
}
}
}

96
rust/tests/control.rs Normal file
View File

@ -0,0 +1,96 @@
//! End-to-end control-plane test: boots the full sidecar (UDP + control socket
//! + SFU loop) in-process and speaks the JSON-lines protocol like PHP does.
use std::io::{BufRead, BufReader, Write};
use std::net::IpAddr;
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::thread;
use std::time::{Duration, Instant};
use blax_webrtc_sfu::server::{self, Config};
fn start_sidecar(socket: PathBuf) -> thread::JoinHandle<()> {
let config = Config {
socket,
udp_ip: IpAddr::from([127, 0, 0, 1]),
udp_port: 0,
public_ip: None,
};
thread::spawn(move || server::run(config).expect("sidecar to run"))
}
fn connect(socket: &PathBuf) -> UnixStream {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
if let Ok(stream) = UnixStream::connect(socket) {
return stream;
}
assert!(Instant::now() < deadline, "control socket never came up");
thread::sleep(Duration::from_millis(20));
}
}
fn roundtrip(reader: &mut impl BufRead, writer: &mut impl Write, req: &str) -> serde_json::Value {
writeln!(writer, "{req}").unwrap();
writer.flush().unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad reply {line:?}: {e}"))
}
#[test]
fn control_protocol_end_to_end() {
let socket = std::env::temp_dir().join(format!("blax-sfu-test-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket);
let sidecar = start_sidecar(socket.clone());
let stream = connect(&socket);
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut writer = stream;
// ping
let reply = roundtrip(&mut reader, &mut writer, r#"{"id":1,"cmd":"ping"}"#);
assert_eq!(reply["id"], 1);
assert_eq!(reply["ok"], true);
// empty stats
let reply = roundtrip(&mut reader, &mut writer, r#"{"id":2,"cmd":"stats"}"#);
assert_eq!(reply["ok"], true);
assert_eq!(reply["rooms"], serde_json::json!([]));
// add_peer with a garbage offer errors cleanly
let reply = roundtrip(
&mut reader,
&mut writer,
r#"{"id":3,"cmd":"add_peer","room":"r1","peer":"p1","offer":"not sdp"}"#,
);
assert_eq!(reply["ok"], false);
assert!(
reply["error"].as_str().unwrap().contains("offer"),
"unexpected error: {reply}"
);
// unknown peer errors cleanly
let reply = roundtrip(
&mut reader,
&mut writer,
r#"{"id":4,"cmd":"mute_peer","room":"r1","peer":"ghost","muted":true}"#,
);
assert_eq!(reply["ok"], false);
assert!(reply["error"].as_str().unwrap().contains("no such peer"));
// malformed json → error with id 0, connection stays usable
let reply = roundtrip(&mut reader, &mut writer, r#"{"id":5,"cmd":"nope"}"#);
assert_eq!(reply["ok"], false);
let reply = roundtrip(&mut reader, &mut writer, r#"{"id":6,"cmd":"ping"}"#);
assert_eq!(reply["ok"], true);
// shutdown stops the loop and removes the socket file
let reply = roundtrip(&mut reader, &mut writer, r#"{"id":7,"cmd":"shutdown"}"#);
assert_eq!(reply["ok"], true);
sidecar.join().expect("sidecar thread to finish");
assert!(!socket.exists(), "socket file cleaned up on shutdown");
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Console\Commands;
use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\SidecarException;
use Illuminate\Console\Command;
/**
* Explicit form of what RustMediaEngine does plug-and-play on first use:
* download the prebuilt, checksummed SFU sidecar binary for this platform.
*/
class InstallSfuBinary extends Command
{
protected $signature = 'webrtc:install {--force : Redownload even when already installed}';
protected $description = 'Download the prebuilt blax-webrtc-sfu sidecar binary (Rust data plane) for this platform.';
public function handle(BinaryManager $binaries): int
{
if (! $this->option('force') && ($found = $binaries->find())) {
$this->info("SFU sidecar already available: {$found}");
return self::SUCCESS;
}
$this->line("Installing blax-webrtc-sfu {$binaries->version()} for {$binaries->platform()}");
try {
$path = $binaries->install((bool) $this->option('force'));
} catch (SidecarException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->info("Installed: {$path}");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Console\Commands;
use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\SidecarException;
use Blax\WebRtc\Media\Rust\SidecarSupervisor;
use Illuminate\Console\Command;
/**
* Run the Rust SFU sidecar in the FOREGROUND for docker services / process
* supervisors (set webrtc.sfu.auto_spawn=false there so workers don't race
* their own detached copy). Uses pcntl_exec when available so the binary
* replaces this process and receives signals directly.
*/
class RunSfuSidecar extends Command
{
protected $signature = 'webrtc:sidecar';
protected $description = 'Run the blax-webrtc-sfu sidecar (Rust data plane) in the foreground.';
public function handle(BinaryManager $binaries, SidecarSupervisor $supervisor): int
{
try {
$binary = $binaries->ensureInstalled();
} catch (SidecarException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$args = $supervisor->args();
$this->info("exec {$binary} ".implode(' ', $args));
if (\function_exists('pcntl_exec')) {
pcntl_exec($binary, $args); // only returns on failure
$this->error('pcntl_exec failed');
return self::FAILURE;
}
passthru(
escapeshellarg($binary).' '.implode(' ', array_map('escapeshellarg', $args)),
$exit,
);
return $exit;
}
}

View File

@ -9,9 +9,9 @@ namespace Blax\WebRtc\Contracts;
* *
* PHP (this package) owns SIGNALING + orchestration; the MediaEngine owns the * PHP (this package) owns SIGNALING + orchestration; the MediaEngine owns the
* real-time media path (ICE, DTLS, SRTP, RTP, Opus the per-20ms DSP). The * real-time media path (ICE, DTLS, SRTP, RTP, Opus the per-20ms DSP). The
* production engine (Str0mMediaEngine) delegates that to a Rust core (str0m, * production engine (RustMediaEngine) delegates that to the bundled Rust SFU
* sans-IO) exposed to PHP via ext-php-rs, so the timing-critical work never runs * sidecar (str0m, sans-IO) driven over a JSON control socket, so the
* in PHP itself. See the package `rust/` directory. * timing-critical work never runs in PHP itself. See the package `rust/` dir.
* *
* Because the engine terminates media server-side, it is also the single place * Because the engine terminates media server-side, it is also the single place
* that can: * that can:

View File

@ -23,7 +23,7 @@ final class NullMediaEngine implements MediaEngine
{ {
throw new \RuntimeException( throw new \RuntimeException(
'NullMediaEngine cannot terminate media. Configure webrtc.media_engine '. 'NullMediaEngine cannot terminate media. Configure webrtc.media_engine '.
'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.' 'to RustMediaEngine (the bundled Rust SFU sidecar) for backend-hosted calls.'
); );
} }

View File

@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Media\Rust;
/**
* Locates and, plug-and-play, downloads the prebuilt `blax-webrtc-sfu`
* static binary (the RoadRunner pattern: composer ships the PHP, the release
* ships the binary, this class marries them).
*
* Resolution order:
* 1. an explicit `webrtc.sfu.binary` path,
* 2. a local dev build under the package's rust/target/{release,debug},
* 3. the installed download for this package version + platform,
* 4. (when auto_install) download from `webrtc.sfu.download_url`, verify the
* sha256 from `webrtc.sfu.checksum_url`, install atomically.
*/
class BinaryManager
{
/** The release tag whose prebuilt sidecar this package version expects. */
public const VERSION = 'v0.1.0';
/** @param array<string,mixed> $config the `webrtc.sfu` config block */
public function __construct(private readonly array $config = []) {}
/** E.g. "linux-x86_64", "linux-aarch64", "darwin-aarch64". */
public function platform(): string
{
$os = match (PHP_OS_FAMILY) {
'Darwin' => 'darwin',
default => strtolower(PHP_OS_FAMILY),
};
$arch = match ($machine = php_uname('m')) {
'arm64' => 'aarch64',
'amd64' => 'x86_64',
default => $machine,
};
return "{$os}-{$arch}";
}
public function version(): string
{
return (string) ($this->config['version'] ?? self::VERSION);
}
/** First usable binary (override → dev build → installed), or null. */
public function find(): ?string
{
$packageRoot = dirname(__DIR__, 3);
$devBuilds = ($this->config['dev_builds'] ?? true) === true;
$candidates = array_filter([
$this->config['binary'] ?? null,
$devBuilds ? $packageRoot.'/rust/target/release/blax-webrtc-sfu' : null,
$devBuilds ? $packageRoot.'/rust/target/debug/blax-webrtc-sfu' : null,
$this->installPath(),
]);
foreach ($candidates as $path) {
if (is_file($path) && is_executable($path)) {
return $path;
}
}
return null;
}
/** Where a downloaded binary lives (versioned + per platform). */
public function installPath(): string
{
$dir = rtrim((string) ($this->config['install_path'] ?? sys_get_temp_dir().'/blax-webrtc'), '/');
return "{$dir}/blax-webrtc-sfu-{$this->version()}-{$this->platform()}";
}
/** Find or (if allowed) download — the plug-and-play entrypoint. */
public function ensureInstalled(): string
{
if ($found = $this->find()) {
return $found;
}
if (($this->config['auto_install'] ?? true) !== true) {
throw new SidecarException(
'SFU sidecar binary not found and webrtc.sfu.auto_install is disabled — '.
'run `php artisan webrtc:install`, build rust/ locally, or set webrtc.sfu.binary.'
);
}
return $this->install();
}
public function install(bool $force = false): string
{
$target = $this->installPath();
if (! $force && is_file($target)) {
return $target;
}
$url = $this->url('download_url');
if ($url === '') {
throw new SidecarException('webrtc.sfu.download_url is not configured');
}
$binary = $this->fetch($url);
// A configured checksum_url is a hard requirement, not best-effort —
// set it to null explicitly to opt out of verification.
if (($checksumUrl = $this->url('checksum_url')) !== '') {
$expected = strtolower((string) strtok(trim($this->fetch($checksumUrl)), " \t"));
$actual = hash('sha256', $binary);
if (! hash_equals($expected, $actual)) {
throw new SidecarException(
"sidecar checksum mismatch for {$url}: expected {$expected}, got {$actual}"
);
}
}
if (! is_dir($dir = dirname($target))) {
@mkdir($dir, 0775, true);
}
$tmp = $target.'.download.'.getmypid();
if (@file_put_contents($tmp, $binary) === false) {
throw new SidecarException("cannot write sidecar binary to {$tmp}");
}
@chmod($tmp, 0755);
if (! @rename($tmp, $target)) {
@unlink($tmp);
throw new SidecarException("cannot move sidecar binary into place at {$target}");
}
return $target;
}
private function url(string $key): string
{
return str_replace(
['{version}', '{platform}'],
[$this->version(), $this->platform()],
(string) ($this->config[$key] ?? ''),
);
}
private function fetch(string $url): string
{
$context = stream_context_create([
'http' => ['timeout' => (float) ($this->config['download_timeout'] ?? 60), 'follow_location' => 1],
]);
$data = @file_get_contents($url, false, $context);
if ($data === false || $data === '') {
throw new SidecarException("sidecar download failed: {$url}");
}
return $data;
}
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Media\Rust;
/**
* JSON-lines client for the Rust SFU sidecar's Unix control socket.
*
* One JSON object per line. Requests carry an `id` the reply echoes; the
* sidecar also PUSHES notices (`{"event":"peer_left",...}`) onto every control
* connection those are buffered while waiting for a reply and retrievable
* via drainEvents().
*/
class ControlClient
{
/** @var resource|null */
private $stream = null;
private int $nextId = 1;
/** @var list<array<string,mixed>> */
private array $events = [];
public function __construct(
private readonly string $socketPath,
private readonly float $timeout = 5.0,
) {}
/**
* Build a client on an already-open stream (tests use a socket pair).
*
* @param resource $stream
*/
public static function fromStream($stream): self
{
$client = new self('');
$client->stream = $stream;
return $client;
}
/**
* Send one command and wait for its reply. Throws on transport errors and
* on `ok:false` replies.
*
* @param array<string,mixed> $params
* @return array<string,mixed> the decoded reply (includes `ok` => true)
*/
public function request(string $cmd, array $params = []): array
{
$id = $this->nextId++;
$stream = $this->stream();
$json = json_encode(['id' => $id, 'cmd' => $cmd] + $params, JSON_UNESCAPED_SLASHES);
if (@fwrite($stream, $json."\n") === false) {
$this->close();
throw new SidecarException("sidecar write failed ({$cmd})");
}
while (true) {
$line = @fgets($stream);
if ($line === false) {
$this->close();
throw new SidecarException("sidecar closed or timed out awaiting the {$cmd} reply");
}
$reply = json_decode(trim($line), true);
if (! is_array($reply)) {
continue;
}
if (isset($reply['event'])) {
$this->events[] = $reply;
continue;
}
if (($reply['id'] ?? null) !== $id) {
continue;
}
if (($reply['ok'] ?? false) !== true) {
throw new SidecarException((string) ($reply['error'] ?? "sidecar error ({$cmd})"));
}
return $reply;
}
}
/**
* Notices (peer_connected / peer_left / ) received while waiting for
* replies. Clears the buffer.
*
* @return list<array<string,mixed>>
*/
public function drainEvents(): array
{
[$events, $this->events] = [$this->events, []];
return $events;
}
public function close(): void
{
if (is_resource($this->stream)) {
@fclose($this->stream);
}
$this->stream = null;
}
/** @return resource */
private function stream()
{
if (is_resource($this->stream)) {
return $this->stream;
}
$stream = @stream_socket_client(
'unix://'.$this->socketPath,
$errno,
$error,
$this->timeout,
);
if ($stream === false) {
throw new SidecarException(
"cannot connect to the SFU sidecar at {$this->socketPath}: ".($error ?: "errno {$errno}")
);
}
$seconds = (int) floor($this->timeout);
stream_set_timeout($stream, $seconds, (int) (($this->timeout - $seconds) * 1_000_000));
return $this->stream = $stream;
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Media\Rust;
/**
* Anything that goes wrong talking to / installing / spawning the Rust SFU
* sidecar: connect failures, protocol errors, download or checksum failures.
*/
class SidecarException extends \RuntimeException {}

View File

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Media\Rust;
/**
* Keeps one Rust SFU sidecar running per control-socket path plug-and-play:
* the first RustMediaEngine call finds the binary (BinaryManager, downloading
* if permitted) and spawns it detached, flock-guarded so concurrent workers
* (fork-per-message hosts) race exactly one spawn. `php artisan webrtc:sidecar`
* runs the same binary in the foreground for supervised/docker deployments
* with auto_spawn disabled, that is the only way it starts.
*/
class SidecarSupervisor
{
/** @param array<string,mixed> $config the `webrtc.sfu` config block */
public function __construct(
private readonly BinaryManager $binaries,
private readonly array $config = [],
) {}
public function socketPath(): string
{
return (string) ($this->config['socket'] ?? sys_get_temp_dir().'/blax-webrtc-sfu.sock');
}
public function running(): bool
{
if (! file_exists($this->socketPath())) {
return false;
}
try {
(new ControlClient($this->socketPath(), 2.0))->request('ping');
return true;
} catch (SidecarException) {
return false;
}
}
public function ensureRunning(): void
{
if ($this->running()) {
return;
}
if (($this->config['auto_spawn'] ?? true) !== true) {
throw new SidecarException(
'the SFU sidecar is not running at '.$this->socketPath().
' and webrtc.sfu.auto_spawn is disabled — start it with `php artisan webrtc:sidecar`.'
);
}
$lockPath = $this->socketPath().'.lock';
if (! is_dir($dir = dirname($lockPath))) {
@mkdir($dir, 0775, true);
}
$lock = @fopen($lockPath, 'c');
if ($lock === false || ! flock($lock, LOCK_EX)) {
throw new SidecarException("cannot acquire the sidecar spawn lock at {$lockPath}");
}
try {
if ($this->running()) {
return; // another worker won the race
}
$this->spawnDetached();
$this->awaitSocket();
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** The CLI arguments matching this supervisor's config. @return list<string> */
public function args(): array
{
$args = ['--socket', $this->socketPath()];
if (($ip = (string) ($this->config['udp_ip'] ?? '')) !== '') {
array_push($args, '--udp-ip', $ip);
}
if (($port = (int) ($this->config['udp_port'] ?? 0)) > 0) {
array_push($args, '--udp-port', (string) $port);
}
if (($ip = (string) ($this->config['public_ip'] ?? '')) !== '') {
array_push($args, '--public-ip', $ip);
}
return $args;
}
public function logPath(): string
{
return (string) ($this->config['log'] ?? sys_get_temp_dir().'/blax-webrtc-sfu.log');
}
/** Graceful stop via the control socket (no-op when not running). */
public function stop(): void
{
if (! $this->running()) {
return;
}
try {
(new ControlClient($this->socketPath(), 2.0))->request('shutdown');
} catch (SidecarException) {
// it went away — that is what we asked for
}
}
private function spawnDetached(): void
{
$binary = $this->binaries->ensureInstalled();
$log = $this->logPath();
if (! is_dir($dir = dirname($log))) {
@mkdir($dir, 0775, true);
}
// `&` + i/o redirection detaches it from this worker; the shell exits
// and the sidecar reparents to init (survives the PHP worker).
$command = sprintf(
'%s %s >> %s 2>&1 < /dev/null &',
escapeshellarg($binary),
implode(' ', array_map('escapeshellarg', $this->args())),
escapeshellarg($log),
);
shell_exec($command);
}
private function awaitSocket(): void
{
$deadline = microtime(true) + (float) ($this->config['spawn_timeout'] ?? 10.0);
while (microtime(true) < $deadline) {
if ($this->running()) {
return;
}
usleep(100_000);
}
throw new SidecarException(
'the SFU sidecar did not come up within the spawn timeout — check '.$this->logPath()
);
}
}

View File

@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Media;
use Blax\WebRtc\Contracts\MediaEngine;
use Blax\WebRtc\Media\Rust\ControlClient;
use Blax\WebRtc\Media\Rust\SidecarException;
use Blax\WebRtc\Media\Rust\SidecarSupervisor;
/**
* Production media engine: BACKEND-HOSTED rooms on the Rust SFU sidecar
* (`rust/` in this package str0m terminates ICE/DTLS/SRTP and forwards Opus
* between room peers without decoding; recording is native).
*
* Plug-and-play: the first call ensures the sidecar is installed + running
* (see SidecarSupervisor / BinaryManager), then speaks the JSON control
* protocol over its Unix socket. Signaling model: only the INITIAL offer /
* answer passes through here renegotiation as peers join rides the WebRTC
* data channel between browser and sidecar directly.
*
* The room-aware API (addPeer/removePeer/mutePeer/stats) is primary; the
* MediaEngine contract is honored with composite "room/peer" peer ids.
*/
final class RustMediaEngine implements MediaEngine
{
private bool $booted = false;
public function __construct(
private readonly SidecarSupervisor $sidecar,
private ?ControlClient $client = null,
) {
// An injected client (tests) skips sidecar boot-strapping.
$this->booted = $client !== null;
}
public function name(): string
{
return 'rust-sfu';
}
// ── Room-aware primary API ─────────────────────────────────────────────
/**
* Add a peer to a hosted room: accepts the browser's SDP offer, returns
* the answer. Pass $recordPath to record the peer from the first packet
* (Ogg/Opus). Rejoining with the same ids replaces the old connection.
*/
public function addPeer(string $room, string $peer, string $sdpOffer, ?string $recordPath = null): string
{
$params = ['room' => $room, 'peer' => $peer, 'offer' => $sdpOffer];
if ($recordPath !== null) {
$params['record'] = $recordPath;
}
return (string) $this->client()->request('add_peer', $params)['answer'];
}
public function removePeer(string $room, string $peer): void
{
$this->client()->request('remove_peer', ['room' => $room, 'peer' => $peer]);
}
/** Server-side mute: the room stops hearing the peer (recording pauses too). */
public function mutePeer(string $room, string $peer, bool $muted): void
{
$this->client()->request('mute_peer', ['room' => $room, 'peer' => $peer, 'muted' => $muted]);
}
/**
* Rooms/peers snapshot: connection state, mute, connected seconds (usage).
*
* @return list<array{room:string,peers:list<array{peer:string,connected:bool,muted:bool,seconds:float}>}>
*/
public function stats(): array
{
return (array) ($this->client()->request('stats')['rooms'] ?? []);
}
/**
* Buffered sidecar notices (peer_connected / peer_left with usage seconds).
*
* @return list<array<string,mixed>>
*/
public function drainEvents(): array
{
return $this->client()->drainEvents();
}
// ── MediaEngine contract (peer id = "room/peer") ───────────────────────
public function offer(string $peerId, string $sdpOffer): string
{
[$room, $peer] = $this->split($peerId);
return $this->addPeer($room, $peer, $sdpOffer);
}
public function addIceCandidate(string $peerId, array $candidate): void
{
// Nothing to forward: the answer carries the sidecar's host candidate,
// and str0m (full ICE) learns the browser's candidates from the STUN
// binding requests arriving on the media socket.
}
public function startRecording(string $peerId, string $path): void
{
[$room, $peer] = $this->split($peerId);
$this->client()->request('record_start', ['room' => $room, 'peer' => $peer, 'path' => $path]);
}
public function stopRecording(string $peerId): void
{
[$room, $peer] = $this->split($peerId);
$this->client()->request('record_stop', ['room' => $room, 'peer' => $peer]);
}
public function connectPeers(string $peerA, string $peerB): void
{
// Nothing to do: the SFU forwards between all peers of a room
// automatically — room membership IS the routing table.
}
public function bridge(string $peerId, array $options): void
{
throw new SidecarException(
'AI bridging inside the Rust data plane is not implemented yet — '.
'bridge via Realtime\OpenAiRealtimeBridge for now (duplex in-SFU bridge is #1051).'
);
}
public function close(string $peerId): void
{
[$room, $peer] = $this->split($peerId);
$this->removePeer($room, $peer);
}
// ───────────────────────────────────────────────────────────────────────
/** @return array{0:string,1:string} */
private function split(string $peerId): array
{
$pos = strpos($peerId, '/');
if ($pos === false) {
return ['default', $peerId];
}
return [substr($peerId, 0, $pos), substr($peerId, $pos + 1)];
}
private function client(): ControlClient
{
if (! $this->booted) {
$this->sidecar->ensureRunning();
$this->booted = true;
}
return $this->client ??= new ControlClient($this->sidecar->socketPath());
}
}

View File

@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Media;
use Blax\WebRtc\Contracts\MediaEngine;
/**
* Production media engine backed by a RUST core.
*
* The core is `str0m` (a sans-IO Rust WebRTC implementation built for SFU +
* recording) compiled into a PHP extension via `ext-php-rs` and shipped in this
* package's `rust/` directory (extension name: `blax_webrtc`). Rust owns the
* timing-critical media path (ICE/DTLS/SRTP/RTP/Opus, the per-20ms DSP); this
* PHP adapter is a thin forwarder that carries SDP/ICE/control across the FFI
* boundary and calls back into Laravel for persistence (recordings, transmissions).
*
* This is the SEAM, not the implementation. Until `rust/` is built and the
* extension is loaded, constructing this engine fails with a clear message.
* Each method documents the `blax_webrtc_*` call it will forward to.
*/
final class Str0mMediaEngine implements MediaEngine
{
public function __construct()
{
if (! \extension_loaded('blax_webrtc')) {
throw new \RuntimeException(
'The blax_webrtc extension (Rust/str0m media core) is not loaded. '.
'Build it from this package\'s rust/ directory (see rust/README.md), '.
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
);
}
}
public function name(): string
{
return 'str0m';
}
public function offer(string $peerId, string $sdpOffer): string
{
// return blax_webrtc_offer($peerId, $sdpOffer);
throw new \LogicException('TODO: forward to blax_webrtc_offer() once the Rust core is built.');
}
public function addIceCandidate(string $peerId, array $candidate): void
{
// blax_webrtc_add_ice_candidate($peerId, $candidate);
throw new \LogicException('TODO: forward to blax_webrtc_add_ice_candidate().');
}
public function startRecording(string $peerId, string $path): void
{
// blax_webrtc_start_recording($peerId, $path);
throw new \LogicException('TODO: forward to blax_webrtc_start_recording().');
}
public function stopRecording(string $peerId): void
{
// blax_webrtc_stop_recording($peerId);
throw new \LogicException('TODO: forward to blax_webrtc_stop_recording().');
}
public function connectPeers(string $peerA, string $peerB): void
{
// blax_webrtc_connect_peers($peerA, $peerB);
throw new \LogicException('TODO: forward to blax_webrtc_connect_peers().');
}
public function bridge(string $peerId, array $options): void
{
// blax_webrtc_bridge($peerId, $options); // e.g. OpenAI Realtime over a server-side WS
throw new \LogicException('TODO: forward to blax_webrtc_bridge().');
}
public function close(string $peerId): void
{
// blax_webrtc_close($peerId);
throw new \LogicException('TODO: forward to blax_webrtc_close().');
}
}

View File

@ -6,6 +6,8 @@ namespace Blax\WebRtc;
use Blax\ReactPhpKernel\Kernel; use Blax\ReactPhpKernel\Kernel;
use Blax\WebRtc\Authorization\DefaultRoomAuthorizer; use Blax\WebRtc\Authorization\DefaultRoomAuthorizer;
use Blax\WebRtc\Console\Commands\InstallSfuBinary;
use Blax\WebRtc\Console\Commands\RunSfuSidecar;
use Blax\WebRtc\Console\Commands\StartWebRtcServer; use Blax\WebRtc\Console\Commands\StartWebRtcServer;
use Blax\WebRtc\Contracts\CallEventListener; use Blax\WebRtc\Contracts\CallEventListener;
use Blax\WebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
@ -14,6 +16,8 @@ use Blax\WebRtc\Contracts\RoomAuthorizer;
use Blax\WebRtc\Contracts\RoomStore; use Blax\WebRtc\Contracts\RoomStore;
use Blax\WebRtc\Events\NullCallEventListener; use Blax\WebRtc\Events\NullCallEventListener;
use Blax\WebRtc\Media\NullMediaEngine; use Blax\WebRtc\Media\NullMediaEngine;
use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\SidecarSupervisor;
use Blax\WebRtc\Realtime\OpenAiRealtimeBridge; use Blax\WebRtc\Realtime\OpenAiRealtimeBridge;
use Blax\WebRtc\Recording\FileRecordingStore; use Blax\WebRtc\Recording\FileRecordingStore;
use Blax\WebRtc\Rooms\RoomManager; use Blax\WebRtc\Rooms\RoomManager;
@ -62,6 +66,15 @@ class WebRtcServiceProvider extends ServiceProvider
); );
}); });
// The Rust SFU sidecar (backend-hosted rooms): binary management + the
// process supervisor RustMediaEngine boots plug-and-play on first use.
$this->app->singleton(BinaryManager::class, function () {
return new BinaryManager((array) config('webrtc.sfu', []));
});
$this->app->singleton(SidecarSupervisor::class, function ($app) {
return new SidecarSupervisor($app->make(BinaryManager::class), (array) config('webrtc.sfu', []));
});
// Realtime AI bridge (provider hidden from the browser, model swappable server-side). // Realtime AI bridge (provider hidden from the browser, model swappable server-side).
$this->app->bind(OpenAiRealtimeBridge::class, function () { $this->app->bind(OpenAiRealtimeBridge::class, function () {
$endpoint = (string) config('webrtc.bridge.openai.endpoint', 'wss://api.openai.com/v1/realtime'); $endpoint = (string) config('webrtc.bridge.openai.endpoint', 'wss://api.openai.com/v1/realtime');
@ -77,6 +90,12 @@ class WebRtcServiceProvider extends ServiceProvider
__DIR__.'/../config/webrtc.php' => $this->app->configPath('webrtc.php'), __DIR__.'/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
], 'webrtc-config'); ], 'webrtc-config');
// The Rust SFU sidecar commands have no extra PHP dependencies.
$this->commands([
InstallSfuBinary::class,
RunSfuSidecar::class,
]);
// The bundled ReactPHP relay server needs blax-software/laravel-ws + // The bundled ReactPHP relay server needs blax-software/laravel-ws +
// reactphp-kernel (dev-only deps). A host that only consumes the domain // reactphp-kernel (dev-only deps). A host that only consumes the domain
// layer (rooms/recording/realtime bridge) on its OWN transport won't have // layer (rooms/recording/realtime bridge) on its OWN transport won't have

117
tests/BinaryManagerTest.php Normal file
View File

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\SidecarException;
use PHPUnit\Framework\TestCase;
final class BinaryManagerTest extends TestCase
{
private string $dir;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir().'/blax-webrtc-binmgr-'.getmypid();
@mkdir($this->dir, 0775, true);
}
protected function tearDown(): void
{
foreach (glob($this->dir.'/*') ?: [] as $file) {
@unlink($file);
}
@rmdir($this->dir);
}
public function test_platform_is_os_dash_arch(): void
{
$platform = (new BinaryManager)->platform();
$this->assertMatchesRegularExpression('/^(linux|darwin|windows|bsd|solaris)-[a-z0-9_]+$/', $platform);
$this->assertStringNotContainsString('amd64', $platform, 'arch aliases are normalized');
$this->assertStringNotContainsString('arm64', $platform);
}
public function test_an_explicit_binary_override_wins(): void
{
$binary = $this->dir.'/custom-sfu';
file_put_contents($binary, '#!/bin/sh');
chmod($binary, 0755);
$manager = new BinaryManager(['binary' => $binary]);
$this->assertSame($binary, $manager->find());
}
public function test_install_downloads_verifies_sha256_and_marks_executable(): void
{
$payload = 'fake-sfu-binary-payload';
file_put_contents($this->dir.'/source', $payload);
// sha256sum format: "<hash> <filename>"
file_put_contents($this->dir.'/source.sha256', hash('sha256', $payload)." blax-webrtc-sfu\n");
$manager = new BinaryManager([
'dev_builds' => false,
'install_path' => $this->dir,
'download_url' => 'file://'.$this->dir.'/source',
'checksum_url' => 'file://'.$this->dir.'/source.sha256',
]);
$installed = $manager->ensureInstalled();
$this->assertSame($manager->installPath(), $installed);
$this->assertSame($payload, file_get_contents($installed));
$this->assertTrue(is_executable($installed));
// Second call finds it without downloading (source removed to prove it).
unlink($this->dir.'/source');
$this->assertSame($installed, $manager->ensureInstalled());
}
public function test_a_checksum_mismatch_aborts_the_install(): void
{
file_put_contents($this->dir.'/source', 'tampered payload');
file_put_contents($this->dir.'/source.sha256', hash('sha256', 'the real payload')."\n");
$manager = new BinaryManager([
'dev_builds' => false,
'install_path' => $this->dir,
'download_url' => 'file://'.$this->dir.'/source',
'checksum_url' => 'file://'.$this->dir.'/source.sha256',
]);
$this->expectException(SidecarException::class);
$this->expectExceptionMessage('checksum mismatch');
$manager->install();
$this->assertFileDoesNotExist($manager->installPath());
}
public function test_auto_install_false_fails_with_actionable_guidance(): void
{
$manager = new BinaryManager([
'dev_builds' => false,
'install_path' => $this->dir,
'auto_install' => false,
]);
$this->expectException(SidecarException::class);
$this->expectExceptionMessage('webrtc:install');
$manager->ensureInstalled();
}
public function test_install_path_is_versioned_and_per_platform(): void
{
$manager = new BinaryManager(['install_path' => '/opt/bin', 'version' => 'v9.9.9']);
$this->assertSame(
'/opt/bin/blax-webrtc-sfu-v9.9.9-'.$manager->platform(),
$manager->installPath(),
);
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Media\Rust\ControlClient;
use Blax\WebRtc\Media\Rust\SidecarException;
use PHPUnit\Framework\TestCase;
final class ControlClientTest extends TestCase
{
/** @var resource */
private $sidecar;
private ControlClient $client;
protected function setUp(): void
{
[$theirs, $ours] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$this->sidecar = $theirs;
$this->client = ControlClient::fromStream($ours);
}
private function sidecarSays(array ...$lines): void
{
foreach ($lines as $line) {
fwrite($this->sidecar, json_encode($line)."\n");
}
}
private function sidecarHeard(): array
{
return json_decode((string) fgets($this->sidecar), true);
}
public function test_request_frames_json_lines_and_matches_the_reply_id(): void
{
$this->sidecarSays(['id' => 1, 'ok' => true, 'answer' => 'v=answer']);
$reply = $this->client->request('add_peer', ['room' => 'r1', 'peer' => 'p1', 'offer' => 'v=0']);
$this->assertSame('v=answer', $reply['answer']);
$this->assertSame(
['id' => 1, 'cmd' => 'add_peer', 'room' => 'r1', 'peer' => 'p1', 'offer' => 'v=0'],
$this->sidecarHeard(),
);
}
public function test_pushed_events_are_buffered_not_mistaken_for_replies(): void
{
$this->sidecarSays(
['event' => 'peer_left', 'room' => 'r1', 'peer' => 'p9', 'seconds' => 12.5],
['id' => 1, 'ok' => true],
);
$this->client->request('ping');
$events = $this->client->drainEvents();
$this->assertCount(1, $events);
$this->assertSame('peer_left', $events[0]['event']);
$this->assertSame([], $this->client->drainEvents(), 'drain clears the buffer');
}
public function test_ok_false_replies_throw_with_the_sidecar_error(): void
{
$this->sidecarSays(['id' => 1, 'ok' => false, 'error' => 'no such peer: r1/ghost']);
$this->expectException(SidecarException::class);
$this->expectExceptionMessage('no such peer: r1/ghost');
$this->client->request('mute_peer', ['room' => 'r1', 'peer' => 'ghost', 'muted' => true]);
}
public function test_a_closed_sidecar_throws_instead_of_hanging(): void
{
// Half-close: the sidecar stops sending (EOF on our reads) but still
// accepts our write — exercising the read-path failure.
stream_socket_shutdown($this->sidecar, STREAM_SHUT_WR);
$this->expectException(SidecarException::class);
$this->expectExceptionMessage('closed or timed out');
$this->client->request('ping');
}
public function test_ids_increment_across_requests(): void
{
$this->sidecarSays(['id' => 1, 'ok' => true], ['id' => 2, 'ok' => true]);
$this->client->request('ping');
$this->client->request('ping');
$this->assertSame(1, $this->sidecarHeard()['id']);
$this->assertSame(2, $this->sidecarHeard()['id']);
}
}

View File

@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\ControlClient;
use Blax\WebRtc\Media\Rust\SidecarException;
use Blax\WebRtc\Media\Rust\SidecarSupervisor;
use Blax\WebRtc\Media\RustMediaEngine;
use PHPUnit\Framework\TestCase;
final class RustMediaEngineTest extends TestCase
{
/** @var resource */
private $sidecar;
private RustMediaEngine $engine;
protected function setUp(): void
{
[$theirs, $ours] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
$this->sidecar = $theirs;
$supervisor = new SidecarSupervisor(new BinaryManager([]), ['auto_spawn' => false]);
$this->engine = new RustMediaEngine($supervisor, ControlClient::fromStream($ours));
}
private function sidecarSays(array $line): void
{
fwrite($this->sidecar, json_encode($line)."\n");
}
private function sidecarHeard(): array
{
return json_decode((string) fgets($this->sidecar), true);
}
public function test_add_peer_speaks_the_control_protocol(): void
{
$this->sidecarSays(['id' => 1, 'ok' => true, 'answer' => 'v=answer']);
$answer = $this->engine->addPeer('lobby', 'alice', 'v=offer', '/rec/alice.ogg');
$this->assertSame('v=answer', $answer);
$this->assertSame(
[
'id' => 1,
'cmd' => 'add_peer',
'room' => 'lobby',
'peer' => 'alice',
'offer' => 'v=offer',
'record' => '/rec/alice.ogg',
],
$this->sidecarHeard(),
);
}
public function test_media_engine_contract_splits_composite_peer_ids(): void
{
$this->sidecarSays(['id' => 1, 'ok' => true, 'answer' => 'v=a']);
$this->engine->offer('lobby/alice', 'v=offer');
$heard = $this->sidecarHeard();
$this->assertSame(['lobby', 'alice'], [$heard['room'], $heard['peer']]);
$this->assertArrayNotHasKey('record', $heard);
$this->sidecarSays(['id' => 2, 'ok' => true]);
$this->engine->startRecording('lobby/alice', '/rec/alice.ogg');
$heard = $this->sidecarHeard();
$this->assertSame('record_start', $heard['cmd']);
$this->assertSame('/rec/alice.ogg', $heard['path']);
$this->sidecarSays(['id' => 3, 'ok' => true]);
$this->engine->close('lobby/alice');
$this->assertSame('remove_peer', $this->sidecarHeard()['cmd']);
}
public function test_bare_peer_ids_fall_back_to_the_default_room(): void
{
$this->sidecarSays(['id' => 1, 'ok' => true, 'answer' => 'v=a']);
$this->engine->offer('alice', 'v=offer');
$heard = $this->sidecarHeard();
$this->assertSame(['default', 'alice'], [$heard['room'], $heard['peer']]);
}
public function test_mute_stats_and_events(): void
{
$this->sidecarSays(['id' => 1, 'ok' => true]);
$this->engine->mutePeer('lobby', 'alice', true);
$this->assertTrue($this->sidecarHeard()['muted']);
$this->sidecarSays([
'id' => 2,
'ok' => true,
'rooms' => [['room' => 'lobby', 'peers' => [['peer' => 'alice', 'connected' => true, 'muted' => true, 'seconds' => 4.2]]]],
]);
$stats = $this->engine->stats();
$this->assertSame('lobby', $stats[0]['room']);
fwrite($this->sidecar, json_encode(['event' => 'peer_connected', 'room' => 'lobby', 'peer' => 'bob'])."\n");
$this->sidecarSays(['id' => 3, 'ok' => true]);
$this->engine->removePeer('lobby', 'bob');
$this->assertSame('peer_connected', $this->engine->drainEvents()[0]['event']);
}
public function test_ice_candidates_and_connect_peers_are_deliberate_noops(): void
{
// Neither may touch the socket: str0m learns remote candidates from
// STUN, and room membership already routes media.
$this->engine->addIceCandidate('lobby/alice', ['candidate' => 'x']);
$this->engine->connectPeers('lobby/alice', 'lobby/bob');
stream_set_blocking($this->sidecar, false);
$this->assertFalse(fgets($this->sidecar), 'no bytes were written to the sidecar');
}
public function test_bridge_is_not_implemented_in_the_data_plane_yet(): void
{
$this->expectException(SidecarException::class);
$this->expectExceptionMessage('OpenAiRealtimeBridge');
$this->engine->bridge('lobby/alice', ['model' => 'gpt-realtime']);
}
public function test_engine_name(): void
{
$this->assertSame('rust-sfu', $this->engine->name());
}
}

View File

@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\SidecarException;
use Blax\WebRtc\Media\Rust\SidecarSupervisor;
use Blax\WebRtc\Media\RustMediaEngine;
use PHPUnit\Framework\TestCase;
final class SidecarSupervisorTest extends TestCase
{
public function test_auto_spawn_disabled_fails_with_actionable_guidance(): void
{
$supervisor = new SidecarSupervisor(new BinaryManager([]), [
'socket' => sys_get_temp_dir().'/blax-sfu-never-'.getmypid().'.sock',
'auto_spawn' => false,
]);
$this->assertFalse($supervisor->running());
$this->expectException(SidecarException::class);
$this->expectExceptionMessage('webrtc:sidecar');
$supervisor->ensureRunning();
}
public function test_args_mirror_the_config(): void
{
$supervisor = new SidecarSupervisor(new BinaryManager([]), [
'socket' => '/run/sfu.sock',
'udp_ip' => '10.0.0.5',
'udp_port' => 41000,
'public_ip' => '203.0.113.9',
]);
$this->assertSame(
['--socket', '/run/sfu.sock', '--udp-ip', '10.0.0.5', '--udp-port', '41000', '--public-ip', '203.0.113.9'],
$supervisor->args(),
);
}
/**
* REAL cross-language round-trip: plug-and-play spawn of the actual Rust
* sidecar (local cargo build), the full control protocol through
* RustMediaEngine, and a graceful stop. Skipped until rust/ is built.
*/
public function test_spawns_and_drives_the_real_sidecar(): void
{
$binary = \dirname(__DIR__).'/rust/target/debug/blax-webrtc-sfu';
if (! is_file($binary)) {
$this->markTestSkipped('rust sidecar not built — run `cargo build` in rust/');
}
$socket = sys_get_temp_dir().'/blax-sfu-e2e-'.getmypid().'.sock';
$config = [
'socket' => $socket,
'binary' => $binary,
'auto_install' => false,
'udp_ip' => '127.0.0.1',
'log' => sys_get_temp_dir().'/blax-sfu-e2e-'.getmypid().'.log',
'spawn_timeout' => 15.0,
];
$supervisor = new SidecarSupervisor(new BinaryManager($config), $config);
try {
$this->assertFalse($supervisor->running());
$supervisor->ensureRunning(); // finds the binary + spawns detached
$this->assertTrue($supervisor->running());
$engine = new RustMediaEngine($supervisor);
// Empty stats on a fresh sidecar.
$this->assertSame([], $engine->stats());
// A garbage offer must come back as a clean protocol error.
try {
$engine->addPeer('lobby', 'alice', 'this is not sdp');
$this->fail('expected the sidecar to reject a garbage offer');
} catch (SidecarException $e) {
$this->assertStringContainsString('offer', $e->getMessage());
}
// Unknown peers error cleanly too.
try {
$engine->mutePeer('lobby', 'ghost', true);
$this->fail('expected a no-such-peer error');
} catch (SidecarException $e) {
$this->assertStringContainsString('no such peer', $e->getMessage());
}
// ensureRunning is idempotent while it lives.
$supervisor->ensureRunning();
$this->assertTrue($supervisor->running());
} finally {
$supervisor->stop();
@unlink($socket);
@unlink($socket.'.lock');
@unlink($config['log']);
}
$this->assertFalse($supervisor->running(), 'graceful shutdown via the control socket');
}
}