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:
parent
ac5d89111e
commit
1479e36f49
|
|
@ -22,3 +22,10 @@ Blax Software's backward-compatibility guarantee.
|
|||
handler.
|
||||
- `rust/` — the `blax_webrtc` crate scaffold (str0m + ext-php-rs) documenting the
|
||||
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.
|
||||
|
|
|
|||
12
README.md
12
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. 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
|
||||
- 🤖 **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
|
||||
- 🔌 **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/`
|
||||
|
|
@ -50,9 +50,11 @@ WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
|
|||
{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
|
||||
{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
|
||||
{ "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": "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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,12 @@ final class SignalingServer implements Server
|
|||
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
|
||||
{
|
||||
$this->socket = SocketServerFactory::create("{$this->host}:{$this->port}", $loop, $this->tls);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Blax\WebRtc\Signaling;
|
||||
|
||||
use Blax\WebRtc\Contracts\MediaEngine;
|
||||
use Blax\WebRtc\Rooms\RoomManager;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
{
|
||||
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
|
||||
|
|
@ -45,7 +57,9 @@ final class SignalingHandler
|
|||
'record' => $this->ack($peer, fn () => $this->engine->startRecording($peer, (string) ($message['path'] ?? ''))),
|
||||
'connect' => $this->ack($peer, fn () => $this->engine->connectPeers($peer, (string) ($message['other'] ?? ''))),
|
||||
'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}"],
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -68,4 +82,44 @@ final class SignalingHandler
|
|||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -76,4 +76,51 @@ final class SignalingHandlerTest extends TestCase
|
|||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue