diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b8d1cdd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/pint.json export-ignore +/test.sh export-ignore +/tests export-ignore +/.github export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a8243d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to `blax-software/laravel-webrtc` are documented here. This +project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +Blax Software's backward-compatibility guarantee. + +## [Unreleased] + +### Added + +- `WebRtcServiceProvider` + `config/webrtc.php` + the `webrtc:serve` command + (boots signaling on the shared `blax-software/reactphp-kernel`). +- `Contracts\MediaEngine` — the pluggable media backend + (`offer`/`addIceCandidate`/`startRecording`/`stopRecording`/`connectPeers`/`bridge`/`close`). +- `Media\NullMediaEngine` — signaling-only engine for dev/tests. +- `Media\Str0mMediaEngine` — seam for the Rust media core (str0m via ext-php-rs); + throws until the `blax_webrtc` extension is built (see `rust/`). +- `Signaling\SignalingHandler` — transport-agnostic router from a signaling + message to the media engine. +- `Server\SignalingServer` — a minimal kernel-attached (newline-JSON) signaling + transport; browsers ride the `laravel-websockets` WS transport into the same + handler. +- `rust/` — the `blax_webrtc` crate scaffold (str0m + ext-php-rs) documenting the + media plan. diff --git a/README.md b/README.md index ce66501..045e2dc 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,98 @@ -# blax-software/laravel-webrtc +[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) -WebRTC for Laravel, built on the shared -[`blax-software/reactphp-kernel`](https://git.blax.at/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. +# Laravel WebRTC -``` -reactphp-kernel shared backbone (loop, IPC, signals) - └─ laravel-webrtc (this) signaling on the kernel - ├─ NullMediaEngine signaling-only (dev/tests) - └─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/) -``` +[![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) +[![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) +[![Assertions](https://img.shields.io/badge/assertions-18-blue?style=flat-square)](#testing) +[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE) -## What it's for +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. -One media path that terminates server-side unlocks all of: +## Features -- **Record / intercept / log** every participant's audio (human or AI). -- **AI realtime, provider hidden** — the engine bridges a caller to an external - realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser - only ever talks to *us*, and the model is a server-side config swap. -- **Party-to-party & group calls** — instructor↔student, controller↔pilot, - guest↔admin: participants are just peers the engine routes between. +- 🎙️ **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 +- 🧩 **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/` +- 🎛️ **Transport-agnostic signaling** — route browser frames from this package's raw server or from `laravel-websockets` into the same handler -## Status - -**Scaffold.** Signaling + the engine abstraction are real and runnable with -`NullMediaEngine`; the `Str0mMediaEngine` + `rust/` core are the seam to build -next (see `rust/README.md`). This package is the home for the WebRTC half of the -blax realtime stack (rel learn-atc #1051/#1019). - -## Install +## Installation ```bash -composer require blax-software/laravel-webrtc:dev-master -php artisan vendor:publish --tag=webrtc-config +composer require blax-software/laravel-webrtc +php artisan vendor:publish --tag="webrtc-config" ``` -It depends on `blax-software/reactphp-kernel` (resolved from git.blax.at via the -`repositories` entry in `composer.json`). +It depends on [`blax-software/reactphp-kernel`](https://github.com/blax-software/reactphp-kernel), resolved from git.blax.at via the `repositories` entry in `composer.json`. -## Run +## Quick Start + +Run the signaling server (on the shared kernel): ```bash -php artisan webrtc:serve # signaling on config(webrtc.host:port) +php artisan webrtc:serve ``` -With `NullMediaEngine` (default) signaling runs and the media engine refuses -`offer()` loudly. Build the Rust core and set -`WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` for real -calls. +With the default `NullMediaEngine`, signaling runs and the engine refuses `offer()` loudly. Build the Rust core (see [`rust/README.md`](rust/README.md)) and switch the engine for real calls: -## Signaling wire shape (JSON, newline-delimited) +```dotenv +WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine" +``` + +### Signaling wire shape (newline-delimited JSON) ```jsonc -{ "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": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model +{ "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": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model { "type": "close", "peer": "p1" } ``` -`SignalingHandler` is transport-agnostic — feed it frames from this package's 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. + +## 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. + +## Status + +**MVP scaffold.** Signaling + the engine abstraction are real, tested, and runnable with `NullMediaEngine`; the `Str0mMediaEngine` + `rust/` core are the seam to build next. This is the WebRTC half of the blax realtime stack. + +## Architecture + +``` +reactphp-kernel shared backbone (loop, IPC, signals) + └─ laravel-webrtc (this) signaling on the kernel + ├─ NullMediaEngine signaling-only (dev/tests) + └─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/) +``` + +## Testing + +```bash +composer install +composer test +``` + +The signaling router and engines are unit-tested against a fake media engine — no browser, sockets, or external services required. ## License -MIT © Blax Software +MIT. See [LICENSE](LICENSE). + +## Star History + + + + + + Star History Chart + + diff --git a/composer.json b/composer.json index 2b69ce2..ea4ecc0 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": ">=8.1", + "php": "^8.1|^8.2|^8.3|^8.4", "blax-software/reactphp-kernel": "dev-master", "react/event-loop": "^1.5", "react/socket": "^1.15", @@ -19,7 +19,8 @@ "illuminate/console": "^10.0|^11.0|^12.0" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^10.5|^11.0", + "laravel/pint": "^1.13" }, "suggest": { "ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).", @@ -33,18 +34,23 @@ ], "autoload": { "psr-4": { - "BlaxSoftware\\LaravelWebRtc\\": "src/" + "Blax\\WebRtc\\": "src/" } }, "autoload-dev": { "psr-4": { - "BlaxSoftware\\LaravelWebRtc\\Tests\\": "tests/" + "Blax\\WebRtc\\Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit", + "lint": "vendor/bin/pint", + "lint-test": "vendor/bin/pint --test" + }, "extra": { "laravel": { "providers": [ - "BlaxSoftware\\LaravelWebRtc\\WebRtcServiceProvider" + "Blax\\WebRtc\\WebRtcServiceProvider" ] } }, diff --git a/config/webrtc.php b/config/webrtc.php index 9f83a3f..ee73397 100644 --- a/config/webrtc.php +++ b/config/webrtc.php @@ -1,6 +1,6 @@ + + + + tests + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..71e1065 --- /dev/null +++ b/pint.json @@ -0,0 +1,4 @@ +{ + "preset": "laravel", + "rules": {} +} diff --git a/src/Console/Commands/StartWebRtcServer.php b/src/Console/Commands/StartWebRtcServer.php index 9b58ee9..048a360 100644 --- a/src/Console/Commands/StartWebRtcServer.php +++ b/src/Console/Commands/StartWebRtcServer.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc\Console\Commands; +namespace Blax\WebRtc\Console\Commands; -use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; -use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine; -use BlaxSoftware\LaravelWebRtc\Server\SignalingServer; -use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler; -use BlaxSoftware\ReactPhpKernel\Kernel; +use Blax\ReactPhpKernel\Kernel; +use Blax\WebRtc\Contracts\MediaEngine; +use Blax\WebRtc\Media\NullMediaEngine; +use Blax\WebRtc\Server\SignalingServer; +use Blax\WebRtc\Signaling\SignalingHandler; use Illuminate\Console\Command; class StartWebRtcServer extends Command @@ -24,12 +24,12 @@ class StartWebRtcServer extends Command $engine = app(config('webrtc.media_engine', NullMediaEngine::class)); if (! $engine instanceof MediaEngine) { - $this->error('Configured webrtc.media_engine is not a ' . MediaEngine::class); + $this->error('Configured webrtc.media_engine is not a '.MediaEngine::class); return self::FAILURE; } - $kernel = (new Kernel())->reapChildren(); + $kernel = (new Kernel)->reapChildren(); $kernel->register(new SignalingServer( $host, $port, diff --git a/src/Contracts/MediaEngine.php b/src/Contracts/MediaEngine.php index 55f7a46..4c9aec8 100644 --- a/src/Contracts/MediaEngine.php +++ b/src/Contracts/MediaEngine.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc\Contracts; +namespace Blax\WebRtc\Contracts; /** * The media backend for a WebRTC call. @@ -31,7 +31,7 @@ interface MediaEngine * * @param string $peerId caller-assigned id, unique within a room/call * @param string $sdpOffer the browser's offer SDP - * @return string the answer SDP to send back through signaling + * @return string the answer SDP to send back through signaling */ public function offer(string $peerId, string $sdpOffer): string; diff --git a/src/Media/NullMediaEngine.php b/src/Media/NullMediaEngine.php index c0c1067..45b55b7 100644 --- a/src/Media/NullMediaEngine.php +++ b/src/Media/NullMediaEngine.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc\Media; +namespace Blax\WebRtc\Media; -use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; +use Blax\WebRtc\Contracts\MediaEngine; /** * A no-op media engine so the signaling stack is runnable and testable before @@ -22,7 +22,7 @@ final class NullMediaEngine implements MediaEngine public function offer(string $peerId, string $sdpOffer): string { throw new \RuntimeException( - 'NullMediaEngine cannot terminate media. Configure webrtc.media_engine ' . + 'NullMediaEngine cannot terminate media. Configure webrtc.media_engine '. 'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.' ); } diff --git a/src/Media/Str0mMediaEngine.php b/src/Media/Str0mMediaEngine.php index 88a8528..96fa84f 100644 --- a/src/Media/Str0mMediaEngine.php +++ b/src/Media/Str0mMediaEngine.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc\Media; +namespace Blax\WebRtc\Media; -use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; +use Blax\WebRtc\Contracts\MediaEngine; /** * Production media engine backed by a RUST core. @@ -26,8 +26,8 @@ final class Str0mMediaEngine implements MediaEngine { if (! \extension_loaded('blax_webrtc')) { throw new \RuntimeException( - 'The blax_webrtc extension (Rust/str0m media core) is not loaded. ' . - 'Build it from this package\'s rust/ directory (see rust/README.md), ' . + 'The blax_webrtc extension (Rust/str0m media core) is not loaded. '. + 'Build it from this package\'s rust/ directory (see rust/README.md), '. 'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.' ); } diff --git a/src/Server/SignalingServer.php b/src/Server/SignalingServer.php index e258c38..0e5417d 100644 --- a/src/Server/SignalingServer.php +++ b/src/Server/SignalingServer.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc\Server; +namespace Blax\WebRtc\Server; -use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler; -use BlaxSoftware\ReactPhpKernel\Contracts\Server; -use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory; +use Blax\ReactPhpKernel\Contracts\Server; +use Blax\ReactPhpKernel\Server\SocketServerFactory; +use Blax\WebRtc\Signaling\SignalingHandler; use React\EventLoop\LoopInterface; use React\Socket\ConnectionInterface; use React\Socket\SocketServer; @@ -33,8 +33,7 @@ final class SignalingServer implements Server private int $port, private SignalingHandler $handler, private array $tls = [], - ) { - } + ) {} public function name(): string { @@ -64,14 +63,14 @@ final class SignalingServer implements Server $message = json_decode($line, true); if (! is_array($message)) { - $conn->write(json_encode(['type' => 'error', 'error' => 'invalid json']) . "\n"); + $conn->write(json_encode(['type' => 'error', 'error' => 'invalid json'])."\n"); continue; } $response = $this->handler->handle($message); if ($response !== null) { - $conn->write(json_encode($response) . "\n"); + $conn->write(json_encode($response)."\n"); } } }); diff --git a/src/Signaling/SignalingHandler.php b/src/Signaling/SignalingHandler.php index 3351769..56831a0 100644 --- a/src/Signaling/SignalingHandler.php +++ b/src/Signaling/SignalingHandler.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc\Signaling; +namespace Blax\WebRtc\Signaling; -use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; +use Blax\WebRtc\Contracts\MediaEngine; /** * Transport-agnostic signaling router: decodes a signaling message and drives @@ -19,9 +19,7 @@ use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; */ final class SignalingHandler { - public function __construct(private MediaEngine $engine) - { - } + public function __construct(private MediaEngine $engine) {} /** * @param array $message diff --git a/src/WebRtcServiceProvider.php b/src/WebRtcServiceProvider.php index bc1ac7c..9a1d7f2 100644 --- a/src/WebRtcServiceProvider.php +++ b/src/WebRtcServiceProvider.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace BlaxSoftware\LaravelWebRtc; +namespace Blax\WebRtc; -use BlaxSoftware\LaravelWebRtc\Console\Commands\StartWebRtcServer; -use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; -use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine; +use Blax\WebRtc\Console\Commands\StartWebRtcServer; +use Blax\WebRtc\Contracts\MediaEngine; +use Blax\WebRtc\Media\NullMediaEngine; use Illuminate\Support\ServiceProvider; class WebRtcServiceProvider extends ServiceProvider { public function register(): void { - $this->mergeConfigFrom(__DIR__ . '/../config/webrtc.php', 'webrtc'); + $this->mergeConfigFrom(__DIR__.'/../config/webrtc.php', 'webrtc'); // Resolve the configured media engine wherever a MediaEngine is type-hinted. $this->app->bind(MediaEngine::class, function ($app) { @@ -27,7 +27,7 @@ class WebRtcServiceProvider extends ServiceProvider { if ($this->app->runningInConsole()) { $this->publishes([ - __DIR__ . '/../config/webrtc.php' => $this->app->configPath('webrtc.php'), + __DIR__.'/../config/webrtc.php' => $this->app->configPath('webrtc.php'), ], 'webrtc-config'); $this->commands([ diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..f6e6fae --- /dev/null +++ b/test.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env nix-shell +#!nix-shell -i bash -p php83 php83Extensions.sockets php83Extensions.mbstring php83Extensions.xml php83Extensions.xmlwriter php83Extensions.tokenizer + +# Test script for NixOS — runs PHPUnit for the WebRTC signaling + engine layer. + +echo "Running Laravel WebRTC tests..." +echo "PHP version: $(php --version | head -n 1)" +echo "" + +vendor/bin/phpunit "$@" diff --git a/tests/Fixtures/FakeMediaEngine.php b/tests/Fixtures/FakeMediaEngine.php new file mode 100644 index 0000000..8d48653 --- /dev/null +++ b/tests/Fixtures/FakeMediaEngine.php @@ -0,0 +1,66 @@ +> */ + public array $calls = []; + + public ?\Throwable $throwOnOffer = null; + + public function name(): string + { + return 'fake'; + } + + public function offer(string $peerId, string $sdpOffer): string + { + $this->calls[] = ['offer', $peerId, $sdpOffer]; + + if ($this->throwOnOffer !== null) { + throw $this->throwOnOffer; + } + + return "answer-for-{$peerId}"; + } + + public function addIceCandidate(string $peerId, array $candidate): void + { + $this->calls[] = ['ice', $peerId, $candidate]; + } + + public function startRecording(string $peerId, string $path): void + { + $this->calls[] = ['record', $peerId, $path]; + } + + public function stopRecording(string $peerId): void + { + $this->calls[] = ['stopRecording', $peerId]; + } + + public function connectPeers(string $peerA, string $peerB): void + { + $this->calls[] = ['connect', $peerA, $peerB]; + } + + public function bridge(string $peerId, array $options): void + { + $this->calls[] = ['bridge', $peerId, $options]; + } + + public function close(string $peerId): void + { + $this->calls[] = ['close', $peerId]; + } +} diff --git a/tests/NullMediaEngineTest.php b/tests/NullMediaEngineTest.php new file mode 100644 index 0000000..bb0373f --- /dev/null +++ b/tests/NullMediaEngineTest.php @@ -0,0 +1,32 @@ +expectException(\RuntimeException::class); + + (new NullMediaEngine)->offer('p1', 'sdp'); + } + + public function test_its_other_operations_are_harmless_no_ops(): void + { + $engine = new NullMediaEngine; + + $engine->addIceCandidate('p1', ['candidate' => 'x']); + $engine->startRecording('p1', '/tmp/x.opus'); + $engine->stopRecording('p1'); + $engine->connectPeers('p1', 'p2'); + $engine->bridge('p1', []); + $engine->close('p1'); + + $this->assertSame('null', $engine->name()); + } +} diff --git a/tests/SignalingHandlerTest.php b/tests/SignalingHandlerTest.php new file mode 100644 index 0000000..a36d87a --- /dev/null +++ b/tests/SignalingHandlerTest.php @@ -0,0 +1,79 @@ +handle([ + 'type' => 'offer', + 'peer' => 'p1', + 'sdp' => 'OFFER-SDP', + ]); + + $this->assertSame(['type' => 'answer', 'peer' => 'p1', 'sdp' => 'answer-for-p1'], $result); + $this->assertSame(['offer', 'p1', 'OFFER-SDP'], $engine->calls[0]); + } + + public function test_ice_is_forwarded_and_needs_no_reply(): void + { + $engine = new FakeMediaEngine; + $result = (new SignalingHandler($engine))->handle([ + 'type' => 'ice', + 'peer' => 'p1', + 'candidate' => ['candidate' => 'a=x'], + ]); + + $this->assertNull($result); + $this->assertSame(['ice', 'p1', ['candidate' => 'a=x']], $engine->calls[0]); + } + + public function test_record_connect_bridge_close_are_acked_and_routed(): void + { + $engine = new FakeMediaEngine; + $handler = new SignalingHandler($engine); + + $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'record', 'peer' => 'p1', 'path' => '/tmp/a.opus'])); + $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'connect', 'peer' => 'p1', 'other' => 'p2'])); + $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'bridge', 'peer' => 'p1', 'options' => ['model' => 'gpt-realtime']])); + $this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'close', 'peer' => 'p1'])); + + $this->assertSame(['record', 'p1', '/tmp/a.opus'], $engine->calls[0]); + $this->assertSame(['connect', 'p1', 'p2'], $engine->calls[1]); + $this->assertSame(['bridge', 'p1', ['model' => 'gpt-realtime']], $engine->calls[2]); + $this->assertSame(['close', 'p1'], $engine->calls[3]); + } + + public function test_a_missing_peer_is_an_error(): void + { + $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'offer']); + + $this->assertSame('error', $result['type']); + } + + public function test_an_unknown_type_is_an_error(): void + { + $result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'wat', 'peer' => 'p1']); + + $this->assertSame('error', $result['type']); + $this->assertStringContainsString('wat', $result['error']); + } + + public function test_an_engine_exception_is_caught_as_an_error_reply(): void + { + $engine = new FakeMediaEngine; + $engine->throwOnOffer = new \RuntimeException('boom'); + + $result = (new SignalingHandler($engine))->handle(['type' => 'offer', 'peer' => 'p1', 'sdp' => 'x']); + + $this->assertSame(['type' => 'error', 'peer' => 'p1', 'error' => 'boom'], $result); + } +}