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:
parent
028eaadaa8
commit
1be40ba59c
21
CHANGELOG.md
21
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 (`<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:
|
||||
|
|
|
|||
69
README.md
69
README.md
|
|
@ -5,8 +5,8 @@
|
|||
[](https://php.net)
|
||||
[](https://laravel.com)
|
||||
[](https://github.com/blax-software/reactphp-kernel)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](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": "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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -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) ?? '_';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver a message to a specific peer; false if they're not connected. */
|
||||
$this->emit(['type' => 'room-left', 'peer' => $peer, 'room' => $room]);
|
||||
}
|
||||
|
||||
/** @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]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue