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:
parent
aca88c3dbc
commit
c75f9122d4
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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', []);
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue