feat: full package — typed rooms (public/private/presence/open-presence) + call recording

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 -> <room>/<peer>.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 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-08 09:01:12 +02:00
parent 028eaadaa8
commit 1be40ba59c
22 changed files with 865 additions and 143 deletions

View File

@ -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 (`<room>/<peer>.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:

View File

@ -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` → `<room>/<peer>.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": "<yourId>" } // on connect
→ { "type": "join", "room": "lobby" }
← { "type": "joined", "room": "lobby", "peers": ["<id>", ...] } // who's already here
← { "type": "peer-joined", "room": "lobby", "peer": "<id>" } // sent to the others
→ { "type": "relay", "to": "<peerId>", "data": { ...sdp | candidate... } }
← { "type": "relay", "from": "<peerId>", "data": { ... } } // delivered to the target
→ { "type": "broadcast", "data": { ... } } // to the rest of your room
← { "type": "welcome", "peer": "<id>", "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": "<peer>", "data": { ...sdp | candidate... } }
← { "type": "relay", "from": "<peer>", "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 <room>/<peer>.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

View File

@ -1,5 +1,7 @@
<?php
use Blax\WebRtc\Authorization\DefaultRoomAuthorizer;
use Blax\WebRtc\Events\NullCallEventListener;
use Blax\WebRtc\Media\NullMediaEngine;
return [
@ -32,16 +34,29 @@ return [
/*
|--------------------------------------------------------------------------
| Recording
| Recording (store the full call audio)
|--------------------------------------------------------------------------
| Where server-side call recordings are written (attach them to your own
| transmission rows afterwards).
| When enabled, the browser streams its mic to the server as binary WS frames
| and RelaySignalingHandler appends them per participant via a RecordingStore
| (FileRecordingStore by default: <path>/<room>/<peer>.<format>). 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

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Authorization;
use Blax\WebRtc\Contracts\RoomAuthorizer;
/**
* Secure default: DENIES every private/presence join. The host app must bind its
* own RoomAuthorizer (via `config('webrtc.authorizer')`) that validates a token /
* the authenticated user and returns the member's presence info. Public and
* open-presence rooms don't consult the authorizer, so they still work out of the box.
*/
final class DefaultRoomAuthorizer implements RoomAuthorizer
{
public function authorize(string $peerId, string $roomId, array $auth): ?array
{
return null;
}
}

View File

@ -5,6 +5,9 @@ declare(strict_types=1);
namespace Blax\WebRtc\Console\Commands;
use Blax\ReactPhpKernel\Kernel;
use Blax\WebRtc\Contracts\CallEventListener;
use Blax\WebRtc\Contracts\RoomAuthorizer;
use Blax\WebRtc\Recording\FileRecordingStore;
use Blax\WebRtc\Signaling\RelaySignalingHandler;
use Blax\Ws\WebSocketServer;
use Illuminate\Console\Command;
@ -13,22 +16,31 @@ class StartWebRtcServer extends Command
{
protected $signature = 'webrtc:serve {--host= : Bind host (defaults to config webrtc.host)} {--port= : Bind port (defaults to config webrtc.port)}';
protected $description = 'Start the blax WebRTC signaling relay (WebSocket) on the shared ReactPHP kernel — browsers connect here for peer-to-peer calls.';
protected $description = 'Start the blax WebRTC signaling relay (WebSocket) on the shared ReactPHP kernel — typed rooms, presence and optional call recording.';
public function handle(): int
{
$host = (string) ($this->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();

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Contracts;
/**
* Receives call-lifecycle events so the host can LOG the call (even when nothing
* is recorded): peer connected/disconnected, room joined/left, and when
* recording is on the finalized recording locator. Bind your own to persist to
* a DB / audit log.
*
* Event shapes (all carry `type` + a monotonic `seq`):
* { type: 'peer-connected', peer }
* { type: 'room-joined', peer, room, roomType }
* { type: 'room-left', peer, room }
* { type: 'recording-finalized', peer, room, recording } // recording = locator
* { type: 'peer-disconnected', peer }
*/
interface CallEventListener
{
/** @param array<string,mixed> $event */
public function handle(array $event): void;
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Contracts;
/**
* Where a call's audio is stored. The browser streams its mic to the server over
* the WebSocket as binary frames; the relay appends them here per participant and
* finalizes on leave/disconnect. Implement this to store to S3, a DB blob, etc.
*/
interface RecordingStore
{
/** Append a chunk of a participant's audio for a call. */
public function append(string $roomId, string $peerId, string $chunk): void;
/**
* Finish a participant's recording (they left / the call ended).
*
* @return string|null a locator (path/URL) for the finished recording, or null
*/
public function finalize(string $roomId, string $peerId): ?string;
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Contracts;
/**
* Authorizes a join to a `private-*` or `presence-*` room. The host app binds its
* own implementation (checking a signed token, the authenticated user, room
* membership, ...). Public + open-presence rooms never reach the authorizer.
*/
interface RoomAuthorizer
{
/**
* @param array<string,mixed> $auth client-supplied auth payload (token, etc.)
* @return array<string,mixed>|null presence info to publish if allowed; null denies the join
*/
public function authorize(string $peerId, string $roomId, array $auth): ?array;
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Events;
use Blax\WebRtc\Contracts\CallEventListener;
/** Discards call events. The default when the host hasn't bound a listener. */
final class NullCallEventListener implements CallEventListener
{
public function handle(array $event): void {}
}

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Recording;
use Blax\WebRtc\Contracts\RecordingStore;
/**
* Stores each participant's streamed audio as an appendable file per call:
* `<directory>/<room>/<peer>.<ext>`. 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<string,resource> 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) ?? '_';
}
}

View File

@ -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<string,true> peer id => present */
private array $peers = [];
/** @var array<string,array<string,mixed>> 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<string,mixed> $info
* @return array<int,string>
*/
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<int,string> */
public function peers(): array
{
return array_keys($this->peers);
return array_keys($this->members);
}
/** @return array<string,array<string,mixed>> peer id => presence info */
public function members(): array
{
return $this->members;
}
/** @return array<string,mixed> */
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);
}
}

View File

@ -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<string,mixed> $info
* @return array<int,string>
*/
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;

44
src/Rooms/RoomType.php Normal file
View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Rooms;
/**
* Room types, mirroring the laravel-websockets channel model and derived from the
* room NAME prefix (so no extra config is needed):
*
* public anyone joins; no member list
* private-* join gated by a RoomAuthorizer (host-provided)
* presence-* private + the member list (with per-member info) is shared
* open-presence-* public presence: anyone joins, everyone sees the members
*/
enum RoomType: string
{
case Public = 'public';
case Private = 'private';
case Presence = 'presence';
case OpenPresence = 'open-presence';
public static function fromName(string $room): self
{
return match (true) {
str_starts_with($room, 'open-presence-') => 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;
}
}

View File

@ -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": "<yourId>" } (on connect)
* { "type": "join", "room": "lobby" }
* { "type": "joined", "room": "lobby", "peers": [...] } (existing peers)
* { "type": "peer-joined", "room": "lobby", "peer": "<id>" } (to the others)
* { "type": "relay", "to": "<peerId>", "data": { ...sdp/ice... } }
* { "type": "relay", "from": "<peerId>", "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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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<string,mixed> $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]);

View File

@ -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

View File

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Recording\FileRecordingStore;
use PHPUnit\Framework\TestCase;
final class FileRecordingStoreTest extends TestCase
{
private string $dir;
protected function setUp(): void
{
$this->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);
}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests\Fixtures;
use Blax\WebRtc\Contracts\RoomAuthorizer;
/** Test authorizer that allows every join, returning fixed presence info. */
final class AllowAuthorizer implements RoomAuthorizer
{
/** @param array<string,mixed> $info */
public function __construct(private array $info = ['ok' => true]) {}
public function authorize(string $peerId, string $roomId, array $auth): ?array
{
return $this->info;
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests\Fixtures;
use Blax\WebRtc\Contracts\CallEventListener;
final class CollectingEvents implements CallEventListener
{
/** @var array<int,array<string,mixed>> */
public array $events = [];
public function handle(array $event): void
{
$this->events[] = $event;
}
/** @return array<int,string> */
public function types(): array
{
return array_map(fn ($e) => (string) $e['type'], $this->events);
}
/** @return array<int,array<string,mixed>> */
public function ofType(string $type): array
{
return array_values(array_filter($this->events, fn ($e) => ($e['type'] ?? null) === $type));
}
}

View File

@ -12,6 +12,9 @@ final class FakePeer implements Sendable
/** @var array<int,string> */
public array $sent = [];
/** @var array<int,string> */
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;

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests\Fixtures;
use Blax\WebRtc\Contracts\RecordingStore;
final class MemoryRecordingStore implements RecordingStore
{
/** @var array<string,string> "room|peer" => concatenated bytes */
public array $data = [];
/** @var array<int,string> 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}";
}
}

View File

@ -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;

32
tests/RoomTypeTest.php Normal file
View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Rooms\RoomType;
use PHPUnit\Framework\TestCase;
final class RoomTypeTest extends TestCase
{
public function test_derives_the_type_from_the_room_name_prefix(): void
{
$this->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());
}
}