From 1be40ba59cd077fc6264e5829ddcabbe61c98c51 Mon Sep 17 00:00:00 2001 From: Blax Software Date: Wed, 8 Jul 2026 09:01:12 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20full=20package=20=E2=80=94=20typed=20ro?= =?UTF-8?q?oms=20(public/private/presence/open-presence)=20+=20call=20reco?= =?UTF-8?q?rding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms - Rooms\RoomType derived from the name prefix (like laravel-websockets channels): public, private-* (authorized), presence-* (authorized + member list), open-presence-* (public + member list). Room/RoomManager carry per-member info. - Contracts\RoomAuthorizer + secure DefaultRoomAuthorizer (denies private/presence until the host binds one). Presence rooms broadcast the member list + info. Recording (store the FULL call audio, in PHP, today) - Pure-P2P audio is DTLS-SRTP end-to-end, so the server can't capture it. Instead the browser MediaRecords its mic and streams chunks over the WS as BINARY frames (needs the new laravel-ws onBinary); RelaySignalingHandler appends them per participant via a RecordingStore. Recording\FileRecordingStore -> /.webm. welcome/joined carry a record flag telling the client to stream. - Contracts\CallEventListener call-log seam (peer/room/recording lifecycle) so calls are auditable even when unrecorded. Wiring - RelaySignalingHandler(authorizer, recorder, events); webrtc:serve + provider + config/webrtc.php (recording enabled/path/format, authorizer, events). Tests 35/99 incl on-disk FileRecordingStore, presence member lists, auth gating, binary recording+finalize+events, RoomType, and the real WS round-trip. README documents room types + the browser MediaRecorder->WS recording flow. rel learn-atc #1060 #1055 #1057 #1051 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 ++ README.md | 71 +++++- config/webrtc.php | 25 +- src/Authorization/DefaultRoomAuthorizer.php | 21 ++ src/Console/Commands/StartWebRtcServer.php | 30 ++- src/Contracts/CallEventListener.php | 24 ++ src/Contracts/RecordingStore.php | 23 ++ src/Contracts/RoomAuthorizer.php | 19 ++ src/Events/NullCallEventListener.php | 13 ++ src/Recording/FileRecordingStore.php | 72 ++++++ src/Rooms/Room.php | 47 ++-- src/Rooms/RoomManager.php | 12 +- src/Rooms/RoomType.php | 44 ++++ src/Signaling/RelaySignalingHandler.php | 238 +++++++++++++++----- src/WebRtcServiceProvider.php | 12 + tests/FileRecordingStoreTest.php | 63 ++++++ tests/Fixtures/AllowAuthorizer.php | 19 ++ tests/Fixtures/CollectingEvents.php | 30 +++ tests/Fixtures/FakePeer.php | 8 + tests/Fixtures/MemoryRecordingStore.php | 33 +++ tests/RelaySignalingHandlerTest.php | 151 ++++++++++--- tests/RoomTypeTest.php | 32 +++ 22 files changed, 865 insertions(+), 143 deletions(-) create mode 100644 src/Authorization/DefaultRoomAuthorizer.php create mode 100644 src/Contracts/CallEventListener.php create mode 100644 src/Contracts/RecordingStore.php create mode 100644 src/Contracts/RoomAuthorizer.php create mode 100644 src/Events/NullCallEventListener.php create mode 100644 src/Recording/FileRecordingStore.php create mode 100644 src/Rooms/RoomType.php create mode 100644 tests/FileRecordingStoreTest.php create mode 100644 tests/Fixtures/AllowAuthorizer.php create mode 100644 tests/Fixtures/CollectingEvents.php create mode 100644 tests/Fixtures/MemoryRecordingStore.php create mode 100644 tests/RoomTypeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ef5fe7b..9b4b1b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,27 @@ Blax Software's backward-compatibility guarantee. ### Added +- **Typed rooms** (`Rooms\RoomType`, derived from the room name prefix like + laravel-websockets channels): `public`, `private-*` (authorized), + `presence-*` (authorized + member list), `open-presence-*` (public + member + list). `Contracts\RoomAuthorizer` (+ secure `DefaultRoomAuthorizer` that denies + private/presence) gates the join; presence rooms broadcast the member list + + per-member `info`. +- **Full call recording**: `Contracts\RecordingStore` + `Recording\FileRecordingStore`. + The browser streams its mic to the server as binary WS frames; the relay appends + them per participant (`/.webm`) and finalizes on leave/disconnect. + `welcome`/`joined` carry a `record` flag telling the client to stream. +- **Call-event log seam**: `Contracts\CallEventListener` (+ `NullCallEventListener`) + receives peer-connected / room-joined / room-left / recording-finalized / + peer-disconnected events, so calls can be logged/audited even when not recorded. +- `RelaySignalingHandler` now takes an authorizer, recorder and event listener; + `webrtc:serve` wires them from `config/webrtc.php` (`recording`, `authorizer`, + `events`). +- Suite grew to 35 / 99 assertions (room types, auth, presence, recording via the + in-memory + on-disk stores, events). + +### Added (earlier) + - **Working browser-to-browser (P2P) MVP.** `Signaling\RelaySignalingHandler` (a `blax-software/laravel-ws` `MessageHandler`) relays SDP/ICE between browsers so they connect peer-to-peer β€” no server-side media engine required. Protocol: diff --git a/README.md b/README.md index ca88b3c..a17fc23 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ [![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue?style=flat-square)](https://php.net) [![Laravel](https://img.shields.io/badge/laravel-10.x--12.x-orange?style=flat-square)](https://laravel.com) [![Built on](https://img.shields.io/badge/on-reactphp--kernel-4b275f?style=flat-square)](https://github.com/blax-software/reactphp-kernel) -[![Tests](https://img.shields.io/badge/tests-26%20passing-success?style=flat-square)](#testing) -[![Assertions](https://img.shields.io/badge/assertions-60-blue?style=flat-square)](#testing) +[![Tests](https://img.shields.io/badge/tests-35%20passing-success?style=flat-square)](#testing) +[![Assertions](https://img.shields.io/badge/assertions-99-blue?style=flat-square)](#testing) [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE) WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) backbone. A **WebSocket signaling relay** connects browsers for **peer-to-peer calls today** (no server-side media needed); when you need the server *in* the media path β€” recording, AI bridging, an SFU β€” a pluggable **media engine** (a Rust `str0m` core via `ext-php-rs`) takes over. @@ -15,6 +15,8 @@ WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-sof - πŸ“ž **Browser-to-browser calls, working today** β€” two browsers `join` a room and the server relays their SDP/ICE so they connect **peer-to-peer**; the audio never touches the server, so no media engine is required - πŸ‘₯ **Rooms & mesh signaling** β€” peer discovery on join, `peer-joined` / `peer-left` notifications, targeted `relay`, and room `broadcast` +- 🏷️ **Typed rooms** β€” `public`, `private-*` (authorized), `presence-*` (authorized + member list), `open-presence-*` β€” derived from the room name, like laravel-websockets channels +- ⏺️ **Record the full call** β€” the browser streams its mic over the WebSocket and the server stores each participant's audio (`FileRecordingStore` β†’ `/.webm`); + a call-event log seam for auditing even unrecorded calls - 🧩 **On the shared kernel** β€” runs on `reactphp-kernel` over the [`laravel-ws`](https://github.com/blax-software/laravel-ws) WebSocket transport; one process, one loop, alongside your other realtime servers - πŸŽ™οΈ **Server-side media, when you need it** β€” a pluggable `MediaEngine` terminates media for **recording / intercept**, **AI-realtime bridging**, and **SFU** group calls - πŸ€– **AI realtime, provider hidden** β€” bridge a caller to an external model (e.g. OpenAI Realtime) server-side; the browser only ever talks to *you*, and the model is a config swap @@ -66,17 +68,62 @@ async function answer() { const a = await pc.createAnswer(); await pc.setLocalDe ### Relay protocol (JSON over WebSocket) ```jsonc -← { "type": "welcome", "peer": "" } // on connect -β†’ { "type": "join", "room": "lobby" } -← { "type": "joined", "room": "lobby", "peers": ["", ...] } // who's already here -← { "type": "peer-joined", "room": "lobby", "peer": "" } // sent to the others -β†’ { "type": "relay", "to": "", "data": { ...sdp | candidate... } } -← { "type": "relay", "from": "", "data": { ... } } // delivered to the target -β†’ { "type": "broadcast", "data": { ... } } // to the rest of your room +← { "type": "welcome", "peer": "", "record": false } // record:true => also stream your mic +β†’ { "type": "join", "room": "lobby", "auth": {}, "info": {} } +← { "type": "joined", "room", "roomType", "record", "peers", "members"? } // members: presence rooms only +← { "type": "peer-joined", "room", "peer", "info"? } // sent to the others +β†’ { "type": "relay", "to": "", "data": { ...sdp | candidate... } } +← { "type": "relay", "from": "", "data": { ... } } // delivered to the target +β†’ { "type": "broadcast", "data": { ... } } // to the rest of your room β†’ { "type": "leave" } ← { "type": "left" } / others get { "type": "peer-left", "peer" } + (binary frames) β†’ appended to YOUR recording (when record=true) ``` -### Server-terminated media (recording / AI bridge / SFU) +### Room types + +The type is derived from the room name prefix, like laravel-websockets channels: + +| Prefix | Type | Who can join | Member list shared | +|---|---|---|---| +| *(none)* | public | anyone | no | +| `private-` | private | via `RoomAuthorizer` | no | +| `presence-` | presence | via `RoomAuthorizer` | yes | +| `open-presence-` | open presence | anyone | yes | + +`private-*` / `presence-*` joins are denied by default β€” bind an authorizer that validates the join and returns the member's presence info: + +```dotenv +WEBRTC_AUTHORIZER="App\WebRtc\MyRoomAuthorizer" +``` +```php +final class MyRoomAuthorizer implements Blax\WebRtc\Contracts\RoomAuthorizer +{ + public function authorize(string $peerId, string $roomId, array $auth): ?array + { + // validate $auth['token'] for $roomId; return presence info, or null to deny + return ['name' => /* the authorized user's name */]; + } +} +``` + +### Recording the full call + +A pure-P2P call's audio is DTLS-SRTP end-to-end, so the server can't capture it. To store the full call, the browser MediaRecords its mic and streams the chunks to the server as **binary WebSocket frames** when `record` is set; the server appends them per participant. + +```dotenv +WEBRTC_RECORDING=true +WEBRTC_RECORDING_PATH=/var/recordings # writes /.webm +``` +```js +// after `welcome` / `joined` reports record: true +const rec = new MediaRecorder(micStream, { mimeType: 'audio/webm;codecs=opus' }) +rec.ondataavailable = (e) => e.data.size && e.data.arrayBuffer().then((b) => ws.send(b)) // binary frame +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. + +### Server-terminated media (AI bridge / SFU) 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)): @@ -88,11 +135,11 @@ The engine-backed path uses `Signaling\SignalingHandler` (offer/ice/record/conne ## Configuration -`config/webrtc.php` covers the signaling bind (`host`/`port`/`tls`), the `media_engine` binding, `recording` disk/path, advertised `ice_servers`, and the external `bridge` (e.g. OpenAI model + key). Swapping the realtime model β€” or the whole provider β€” is a server-side config change the browser never sees. +`config/webrtc.php` covers the signaling bind (`host`/`port`/`tls`), `recording` (`enabled`/`path`/`format`), the `authorizer` (room join gate) and `events` (call log) bindings, the `media_engine`, advertised `ice_servers`, and the external `bridge` (e.g. OpenAI model + key). ## Status -**Working MVP.** Browser-to-browser (mesh) calls work today via the WebSocket signaling relay β€” no server-side media required. Rooms, peer discovery, targeted relay and broadcast are tested, including a real WebSocket round-trip. Server-terminated media (recording, AI bridge, SFU) is the pluggable `MediaEngine`; the real one (`Str0mMediaEngine` + `rust/`) is the next build. +**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. ## Architecture diff --git a/config/webrtc.php b/config/webrtc.php index ee73397..115952a 100644 --- a/config/webrtc.php +++ b/config/webrtc.php @@ -1,5 +1,7 @@ //.). See README. */ 'recording' => [ - 'disk' => env('WEBRTC_RECORDING_DISK', 'local'), - 'path' => env('WEBRTC_RECORDING_PATH', 'webrtc/recordings'), + 'enabled' => (bool) env('WEBRTC_RECORDING', false), + 'path' => env('WEBRTC_RECORDING_PATH', storage_path('app/webrtc')), + 'format' => env('WEBRTC_RECORDING_FORMAT', 'webm'), ], + /* + |-------------------------------------------------------------------------- + | Room authorization + call-event log + |-------------------------------------------------------------------------- + | `authorizer` gates private-* / presence-* joins (bind your own that checks a + | token / the authenticated user; the default DENIES them). `events` receives + | call-lifecycle events so you can log the call (bind your own to persist). + */ + 'authorizer' => env('WEBRTC_AUTHORIZER', DefaultRoomAuthorizer::class), + 'events' => env('WEBRTC_EVENTS', NullCallEventListener::class), + /* |-------------------------------------------------------------------------- | ICE servers (STUN/TURN) advertised to browsers diff --git a/src/Authorization/DefaultRoomAuthorizer.php b/src/Authorization/DefaultRoomAuthorizer.php new file mode 100644 index 0000000..7266806 --- /dev/null +++ b/src/Authorization/DefaultRoomAuthorizer.php @@ -0,0 +1,21 @@ +option('host') ?: config('webrtc.host', '127.0.0.1')); $port = (int) ($this->option('port') ?: config('webrtc.port', 8090)); - $kernel = (new Kernel)->reapChildren(); - $kernel->register(new WebSocketServer( - $host, - $port, - new RelaySignalingHandler, - (array) config('webrtc.tls', []), - )); + $recorder = config('webrtc.recording.enabled') + ? new FileRecordingStore( + (string) config('webrtc.recording.path', 'webrtc'), + (string) config('webrtc.recording.format', 'webm'), + ) + : null; - $this->info("blax WebRTC signaling relay (WebSocket) on ws://{$host}:{$port} β€” browsers connect here for P2P calls. Ctrl-C to stop."); + $handler = new RelaySignalingHandler( + authorizer: app(RoomAuthorizer::class), + recorder: $recorder, + events: app(CallEventListener::class), + ); + + $kernel = (new Kernel)->reapChildren(); + $kernel->register(new WebSocketServer($host, $port, $handler, (array) config('webrtc.tls', []))); + + $recording = $recorder !== null ? 'on' : 'off'; + $this->info("blax WebRTC relay on ws://{$host}:{$port} β€” typed rooms + presence, recording {$recording}. Ctrl-C to stop."); $kernel->run(); diff --git a/src/Contracts/CallEventListener.php b/src/Contracts/CallEventListener.php new file mode 100644 index 0000000..20bb13d --- /dev/null +++ b/src/Contracts/CallEventListener.php @@ -0,0 +1,24 @@ + $event */ + public function handle(array $event): void; +} diff --git a/src/Contracts/RecordingStore.php b/src/Contracts/RecordingStore.php new file mode 100644 index 0000000..31c8d4f --- /dev/null +++ b/src/Contracts/RecordingStore.php @@ -0,0 +1,23 @@ + $auth client-supplied auth payload (token, etc.) + * @return array|null presence info to publish if allowed; null denies the join + */ + public function authorize(string $peerId, string $roomId, array $auth): ?array; +} diff --git a/src/Events/NullCallEventListener.php b/src/Events/NullCallEventListener.php new file mode 100644 index 0000000..617f71b --- /dev/null +++ b/src/Events/NullCallEventListener.php @@ -0,0 +1,13 @@ +//.`. The browser MediaRecords its mic and streams + * the chunks (default webm/opus) over the WebSocket; concatenating them yields a + * single playable file, so this just appends bytes and closes on finalize. + */ +final class FileRecordingStore implements RecordingStore +{ + /** @var array open write handles keyed by "room|peer" */ + private array $handles = []; + + public function __construct( + private string $directory, + private string $extension = 'webm', + ) {} + + public function append(string $roomId, string $peerId, string $chunk): void + { + $key = $this->key($roomId, $peerId); + + if (! isset($this->handles[$key])) { + $dir = $this->directory.'/'.$this->safe($roomId); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $handle = @fopen($this->path($roomId, $peerId), 'ab'); + if ($handle === false) { + return; // storage unavailable β€” drop the chunk rather than crash the call + } + $this->handles[$key] = $handle; + } + + fwrite($this->handles[$key], $chunk); + } + + public function finalize(string $roomId, string $peerId): ?string + { + $key = $this->key($roomId, $peerId); + if (! isset($this->handles[$key])) { + return null; + } + + fclose($this->handles[$key]); + unset($this->handles[$key]); + + return $this->path($roomId, $peerId); + } + + /** The path a recording is (or will be) written to. */ + public function path(string $roomId, string $peerId): string + { + return $this->directory.'/'.$this->safe($roomId).'/'.$this->safe($peerId).'.'.$this->extension; + } + + private function key(string $roomId, string $peerId): string + { + return $roomId.'|'.$peerId; + } + + private function safe(string $value): string + { + return preg_replace('/[^A-Za-z0-9_.-]/', '_', $value) ?? '_'; + } +} diff --git a/src/Rooms/Room.php b/src/Rooms/Room.php index c75df2f..3f292e2 100644 --- a/src/Rooms/Room.php +++ b/src/Rooms/Room.php @@ -5,53 +5,70 @@ declare(strict_types=1); namespace Blax\WebRtc\Rooms; /** - * A call room: the set of peers currently in a party-to-party / group call. - * The media engine forwards audio between the peers a room reports. + * A call room: the peers currently in a party-to-party / group call, each with + * optional presence `info` (name, avatar, role β€” whatever the app attaches). The + * media engine (or, for P2P, the peers themselves) forwards audio between them. */ final class Room { - /** @var array peer id => present */ - private array $peers = []; + /** @var array> peer id => presence info */ + private array $members = []; - public function __construct(public readonly string $id) {} + public function __construct( + public readonly string $id, + public readonly RoomType $type = RoomType::Public, + ) {} /** - * Add a peer and return the OTHER peers already present β€” the set the caller - * must mesh the new peer with (engine->connectPeers). + * Add a member (with optional presence info) and return the OTHER peers that + * were already present β€” the set to mesh / notify. * + * @param array $info * @return array */ - public function add(string $peerId): array + public function add(string $peerId, array $info = []): array { - $others = array_keys($this->peers); - $this->peers[$peerId] = true; + $others = array_keys($this->members); + $this->members[$peerId] = $info; return $others; } public function remove(string $peerId): void { - unset($this->peers[$peerId]); + unset($this->members[$peerId]); } public function has(string $peerId): bool { - return isset($this->peers[$peerId]); + return isset($this->members[$peerId]); } /** @return array */ public function peers(): array { - return array_keys($this->peers); + return array_keys($this->members); + } + + /** @return array> peer id => presence info */ + public function members(): array + { + return $this->members; + } + + /** @return array */ + public function info(string $peerId): array + { + return $this->members[$peerId] ?? []; } public function isEmpty(): bool { - return $this->peers === []; + return $this->members === []; } public function count(): int { - return count($this->peers); + return count($this->members); } } diff --git a/src/Rooms/RoomManager.php b/src/Rooms/RoomManager.php index 784dbd7..bd60abf 100644 --- a/src/Rooms/RoomManager.php +++ b/src/Rooms/RoomManager.php @@ -18,20 +18,22 @@ final class RoomManager private array $peerRoom = []; /** - * Join $peerId to $roomId, returning the OTHER peers already present so the - * caller can mesh them. + * Join $peerId to $roomId (with optional presence $info), returning the OTHER + * peers already present so the caller can mesh / notify them. The room's type + * is derived from its name (RoomType::fromName) on first join. * + * @param array $info * @return array */ - public function join(string $roomId, string $peerId): array + public function join(string $roomId, string $peerId, array $info = []): array { // Moving between rooms: leave the old one first. if (($this->peerRoom[$peerId] ?? null) !== null && $this->peerRoom[$peerId] !== $roomId) { $this->leave($peerId); } - $room = $this->rooms[$roomId] ??= new Room($roomId); - $others = $room->add($peerId); + $room = $this->rooms[$roomId] ??= new Room($roomId, RoomType::fromName($roomId)); + $others = $room->add($peerId, $info); $this->peerRoom[$peerId] = $roomId; return $others; diff --git a/src/Rooms/RoomType.php b/src/Rooms/RoomType.php new file mode 100644 index 0000000..e8ced74 --- /dev/null +++ b/src/Rooms/RoomType.php @@ -0,0 +1,44 @@ + self::OpenPresence, + str_starts_with($room, 'presence-') => self::Presence, + str_starts_with($room, 'private-') => self::Private, + default => self::Public, + }; + } + + /** Private + presence rooms require the host to authorize the join. */ + public function requiresAuth(): bool + { + return $this === self::Private || $this === self::Presence; + } + + /** Presence + open-presence rooms share the member list with the room. */ + public function hasPresence(): bool + { + return $this === self::Presence || $this === self::OpenPresence; + } +} diff --git a/src/Signaling/RelaySignalingHandler.php b/src/Signaling/RelaySignalingHandler.php index d847309..1da4294 100644 --- a/src/Signaling/RelaySignalingHandler.php +++ b/src/Signaling/RelaySignalingHandler.php @@ -5,32 +5,38 @@ declare(strict_types=1); namespace Blax\WebRtc\Signaling; use Blax\ReactPhpKernel\Support\ConnectionRegistry; +use Blax\WebRtc\Authorization\DefaultRoomAuthorizer; +use Blax\WebRtc\Contracts\CallEventListener; +use Blax\WebRtc\Contracts\RecordingStore; +use Blax\WebRtc\Contracts\RoomAuthorizer; +use Blax\WebRtc\Events\NullCallEventListener; use Blax\WebRtc\Rooms\RoomManager; +use Blax\WebRtc\Rooms\RoomType; use Blax\Ws\Contracts\MessageHandler; use Blax\Ws\Contracts\Sendable; /** - * The working browser-to-browser (mesh) signaling relay. + * The full browser signaling relay: typed rooms, presence, per-call recording and + * an audit-log seam, over a `blax-software/laravel-ws` WebSocket. * - * For a peer-to-peer call the SERVER does not touch media at all β€” the browsers - * establish a direct WebRTC connection and exchange only SDP + ICE, which this - * relay forwards. Two browsers that `join` the same room discover each other and - * relay `offer`/`answer`/`candidate` payloads through here; the audio flows P2P. + * Rooms (type from the name prefix, like laravel-websockets channels): + * public Β· private-* (authorized) Β· presence-* (authorized + member list) Β· + * open-presence-* (public + member list). * - * Runs as a Blax\Ws\Contracts\MessageHandler on a laravel-ws WebSocketServer, so - * `webrtc:serve` gives a browser-usable signaling endpoint out of the box. Server - * -terminated media (recording, AI bridge, SFU) is the separate MediaEngine seam - * (Str0mMediaEngine) β€” not needed for a P2P call. + * Recording: for a peer-to-peer call the audio is DTLS-SRTP end-to-end, so the + * server can't see it. To store the full call the browser MediaRecords its mic + * and streams the chunks here as BINARY frames (onBinary) β€” the RecordingStore + * appends them per participant and finalizes on leave/disconnect. The `record` + * flag in `welcome`/`joined` tells the client whether to stream. * - * Protocol (JSON): - * ← { "type": "welcome", "peer": "" } (on connect) - * β†’ { "type": "join", "room": "lobby" } - * ← { "type": "joined", "room": "lobby", "peers": [...] } (existing peers) - * ← { "type": "peer-joined", "room": "lobby", "peer": "" } (to the others) - * β†’ { "type": "relay", "to": "", "data": { ...sdp/ice... } } - * ← { "type": "relay", "from": "", "data": { ... } } (to the target) - * β†’ { "type": "broadcast", "data": { ... } } (to the rest of your room) - * β†’ { "type": "leave" } ← { "type": "left" } / peers get { "type": "peer-left", "peer" } + * Protocol (JSON text frames): + * ← welcome { peer, record } + * β†’ join { room, auth?, info? } ← joined { room, roomType, record, peers, members? } + * others ← peer-joined { room, peer, info? } + * β†’ relay { to, data } ← relay { from, data } + * β†’ broadcast { data } room ← broadcast { from, data } + * β†’ leave ← left / others ← peer-left { peer } + * (binary frames) β†’ appended to the sender's recording */ final class RelaySignalingHandler implements MessageHandler { @@ -38,10 +44,26 @@ final class RelaySignalingHandler implements MessageHandler private RoomManager $rooms; - public function __construct(?ConnectionRegistry $peers = null, ?RoomManager $rooms = null) - { + private RoomAuthorizer $authorizer; + + private ?RecordingStore $recorder; + + private CallEventListener $events; + + private int $seq = 0; + + public function __construct( + ?ConnectionRegistry $peers = null, + ?RoomManager $rooms = null, + ?RoomAuthorizer $authorizer = null, + ?RecordingStore $recorder = null, + ?CallEventListener $events = null, + ) { $this->peers = $peers ?? new ConnectionRegistry; $this->rooms = $rooms ?? new RoomManager; + $this->authorizer = $authorizer ?? new DefaultRoomAuthorizer; + $this->recorder = $recorder; + $this->events = $events ?? new NullCallEventListener; } public function peers(): ConnectionRegistry @@ -54,10 +76,20 @@ final class RelaySignalingHandler implements MessageHandler return $this->rooms; } + public function recording(): bool + { + return $this->recorder !== null; + } + public function onOpen(Sendable $connection): void { $this->peers->add($connection->id(), $connection); - $connection->send($this->encode(['type' => 'welcome', 'peer' => $connection->id()])); + $connection->send($this->encode([ + 'type' => 'welcome', + 'peer' => $connection->id(), + 'record' => $this->recording(), + ])); + $this->emit(['type' => 'peer-connected', 'peer' => $connection->id()]); } public function onMessage(Sendable $connection, string $payload): void @@ -69,68 +101,147 @@ final class RelaySignalingHandler implements MessageHandler return; } - $peer = $connection->id(); - switch ((string) ($message['type'] ?? '')) { case 'join': - $room = (string) ($message['room'] ?? ''); - if ($room === '') { - $connection->send($this->error('missing room')); - - return; - } - $others = $this->rooms->join($room, $peer); - $connection->send($this->encode(['type' => 'joined', 'room' => $room, 'peers' => $others])); - foreach ($others as $other) { - $this->sendTo($other, ['type' => 'peer-joined', 'room' => $room, 'peer' => $peer]); - } + $this->handleJoin($connection, $message); break; - case 'leave': - foreach ($this->rooms->leave($peer) as $other) { - $this->sendTo($other, ['type' => 'peer-left', 'peer' => $peer]); - } + $this->departRoom($connection->id()); $connection->send($this->encode(['type' => 'left'])); break; - case 'relay': - $to = (string) ($message['to'] ?? ''); - if ($to === '') { - $connection->send($this->error('missing to')); - - return; - } - if (! $this->sendTo($to, ['type' => 'relay', 'from' => $peer, 'data' => $message['data'] ?? null])) { - $connection->send($this->error("peer not connected: {$to}")); - } + $this->handleRelay($connection, $message); break; - case 'broadcast': - $room = $this->rooms->roomOf($peer); - if ($room !== null) { - foreach ($this->rooms->peersIn($room) as $other) { - if ($other !== $peer) { - $this->sendTo($other, ['type' => 'broadcast', 'from' => $peer, 'data' => $message['data'] ?? null]); - } - } - } + $this->handleBroadcast($connection->id(), $message); break; - default: $connection->send($this->error('unknown type')); } } + public function onBinary(Sendable $connection, string $payload): void + { + // Streamed audio for recording β€” append to this peer's recording in its room. + if ($this->recorder === null) { + return; + } + $room = $this->rooms->roomOf($connection->id()); + if ($room !== null) { + $this->recorder->append($room, $connection->id(), $payload); + } + } + public function onClose(Sendable $connection): void { $peer = $connection->id(); + $this->departRoom($peer); + $this->peers->remove($peer); + $this->emit(['type' => 'peer-disconnected', 'peer' => $peer]); + } + + /** @param array $message */ + private function handleJoin(Sendable $connection, array $message): void + { + $peer = $connection->id(); + $roomId = (string) ($message['room'] ?? ''); + if ($roomId === '') { + $connection->send($this->error('missing room')); + + return; + } + + $type = RoomType::fromName($roomId); + + if ($type->requiresAuth()) { + $info = $this->authorizer->authorize($peer, $roomId, (array) ($message['auth'] ?? [])); + if ($info === null) { + $connection->send($this->error('unauthorized')); + + return; + } + } else { + // public keeps no info; open-presence lets the client publish its own. + $info = $type->hasPresence() ? (array) ($message['info'] ?? []) : []; + } + + $others = $this->rooms->join($roomId, $peer, $info); + $room = $this->rooms->room($roomId); + + $reply = [ + 'type' => 'joined', + 'room' => $roomId, + 'roomType' => $type->value, + 'record' => $this->recording(), + 'peers' => $others, + ]; + if ($type->hasPresence() && $room !== null) { + $reply['members'] = $room->members(); + } + $connection->send($this->encode($reply)); + + $joined = ['type' => 'peer-joined', 'room' => $roomId, 'peer' => $peer]; + if ($type->hasPresence()) { + $joined['info'] = $info; + } + foreach ($others as $other) { + $this->sendTo($other, $joined); + } + + $this->emit(['type' => 'room-joined', 'peer' => $peer, 'room' => $roomId, 'roomType' => $type->value]); + } + + /** Leave the current room: notify remaining peers, finalize recording, emit events. */ + private function departRoom(string $peer): void + { + $room = $this->rooms->roomOf($peer); + if ($room === null) { + return; + } + foreach ($this->rooms->leave($peer) as $other) { $this->sendTo($other, ['type' => 'peer-left', 'peer' => $peer]); } - $this->peers->remove($peer); + + if ($this->recorder !== null) { + $locator = $this->recorder->finalize($room, $peer); + if ($locator !== null) { + $this->emit(['type' => 'recording-finalized', 'peer' => $peer, 'room' => $room, 'recording' => $locator]); + } + } + + $this->emit(['type' => 'room-left', 'peer' => $peer, 'room' => $room]); } - /** Deliver a message to a specific peer; false if they're not connected. */ + /** @param array $message */ + private function handleRelay(Sendable $connection, array $message): void + { + $to = (string) ($message['to'] ?? ''); + if ($to === '') { + $connection->send($this->error('missing to')); + + return; + } + if (! $this->sendTo($to, ['type' => 'relay', 'from' => $connection->id(), 'data' => $message['data'] ?? null])) { + $connection->send($this->error("peer not connected: {$to}")); + } + } + + /** @param array $message */ + private function handleBroadcast(string $peer, array $message): void + { + $room = $this->rooms->roomOf($peer); + if ($room === null) { + return; + } + foreach ($this->rooms->peersIn($room) as $other) { + if ($other !== $peer) { + $this->sendTo($other, ['type' => 'broadcast', 'from' => $peer, 'data' => $message['data'] ?? null]); + } + } + } + + /** @param array $message */ private function sendTo(string $peerId, array $message): bool { $connection = $this->peers->get($peerId); @@ -143,6 +254,13 @@ final class RelaySignalingHandler implements MessageHandler return true; } + /** @param array $event */ + private function emit(array $event): void + { + $event['seq'] = ++$this->seq; + $this->events->handle($event); + } + private function error(string $error): string { return $this->encode(['type' => 'error', 'error' => $error]); diff --git a/src/WebRtcServiceProvider.php b/src/WebRtcServiceProvider.php index 9a1d7f2..0617a92 100644 --- a/src/WebRtcServiceProvider.php +++ b/src/WebRtcServiceProvider.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace Blax\WebRtc; +use Blax\WebRtc\Authorization\DefaultRoomAuthorizer; use Blax\WebRtc\Console\Commands\StartWebRtcServer; +use Blax\WebRtc\Contracts\CallEventListener; use Blax\WebRtc\Contracts\MediaEngine; +use Blax\WebRtc\Contracts\RoomAuthorizer; +use Blax\WebRtc\Events\NullCallEventListener; use Blax\WebRtc\Media\NullMediaEngine; use Illuminate\Support\ServiceProvider; @@ -21,6 +25,14 @@ class WebRtcServiceProvider extends ServiceProvider return $app->make($class); }); + + // Room authorization (private/presence) + the call-event log β€” host-overridable. + $this->app->bind(RoomAuthorizer::class, function ($app) { + return $app->make((string) config('webrtc.authorizer', DefaultRoomAuthorizer::class)); + }); + $this->app->bind(CallEventListener::class, function ($app) { + return $app->make((string) config('webrtc.events', NullCallEventListener::class)); + }); } public function boot(): void diff --git a/tests/FileRecordingStoreTest.php b/tests/FileRecordingStoreTest.php new file mode 100644 index 0000000..4386261 --- /dev/null +++ b/tests/FileRecordingStoreTest.php @@ -0,0 +1,63 @@ +dir = sys_get_temp_dir().'/blax-webrtc-rec-'.bin2hex(random_bytes(4)); + } + + protected function tearDown(): void + { + if (! is_dir($this->dir)) { + return; + } + foreach (glob($this->dir.'/*/*') ?: [] as $file) { + @unlink($file); + } + foreach (glob($this->dir.'/*') ?: [] as $sub) { + @rmdir($sub); + } + @rmdir($this->dir); + } + + public function test_appends_chunks_and_finalizes_the_full_audio(): void + { + $store = new FileRecordingStore($this->dir, 'webm'); + + $store->append('presence-lobby', 'peer1', 'CHUNK-A'); + $store->append('presence-lobby', 'peer1', 'CHUNK-B'); + $path = $store->finalize('presence-lobby', 'peer1'); + + $this->assertNotNull($path); + $this->assertFileExists($path); + $this->assertStringEndsWith('.webm', $path); + $this->assertSame('CHUNK-ACHUNK-B', file_get_contents($path)); + } + + public function test_finalize_without_any_data_returns_null(): void + { + $this->assertNull((new FileRecordingStore($this->dir))->finalize('r', 'p')); + } + + public function test_room_and_peer_names_are_sanitized_into_a_contained_path(): void + { + $store = new FileRecordingStore($this->dir); + + $store->append('room/../x', 'peer id!', 'X'); + $path = $store->finalize('room/../x', 'peer id!'); + + $this->assertNotNull($path); + $this->assertStringStartsWith($this->dir, $path); // can't escape the recording dir + $this->assertFileExists($path); + } +} diff --git a/tests/Fixtures/AllowAuthorizer.php b/tests/Fixtures/AllowAuthorizer.php new file mode 100644 index 0000000..0672707 --- /dev/null +++ b/tests/Fixtures/AllowAuthorizer.php @@ -0,0 +1,19 @@ + $info */ + public function __construct(private array $info = ['ok' => true]) {} + + public function authorize(string $peerId, string $roomId, array $auth): ?array + { + return $this->info; + } +} diff --git a/tests/Fixtures/CollectingEvents.php b/tests/Fixtures/CollectingEvents.php new file mode 100644 index 0000000..aac16d3 --- /dev/null +++ b/tests/Fixtures/CollectingEvents.php @@ -0,0 +1,30 @@ +> */ + public array $events = []; + + public function handle(array $event): void + { + $this->events[] = $event; + } + + /** @return array */ + public function types(): array + { + return array_map(fn ($e) => (string) $e['type'], $this->events); + } + + /** @return array> */ + public function ofType(string $type): array + { + return array_values(array_filter($this->events, fn ($e) => ($e['type'] ?? null) === $type)); + } +} diff --git a/tests/Fixtures/FakePeer.php b/tests/Fixtures/FakePeer.php index affcea0..2759b18 100644 --- a/tests/Fixtures/FakePeer.php +++ b/tests/Fixtures/FakePeer.php @@ -12,6 +12,9 @@ final class FakePeer implements Sendable /** @var array */ public array $sent = []; + /** @var array */ + public array $sentBinary = []; + public bool $closed = false; public function __construct(private string $id) {} @@ -26,6 +29,11 @@ final class FakePeer implements Sendable $this->sent[] = $message; } + public function sendBinary(string $bytes): void + { + $this->sentBinary[] = $bytes; + } + public function close(): void { $this->closed = true; diff --git a/tests/Fixtures/MemoryRecordingStore.php b/tests/Fixtures/MemoryRecordingStore.php new file mode 100644 index 0000000..000c9a4 --- /dev/null +++ b/tests/Fixtures/MemoryRecordingStore.php @@ -0,0 +1,33 @@ + "room|peer" => concatenated bytes */ + public array $data = []; + + /** @var array finalized keys */ + public array $finalized = []; + + public function append(string $roomId, string $peerId, string $chunk): void + { + $key = "{$roomId}|{$peerId}"; + $this->data[$key] = ($this->data[$key] ?? '').$chunk; + } + + public function finalize(string $roomId, string $peerId): ?string + { + $key = "{$roomId}|{$peerId}"; + if (! isset($this->data[$key])) { + return null; + } + $this->finalized[] = $key; + + return "mem://{$key}"; + } +} diff --git a/tests/RelaySignalingHandlerTest.php b/tests/RelaySignalingHandlerTest.php index 53fad42..f24211f 100644 --- a/tests/RelaySignalingHandlerTest.php +++ b/tests/RelaySignalingHandlerTest.php @@ -5,22 +5,23 @@ declare(strict_types=1); namespace Blax\WebRtc\Tests; use Blax\WebRtc\Signaling\RelaySignalingHandler; +use Blax\WebRtc\Tests\Fixtures\AllowAuthorizer; +use Blax\WebRtc\Tests\Fixtures\CollectingEvents; use Blax\WebRtc\Tests\Fixtures\FakePeer; +use Blax\WebRtc\Tests\Fixtures\MemoryRecordingStore; use PHPUnit\Framework\TestCase; final class RelaySignalingHandlerTest extends TestCase { - public function test_welcomes_a_peer_on_connect_with_its_id(): void + public function test_welcomes_a_peer_with_its_id_and_the_record_flag(): void { - $handler = new RelaySignalingHandler; $a = new FakePeer('a'); + (new RelaySignalingHandler)->onOpen($a); - $handler->onOpen($a); - - $this->assertSame(['type' => 'welcome', 'peer' => 'a'], $a->lastJson()); + $this->assertSame(['type' => 'welcome', 'peer' => 'a', 'record' => false], $a->lastJson()); } - public function test_join_returns_existing_peers_and_notifies_them(): void + public function test_public_join_returns_others_notifies_them_and_has_no_member_list(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); @@ -31,13 +32,61 @@ final class RelaySignalingHandlerTest extends TestCase $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); $handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby'])); - // b learns 'a' is already there - $this->assertSame(['type' => 'joined', 'room' => 'lobby', 'peers' => ['a']], $b->lastJson()); - // a is told b joined + $joined = $b->lastJson(); + $this->assertSame('joined', $joined['type']); + $this->assertSame('public', $joined['roomType']); + $this->assertSame(['a'], $joined['peers']); + $this->assertArrayNotHasKey('members', $joined); $this->assertContains(['type' => 'peer-joined', 'room' => 'lobby', 'peer' => 'b'], $a->jsonMessages()); } - public function test_relay_forwards_the_payload_to_the_target_with_the_sender(): void + public function test_presence_room_shares_the_member_list_with_info(): void + { + $handler = new RelaySignalingHandler; + $a = new FakePeer('a'); + $b = new FakePeer('b'); + $handler->onOpen($a); + $handler->onOpen($b); + + $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'open-presence-lobby', 'info' => ['name' => 'Ann']])); + $handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'open-presence-lobby', 'info' => ['name' => 'Bob']])); + + $joined = $b->lastJson(); + $this->assertSame('open-presence', $joined['roomType']); + $this->assertSame(['a' => ['name' => 'Ann'], 'b' => ['name' => 'Bob']], $joined['members']); + $this->assertContains( + ['type' => 'peer-joined', 'room' => 'open-presence-lobby', 'peer' => 'b', 'info' => ['name' => 'Bob']], + $a->jsonMessages(), + ); + } + + public function test_private_room_is_denied_by_default(): void + { + $handler = new RelaySignalingHandler; // DefaultRoomAuthorizer denies + $a = new FakePeer('a'); + $handler->onOpen($a); + + $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'private-x'])); + + $this->assertSame(['type' => 'error', 'error' => 'unauthorized'], $a->lastJson()); + $this->assertNull($handler->rooms()->roomOf('a')); + } + + public function test_private_room_is_allowed_by_a_bound_authorizer(): void + { + $handler = new RelaySignalingHandler(authorizer: new AllowAuthorizer(['role' => 'host'])); + $a = new FakePeer('a'); + $handler->onOpen($a); + + $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'private-x', 'auth' => ['token' => 't']])); + + $joined = $a->lastJson(); + $this->assertSame('joined', $joined['type']); + $this->assertSame('private', $joined['roomType']); + $this->assertSame('private-x', $handler->rooms()->roomOf('a')); + } + + public function test_relay_forwards_to_the_target_with_the_sender(): void { $handler = new RelaySignalingHandler; $a = new FakePeer('a'); @@ -50,17 +99,6 @@ final class RelaySignalingHandlerTest extends TestCase $this->assertSame(['type' => 'relay', 'from' => 'a', 'data' => ['sdp' => 'OFFER']], $b->lastJson()); } - public function test_relay_to_an_unknown_peer_errors_the_sender(): void - { - $handler = new RelaySignalingHandler; - $a = new FakePeer('a'); - $handler->onOpen($a); - - $handler->onMessage($a, $this->json(['type' => 'relay', 'to' => 'ghost', 'data' => []])); - - $this->assertSame('error', $a->lastJson()['type']); - } - public function test_leave_notifies_the_remaining_peers(): void { $handler = new RelaySignalingHandler; @@ -77,22 +115,6 @@ final class RelaySignalingHandlerTest extends TestCase $this->assertSame(['type' => 'left'], $b->lastJson()); } - public function test_disconnect_leaves_the_room_and_forgets_the_peer(): void - { - $handler = new RelaySignalingHandler; - $a = new FakePeer('a'); - $b = new FakePeer('b'); - $handler->onOpen($a); - $handler->onOpen($b); - $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); - $handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby'])); - - $handler->onClose($b); - - $this->assertContains(['type' => 'peer-left', 'peer' => 'b'], $a->jsonMessages()); - $this->assertFalse($handler->peers()->has('b')); - } - public function test_broadcast_reaches_the_rest_of_the_room_only(): void { $handler = new RelaySignalingHandler; @@ -111,6 +133,61 @@ final class RelaySignalingHandlerTest extends TestCase $this->assertNotContains($expected, $a->jsonMessages()); } + public function test_streamed_binary_audio_is_recorded_and_finalized_on_disconnect(): void + { + $store = new MemoryRecordingStore; + $events = new CollectingEvents; + $handler = new RelaySignalingHandler(recorder: $store, events: $events); + + $a = new FakePeer('a'); + $handler->onOpen($a); + $this->assertTrue($a->lastJson()['record'], 'welcome should tell the client to record'); + + $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); + $handler->onBinary($a, 'AUDIO-1'); + $handler->onBinary($a, 'AUDIO-2'); + $this->assertSame('AUDIO-1AUDIO-2', $store->data['lobby|a']); + + $handler->onClose($a); + $this->assertContains('lobby|a', $store->finalized); + + $finalized = $events->ofType('recording-finalized'); + $this->assertCount(1, $finalized); + $this->assertSame('mem://lobby|a', $finalized[0]['recording']); + } + + public function test_binary_without_a_recorder_is_ignored(): void + { + $handler = new RelaySignalingHandler; // no recorder + $a = new FakePeer('a'); + $handler->onOpen($a); + $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); + + $handler->onBinary($a, 'AUDIO'); // must be a harmless no-op + + $this->assertTrue(true); + } + + public function test_emits_ordered_call_lifecycle_events(): void + { + $events = new CollectingEvents; + $handler = new RelaySignalingHandler(events: $events); + $a = new FakePeer('a'); + + $handler->onOpen($a); + $handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby'])); + $handler->onClose($a); + + $types = $events->types(); + $this->assertContains('peer-connected', $types); + $this->assertContains('room-joined', $types); + $this->assertContains('room-left', $types); + $this->assertContains('peer-disconnected', $types); + + $seqs = array_column($events->events, 'seq'); + $this->assertSame(range(1, count($seqs)), $seqs, 'seq should be monotonic from 1'); + } + public function test_invalid_json_and_unknown_type_are_errors(): void { $handler = new RelaySignalingHandler; diff --git a/tests/RoomTypeTest.php b/tests/RoomTypeTest.php new file mode 100644 index 0000000..16e83ae --- /dev/null +++ b/tests/RoomTypeTest.php @@ -0,0 +1,32 @@ +assertSame(RoomType::Public, RoomType::fromName('lobby')); + $this->assertSame(RoomType::Private, RoomType::fromName('private-standup')); + $this->assertSame(RoomType::Presence, RoomType::fromName('presence-team')); + $this->assertSame(RoomType::OpenPresence, RoomType::fromName('open-presence-town-hall')); + } + + public function test_auth_and_presence_flags(): void + { + $this->assertTrue(RoomType::Private->requiresAuth()); + $this->assertTrue(RoomType::Presence->requiresAuth()); + $this->assertFalse(RoomType::Public->requiresAuth()); + $this->assertFalse(RoomType::OpenPresence->requiresAuth()); + + $this->assertTrue(RoomType::Presence->hasPresence()); + $this->assertTrue(RoomType::OpenPresence->hasPresence()); + $this->assertFalse(RoomType::Public->hasPresence()); + $this->assertFalse(RoomType::Private->hasPresence()); + } +}