feat: multi-party rooms + real socket round-trip test

- Rooms\Room + Rooms\RoomManager: track peers per room, auto-mesh a new joiner
  with everyone already there, drop empty rooms
- SignalingHandler: join/leave message types (auto-connectPeers on join), and
  close now also leaves the room; expose rooms()
- SignalingServer::address() for the bound listen address
- Tests: real offer->answer round-trip over a live TCP socket on the kernel loop,
  RoomManager + join/leave/close coverage. Suite: 17 tests / 42 assertions
- Verified on the updated reactphp-kernel (logging + registry)

rel learn-atc #1055 #1051 #1019

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-07 13:54:36 +02:00
parent ac5d89111e
commit 1479e36f49
9 changed files with 374 additions and 8 deletions

View File

@ -22,3 +22,10 @@ Blax Software's backward-compatibility guarantee.
handler. handler.
- `rust/` — the `blax_webrtc` crate scaffold (str0m + ext-php-rs) documenting the - `rust/` — the `blax_webrtc` crate scaffold (str0m + ext-php-rs) documenting the
media plan. media plan.
- `Rooms\Room` + `Rooms\RoomManager` and `join`/`leave` signaling for
party-to-party / group calls: a new joiner is auto-meshed with everyone already
in the room; `close` also leaves. Empty rooms are dropped.
- `SignalingServer::address()` — the bound listen address (for integration).
- A real socket round-trip test (offer→answer over a live TCP connection on the
kernel loop), plus RoomManager + join/leave coverage. Suite: 17 tests / 42
assertions.

View File

@ -5,8 +5,8 @@
[![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue?style=flat-square)](https://php.net) [![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) [![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) [![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-8%20passing-success?style=flat-square)](#testing) [![Tests](https://img.shields.io/badge/tests-17%20passing-success?style=flat-square)](#testing)
[![Assertions](https://img.shields.io/badge/assertions-18-blue?style=flat-square)](#testing) [![Assertions](https://img.shields.io/badge/assertions-42-blue?style=flat-square)](#testing)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE) [![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. PHP owns **signaling + orchestration**; a pluggable **media engine** owns the real-time media (ICE/DTLS/SRTP/RTP/Opus) — with the production engine backed by a **Rust core (`str0m`) via `ext-php-rs`**, so PHP never runs per-20ms DSP. WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) backbone. PHP owns **signaling + orchestration**; a pluggable **media engine** owns the real-time media (ICE/DTLS/SRTP/RTP/Opus) — with the production engine backed by a **Rust core (`str0m`) via `ext-php-rs`**, so PHP never runs per-20ms DSP.
@ -15,7 +15,7 @@ WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-sof
- 🎙️ **Record / intercept / log** — the engine terminates media server-side, so every participant's audio (human or AI) can be captured - 🎙️ **Record / intercept / log** — the engine terminates media server-side, so every participant's audio (human or AI) can be captured
- 🤖 **AI realtime, provider hidden** — bridge a caller to an external realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser only ever talks to *you*, and the model is a config swap - 🤖 **AI realtime, provider hidden** — bridge a caller to an external realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser only ever talks to *you*, and the model is a config swap
- 👥 **Party-to-party & group calls** — instructor↔student, controller↔pilot, guest↔admin are just peers the engine routes between - 👥 **Party-to-party & group calls**`join`/`leave` a room and the `RoomManager` meshes each new peer with everyone already there (instructor↔student, controller↔pilot, guest↔admin)
- 🧩 **On the shared kernel** — signaling runs on `reactphp-kernel` alongside your WebSockets, one process, one loop - 🧩 **On the shared kernel** — signaling runs on `reactphp-kernel` alongside your WebSockets, one process, one loop
- 🔌 **Pluggable `MediaEngine`**`NullMediaEngine` for signaling-only dev; `Str0mMediaEngine` for the real Rust-backed media - 🔌 **Pluggable `MediaEngine`**`NullMediaEngine` for signaling-only dev; `Str0mMediaEngine` for the real Rust-backed media
- 🦀 **Rust media core**`str0m` (sans-IO WebRTC) via `ext-php-rs`, shipped in `rust/` - 🦀 **Rust media core**`str0m` (sans-IO WebRTC) via `ext-php-rs`, shipped in `rust/`
@ -50,9 +50,11 @@ WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." } { "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply) { "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
{ "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... } { "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... }
{ "type": "connect", "peer": "p1", "other": "p2" } // party-to-party { "type": "connect", "peer": "p1", "other": "p2" } // low-level: mesh two peers
{ "type": "join", "peer": "p1", "room": "lobby" } // -> { "type": "joined", "peers": [...] } (auto-meshes)
{ "type": "leave", "peer": "p1" } // -> { "type": "left", "peers": [...] }
{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model { "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
{ "type": "close", "peer": "p1" } { "type": "close", "peer": "p1" } // closes media + leaves the room
``` ```
`SignalingHandler` is transport-agnostic — feed it frames from the built-in raw `SignalingServer` today, or from the `laravel-websockets` WS transport (for browsers) later, without changing the media wiring. `SignalingHandler` is transport-agnostic — feed it frames from the built-in raw `SignalingServer` today, or from the `laravel-websockets` WS transport (for browsers) later, without changing the media wiring.

57
src/Rooms/Room.php Normal file
View File

@ -0,0 +1,57 @@
<?php
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.
*/
final class Room
{
/** @var array<string,true> peer id => present */
private array $peers = [];
public function __construct(public readonly string $id) {}
/**
* Add a peer and return the OTHER peers already present the set the caller
* must mesh the new peer with (engine->connectPeers).
*
* @return array<int,string>
*/
public function add(string $peerId): array
{
$others = array_keys($this->peers);
$this->peers[$peerId] = true;
return $others;
}
public function remove(string $peerId): void
{
unset($this->peers[$peerId]);
}
public function has(string $peerId): bool
{
return isset($this->peers[$peerId]);
}
/** @return array<int,string> */
public function peers(): array
{
return array_keys($this->peers);
}
public function isEmpty(): bool
{
return $this->peers === [];
}
public function count(): int
{
return count($this->peers);
}
}

86
src/Rooms/RoomManager.php Normal file
View File

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Rooms;
/**
* Tracks which peers are in which room for party-to-party / group calls, and
* answers the mesh question the SignalingHandler needs: "who is a new joiner
* already in a call with?" Empty rooms are dropped automatically.
*/
final class RoomManager
{
/** @var array<string,Room> roomId => Room */
private array $rooms = [];
/** @var array<string,string> peerId => roomId */
private array $peerRoom = [];
/**
* Join $peerId to $roomId, returning the OTHER peers already present so the
* caller can mesh them.
*
* @return array<int,string>
*/
public function join(string $roomId, string $peerId): 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);
$this->peerRoom[$peerId] = $roomId;
return $others;
}
/**
* Remove $peerId from its room, returning the peers that REMAIN there (so the
* caller can notify them). Drops the room when it empties.
*
* @return array<int,string>
*/
public function leave(string $peerId): array
{
$roomId = $this->peerRoom[$peerId] ?? null;
if ($roomId === null || ! isset($this->rooms[$roomId])) {
return [];
}
$room = $this->rooms[$roomId];
$room->remove($peerId);
unset($this->peerRoom[$peerId]);
if ($room->isEmpty()) {
unset($this->rooms[$roomId]);
return [];
}
return $room->peers();
}
public function roomOf(string $peerId): ?string
{
return $this->peerRoom[$peerId] ?? null;
}
public function room(string $roomId): ?Room
{
return $this->rooms[$roomId] ?? null;
}
/** @return array<int,string> */
public function peersIn(string $roomId): array
{
return isset($this->rooms[$roomId]) ? $this->rooms[$roomId]->peers() : [];
}
public function count(): int
{
return count($this->rooms);
}
}

View File

@ -40,6 +40,12 @@ final class SignalingServer implements Server
return 'webrtc-signaling'; return 'webrtc-signaling';
} }
/** The bound listen address (e.g. "tcp://127.0.0.1:54321"), or null before boot. */
public function address(): ?string
{
return $this->socket?->getAddress();
}
public function boot(LoopInterface $loop): void public function boot(LoopInterface $loop): void
{ {
$this->socket = SocketServerFactory::create("{$this->host}:{$this->port}", $loop, $this->tls); $this->socket = SocketServerFactory::create("{$this->host}:{$this->port}", $loop, $this->tls);

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Blax\WebRtc\Signaling; namespace Blax\WebRtc\Signaling;
use Blax\WebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
use Blax\WebRtc\Rooms\RoomManager;
/** /**
* Transport-agnostic signaling router: decodes a signaling message and drives * Transport-agnostic signaling router: decodes a signaling message and drives
@ -15,11 +16,22 @@ use Blax\WebRtc\Contracts\MediaEngine;
* the browser-facing transport (this package's raw SignalingServer today, or the * the browser-facing transport (this package's raw SignalingServer today, or the
* laravel-websockets WS layer later) is a swappable detail. * laravel-websockets WS layer later) is a swappable detail.
* *
* Wire shape (JSON): { "type": "offer|ice|record|connect|bridge|close", "peer": "...", ... } * Wire shape (JSON): { "type": "offer|ice|record|connect|bridge|close|join|leave", "peer": "...", ... }
*/ */
final class SignalingHandler final class SignalingHandler
{ {
public function __construct(private MediaEngine $engine) {} private RoomManager $rooms;
public function __construct(private MediaEngine $engine, ?RoomManager $rooms = null)
{
$this->rooms = $rooms ?? new RoomManager;
}
/** The shared room registry (party-to-party / group calls). */
public function rooms(): RoomManager
{
return $this->rooms;
}
/** /**
* @param array<string,mixed> $message * @param array<string,mixed> $message
@ -45,7 +57,9 @@ final class SignalingHandler
'record' => $this->ack($peer, fn () => $this->engine->startRecording($peer, (string) ($message['path'] ?? ''))), 'record' => $this->ack($peer, fn () => $this->engine->startRecording($peer, (string) ($message['path'] ?? ''))),
'connect' => $this->ack($peer, fn () => $this->engine->connectPeers($peer, (string) ($message['other'] ?? ''))), 'connect' => $this->ack($peer, fn () => $this->engine->connectPeers($peer, (string) ($message['other'] ?? ''))),
'bridge' => $this->ack($peer, fn () => $this->engine->bridge($peer, (array) ($message['options'] ?? []))), 'bridge' => $this->ack($peer, fn () => $this->engine->bridge($peer, (array) ($message['options'] ?? []))),
'close' => $this->ack($peer, fn () => $this->engine->close($peer)), 'close' => $this->close($peer),
'join' => $this->join($peer, (string) ($message['room'] ?? '')),
'leave' => $this->leave($peer),
default => ['type' => 'error', 'error' => "unknown type: {$type}"], default => ['type' => 'error', 'error' => "unknown type: {$type}"],
}; };
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -68,4 +82,44 @@ final class SignalingHandler
return ['type' => 'ack', 'peer' => $peer]; return ['type' => 'ack', 'peer' => $peer];
} }
/** Tear a peer down: drop it from its room, then close its media. */
private function close(string $peer): array
{
$this->rooms->leave($peer);
$this->engine->close($peer);
return ['type' => 'ack', 'peer' => $peer];
}
/**
* Join a room and mesh with everyone already there.
*
* @return array<string,mixed>
*/
private function join(string $peer, string $roomId): array
{
if ($roomId === '') {
return ['type' => 'error', 'peer' => $peer, 'error' => 'missing room'];
}
$others = $this->rooms->join($roomId, $peer);
foreach ($others as $other) {
$this->engine->connectPeers($peer, $other);
}
return ['type' => 'joined', 'peer' => $peer, 'room' => $roomId, 'peers' => $others];
}
/**
* Leave the current room; the reply lists the peers that remain.
*
* @return array<string,mixed>
*/
private function leave(string $peer): array
{
$remaining = $this->rooms->leave($peer);
return ['type' => 'left', 'peer' => $peer, 'peers' => $remaining];
}
} }

52
tests/RoomManagerTest.php Normal file
View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Rooms\RoomManager;
use PHPUnit\Framework\TestCase;
final class RoomManagerTest extends TestCase
{
public function test_first_joiner_meshes_with_nobody(): void
{
$rooms = new RoomManager;
$this->assertSame([], $rooms->join('lobby', 'p1'));
$this->assertSame(['p1'], $rooms->peersIn('lobby'));
$this->assertSame('lobby', $rooms->roomOf('p1'));
}
public function test_second_joiner_meshes_with_the_first(): void
{
$rooms = new RoomManager;
$rooms->join('lobby', 'p1');
$this->assertSame(['p1'], $rooms->join('lobby', 'p2'));
$this->assertSame(['p1', 'p2'], $rooms->peersIn('lobby'));
}
public function test_leaving_returns_remaining_and_drops_empty_rooms(): void
{
$rooms = new RoomManager;
$rooms->join('lobby', 'p1');
$rooms->join('lobby', 'p2');
$this->assertSame(['p1'], $rooms->leave('p2'));
$this->assertSame([], $rooms->leave('p1')); // room now empty
$this->assertSame(0, $rooms->count());
$this->assertNull($rooms->room('lobby'));
}
public function test_joining_a_new_room_leaves_the_old(): void
{
$rooms = new RoomManager;
$rooms->join('a', 'p1');
$rooms->join('b', 'p1');
$this->assertSame([], $rooms->peersIn('a')); // 'a' emptied + dropped
$this->assertSame(['p1'], $rooms->peersIn('b'));
$this->assertSame('b', $rooms->roomOf('p1'));
}
}

View File

@ -76,4 +76,51 @@ final class SignalingHandlerTest extends TestCase
$this->assertSame(['type' => 'error', 'peer' => 'p1', 'error' => 'boom'], $result); $this->assertSame(['type' => 'error', 'peer' => 'p1', 'error' => 'boom'], $result);
} }
public function test_join_meshes_a_new_peer_with_those_already_present(): void
{
$engine = new FakeMediaEngine;
$handler = new SignalingHandler($engine);
$this->assertSame(
['type' => 'joined', 'peer' => 'p1', 'room' => 'lobby', 'peers' => []],
$handler->handle(['type' => 'join', 'peer' => 'p1', 'room' => 'lobby']),
);
$this->assertSame(
['type' => 'joined', 'peer' => 'p2', 'room' => 'lobby', 'peers' => ['p1']],
$handler->handle(['type' => 'join', 'peer' => 'p2', 'room' => 'lobby']),
);
$this->assertContains(['connect', 'p2', 'p1'], $engine->calls);
}
public function test_join_without_a_room_is_an_error(): void
{
$result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'join', 'peer' => 'p1']);
$this->assertSame('error', $result['type']);
}
public function test_leave_reports_the_remaining_peers(): void
{
$handler = new SignalingHandler(new FakeMediaEngine);
$handler->handle(['type' => 'join', 'peer' => 'p1', 'room' => 'lobby']);
$handler->handle(['type' => 'join', 'peer' => 'p2', 'room' => 'lobby']);
$this->assertSame(
['type' => 'left', 'peer' => 'p2', 'peers' => ['p1']],
$handler->handle(['type' => 'leave', 'peer' => 'p2']),
);
}
public function test_close_also_removes_the_peer_from_its_room(): void
{
$engine = new FakeMediaEngine;
$handler = new SignalingHandler($engine);
$handler->handle(['type' => 'join', 'peer' => 'p1', 'room' => 'lobby']);
$this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'close', 'peer' => 'p1']));
$this->assertNull($handler->rooms()->roomOf('p1'));
$this->assertContains(['close', 'p1'], $engine->calls);
}
} }

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Server\SignalingServer;
use Blax\WebRtc\Signaling\SignalingHandler;
use Blax\WebRtc\Tests\Fixtures\FakeMediaEngine;
use PHPUnit\Framework\TestCase;
use React\EventLoop\StreamSelectLoop;
use React\Socket\ConnectionInterface;
use React\Socket\Connector;
final class SignalingServerTest extends TestCase
{
public function test_it_answers_an_offer_over_a_real_socket_on_the_kernel_loop(): void
{
$loop = new StreamSelectLoop;
$engine = new FakeMediaEngine;
$server = new SignalingServer('127.0.0.1', 0, new SignalingHandler($engine));
$server->boot($loop);
$address = $server->address();
$this->assertNotNull($address, 'server should be bound after boot');
$port = (int) parse_url($address, PHP_URL_PORT);
$this->assertGreaterThan(0, $port);
$received = null;
(new Connector([], $loop))
->connect("127.0.0.1:{$port}")
->then(function (ConnectionInterface $conn) use (&$received, $loop) {
$buffer = '';
$conn->on('data', function ($chunk) use (&$buffer, &$received, $conn, $loop) {
$buffer .= $chunk;
if (($pos = strpos($buffer, "\n")) !== false) {
$received = json_decode(substr($buffer, 0, $pos), true);
$conn->close();
$loop->stop();
}
});
$conn->write(json_encode(['type' => 'offer', 'peer' => 'p1', 'sdp' => 'OFFER-SDP'])."\n");
});
// Safety net so a broken server can't hang the suite.
$loop->addTimer(2.0, fn () => $loop->stop());
$loop->run();
$server->shutdown();
$this->assertSame(['type' => 'answer', 'peer' => 'p1', 'sdp' => 'answer-for-p1'], $received);
$this->assertSame(['offer', 'p1', 'OFFER-SDP'], $engine->calls[0]);
}
}