feat: backend-defined rooms with per-room transport (RoomRegistry + RoomMode)

Rooms become entities the BACKEND defines — clients can only join a registered
room, and each room configures how its media is carried:

- Rooms\RoomMode: 'p2p' (browsers mesh; server relays signaling only) vs 'sfu'
  (server terminates media via the MediaEngine/Rust SFU).
- Rooms\RoomDefinition: id + mode + limit + RoomType (explicit or from the id
  prefix) + free-form meta for the host UI.
- Contracts\RoomRegistry + Rooms\ConfigRoomRegistry (reads config webrtc.rooms;
  supports shorthand 'room' => 'p2p'). The provider binding is host-overridable
  via config webrtc.room_registry so DYNAMIC (DB/Eloquent) registries plug in —
  that is how learn-atc sources rooms from its talk_rooms table at runtime.

5 new tests (66 total green).

Card #1087 (rel #1083 #1085).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-11 10:53:54 +02:00
parent aca88c3dbc
commit c75f9122d4
7 changed files with 290 additions and 0 deletions

View File

@ -91,6 +91,28 @@ return [
'format' => env('WEBRTC_RECORDING_FORMAT', 'webm'), 'format' => env('WEBRTC_RECORDING_FORMAT', 'webm'),
], ],
/*
|--------------------------------------------------------------------------
| Backend-defined rooms (the room registry)
|--------------------------------------------------------------------------
| The rooms clients are allowed to join. A join to anything NOT listed here is
| rejected clients can't invent rooms. Each room configures its transport:
| 'p2p' browsers mesh, the server only relays signaling (no server media)
| 'sfu' the server terminates media (the Rust SFU; enables recording/scale)
|
| Shapes (all equivalent granularity):
| 'just-talk' => 'p2p',
| 'lobby' => ['mode' => 'sfu', 'limit' => 50, 'type' => 'presence'],
| 'private-desk' => ['mode' => 'p2p'], // membership type inferred from prefix
|
| For DYNAMIC rooms (created/edited at runtime), point `room_registry` at your
| own Contracts\RoomRegistry (e.g. an Eloquent-backed one); the static `rooms`
| map below is the fallback used when no registry class is bound.
*/
'room_registry' => env('WEBRTC_ROOM_REGISTRY'),
'rooms' => [],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Room authorization + call-event log | Room authorization + call-event log

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Contracts;
use Blax\WebRtc\Rooms\ConfigRoomRegistry;
use Blax\WebRtc\Rooms\RoomDefinition;
use Blax\WebRtc\Rooms\RoomMode;
/**
* The set of rooms the backend has DEFINED. The signaling layer consults this to
* (a) reject joins to unknown rooms and (b) learn each room's transport
* {@see RoomMode} (p2p vs server-handled). Bind your own to source rooms from a
* database/model; {@see ConfigRoomRegistry} reads them from config.
*/
interface RoomRegistry
{
public function has(string $roomId): bool;
public function get(string $roomId): ?RoomDefinition;
/** The room's transport, or null when the room is not registered. */
public function mode(string $roomId): ?RoomMode;
/** @return array<string,RoomDefinition> keyed by room id */
public function all(): array;
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Rooms;
use Blax\WebRtc\Contracts\RoomRegistry;
/**
* A {@see RoomRegistry} backed by a declarative array (from `config('webrtc.rooms')`):
*
* 'lobby' => ['mode' => 'sfu', 'limit' => 50, 'type' => 'presence'],
* 'just-talk' => ['mode' => 'p2p', 'limit' => 20],
* 'private-desk' => ['mode' => 'p2p'], // type inferred from the id prefix
*
* A bare value is also accepted as shorthand for the mode: `'just-talk' => 'p2p'`.
*/
final class ConfigRoomRegistry implements RoomRegistry
{
/** @var array<string,RoomDefinition> */
private array $rooms;
/** @param array<string,mixed> $rooms */
public function __construct(array $rooms)
{
$this->rooms = [];
foreach ($rooms as $id => $config) {
$id = (string) $id;
$this->rooms[$id] = is_array($config)
? RoomDefinition::fromConfig($id, $config)
: new RoomDefinition($id, RoomMode::fromString(is_string($config) ? $config : null));
}
}
public function has(string $roomId): bool
{
return isset($this->rooms[$roomId]);
}
public function get(string $roomId): ?RoomDefinition
{
return $this->rooms[$roomId] ?? null;
}
public function mode(string $roomId): ?RoomMode
{
return $this->rooms[$roomId]?->mode;
}
public function all(): array
{
return $this->rooms;
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Rooms;
/**
* A BACKEND-DEFINED room: the app declares these (config / a model) so clients
* can only ever join a room that exists here arbitrary client-chosen room
* names are rejected. Carries the room's transport {@see RoomMode} and its
* membership {@see RoomType} (derived from the id prefix unless overridden).
*/
final class RoomDefinition
{
/** @param array<string,mixed> $meta free-form (label, description, …) for the app/UI */
public function __construct(
public readonly string $id,
public readonly RoomMode $mode = RoomMode::P2P,
public readonly ?int $limit = null,
public readonly ?RoomType $type = null,
public readonly array $meta = [],
) {}
public function roomType(): RoomType
{
return $this->type ?? RoomType::fromName($this->id);
}
/**
* Build from a declarative array (the config shape):
* 'lobby' => ['mode' => 'sfu', 'limit' => 50, 'type' => 'presence', 'label' => '…']
*
* @param array<string,mixed> $config
*/
public static function fromConfig(string $id, array $config): self
{
$known = ['mode', 'limit', 'type'];
$meta = array_diff_key($config, array_flip($known));
return new self(
id: $id,
mode: RoomMode::fromString($config['mode'] ?? null),
limit: isset($config['limit']) ? (int) $config['limit'] : null,
type: isset($config['type']) ? RoomType::tryFrom((string) $config['type']) : null,
meta: $meta,
);
}
/** @return array<string,mixed> */
public function toArray(): array
{
return [
'id' => $this->id,
'mode' => $this->mode->value,
'limit' => $this->limit,
'type' => $this->roomType()->value,
'meta' => $this->meta,
];
}
}

34
src/Rooms/RoomMode.php Normal file
View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Rooms;
use Blax\WebRtc\Contracts\RoomRegistry;
/**
* How a room's MEDIA is carried an orthogonal dimension to {@see RoomType}
* (which governs membership/auth). Configured per room in the {@see RoomRegistry}.
*
* p2p browsers connect directly (mesh); the server only RELAYS signaling
* (SDP/ICE). No server-side media, no recording of the media, E2E
* encrypted. Best for small rooms.
* sfu the server TERMINATES media (the Rust SFU MediaEngine ICE/DTLS/SRTP
* server-side, forwards without decoding). Enables server recording,
* moderation and large rooms. Requires the media engine to be available.
*/
enum RoomMode: string
{
case P2P = 'p2p';
case Sfu = 'sfu';
public function isServerHandled(): bool
{
return $this === self::Sfu;
}
public static function fromString(?string $value, self $default = self::P2P): self
{
return self::tryFrom((string) $value) ?? $default;
}
}

View File

@ -13,6 +13,7 @@ use Blax\WebRtc\Contracts\CallEventListener;
use Blax\WebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
use Blax\WebRtc\Contracts\RecordingStore; use Blax\WebRtc\Contracts\RecordingStore;
use Blax\WebRtc\Contracts\RoomAuthorizer; use Blax\WebRtc\Contracts\RoomAuthorizer;
use Blax\WebRtc\Contracts\RoomRegistry;
use Blax\WebRtc\Contracts\RoomStore; use Blax\WebRtc\Contracts\RoomStore;
use Blax\WebRtc\Events\NullCallEventListener; use Blax\WebRtc\Events\NullCallEventListener;
use Blax\WebRtc\Media\NullMediaEngine; use Blax\WebRtc\Media\NullMediaEngine;
@ -20,6 +21,7 @@ use Blax\WebRtc\Media\Rust\BinaryManager;
use Blax\WebRtc\Media\Rust\SidecarSupervisor; use Blax\WebRtc\Media\Rust\SidecarSupervisor;
use Blax\WebRtc\Realtime\OpenAiRealtimeBridge; use Blax\WebRtc\Realtime\OpenAiRealtimeBridge;
use Blax\WebRtc\Recording\FileRecordingStore; use Blax\WebRtc\Recording\FileRecordingStore;
use Blax\WebRtc\Rooms\ConfigRoomRegistry;
use Blax\WebRtc\Rooms\RoomManager; use Blax\WebRtc\Rooms\RoomManager;
use Blax\WebRtc\Rooms\Stores\ArrayRoomStore; use Blax\WebRtc\Rooms\Stores\ArrayRoomStore;
use Blax\Ws\WebSocketServer; use Blax\Ws\WebSocketServer;
@ -56,6 +58,18 @@ class WebRtcServiceProvider extends ServiceProvider
return new RoomManager($app->make(RoomStore::class)); return new RoomManager($app->make(RoomStore::class));
}); });
// The backend-defined room registry: which rooms exist and each room's
// transport (p2p vs server-handled). Defaults to the config-driven registry;
// a host binds its own (e.g. a DB/Eloquent-backed registry so rooms are
// created + edited at runtime) via `webrtc.room_registry`.
$this->app->bind(RoomRegistry::class, function ($app) {
$class = config('webrtc.room_registry');
return $class
? $app->make($class)
: new ConfigRoomRegistry((array) config('webrtc.rooms', []));
});
// Where a call's audio is stored (per participant). Host may override the class. // Where a call's audio is stored (per participant). Host may override the class.
$this->app->bind(RecordingStore::class, function () { $this->app->bind(RecordingStore::class, function () {
$recording = (array) config('webrtc.recording', []); $recording = (array) config('webrtc.recording', []);

View File

@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Rooms\ConfigRoomRegistry;
use Blax\WebRtc\Rooms\RoomMode;
use Blax\WebRtc\Rooms\RoomType;
use PHPUnit\Framework\TestCase;
final class RoomRegistryTest extends TestCase
{
private function registry(): ConfigRoomRegistry
{
return new ConfigRoomRegistry([
'just-talk' => 'p2p', // shorthand
'lobby' => ['mode' => 'sfu', 'limit' => 50, 'type' => 'presence'],
'private-desk' => ['mode' => 'p2p'], // type from prefix
'default-mode' => [], // defaults to p2p
]);
}
public function test_only_registered_rooms_exist(): void
{
$reg = $this->registry();
$this->assertTrue($reg->has('just-talk'));
$this->assertTrue($reg->has('lobby'));
$this->assertFalse($reg->has('anything-else'));
$this->assertNull($reg->get('anything-else'));
$this->assertNull($reg->mode('anything-else'));
}
public function test_mode_is_configured_per_room(): void
{
$reg = $this->registry();
$this->assertSame(RoomMode::P2P, $reg->mode('just-talk'));
$this->assertSame(RoomMode::Sfu, $reg->mode('lobby'));
$this->assertTrue($reg->get('lobby')->mode->isServerHandled());
$this->assertFalse($reg->get('just-talk')->mode->isServerHandled());
$this->assertSame(RoomMode::P2P, $reg->mode('default-mode'), 'missing mode defaults to p2p');
}
public function test_limit_and_type_resolution(): void
{
$reg = $this->registry();
$this->assertSame(50, $reg->get('lobby')->limit);
$this->assertNull($reg->get('just-talk')->limit);
// Explicit type wins…
$this->assertSame(RoomType::Presence, $reg->get('lobby')->roomType());
// …otherwise it is derived from the id prefix.
$this->assertSame(RoomType::Private, $reg->get('private-desk')->roomType());
$this->assertSame(RoomType::Public, $reg->get('just-talk')->roomType());
}
public function test_meta_carries_unknown_keys_for_the_app(): void
{
$reg = new ConfigRoomRegistry([
'lobby' => ['mode' => 'sfu', 'label' => 'Main lobby', 'icon' => 'fa-comments'],
]);
$this->assertSame(['label' => 'Main lobby', 'icon' => 'fa-comments'], $reg->get('lobby')->meta);
}
public function test_all_returns_every_definition_keyed_by_id(): void
{
$reg = $this->registry();
$this->assertSame(
['just-talk', 'lobby', 'private-desk', 'default-mode'],
array_keys($reg->all()),
);
}
}