feat: WebRTC MVP to Blax standard (namespace, README, tests, scaffolding)
- Rename namespace BlaxSoftware\LaravelWebRtc -> Blax\WebRtc (house standard) + update kernel imports to Blax\ReactPhpKernel - README rewritten to the Blax OSS package principles: OSS banner, title+badges, emoji feature list, quick start, wire shape, config, architecture, star history - Add PHPUnit suite (8 tests / 18 assertions, green): SignalingHandler routing (offer/ice/record/connect/bridge/close/error paths via a fake engine) + NullMediaEngine behaviour - Add pint.json (laravel preset, applied), composer scripts, .gitattributes, test.sh (nix), CHANGELOG - Verified end-to-end: composer install resolves blax-software/reactphp-kernel from Forgejo VCS and the suite runs green on it rel learn-atc #1055 #1051 #1019 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4d79aee2cc
commit
ac5d89111e
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
113
README.md
113
README.md
|
|
@ -1,59 +1,50 @@
|
|||
# blax-software/laravel-webrtc
|
||||
[](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/)
|
||||
```
|
||||
[](https://php.net)
|
||||
[](https://laravel.com)
|
||||
[](https://github.com/blax-software/reactphp-kernel)
|
||||
[](#testing)
|
||||
[](#testing)
|
||||
[](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": "..." }
|
||||
|
|
@ -64,10 +55,44 @@ calls.
|
|||
{ "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
|
||||
|
||||
<a href="https://www.star-history.com/?repos=blax-software%2Flaravel-webrtc&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-webrtc&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-webrtc&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/laravel-webrtc&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
|
||||
use Blax\WebRtc\Media\NullMediaEngine;
|
||||
|
||||
return [
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false"
|
||||
cacheDirectory=".phpunit.cache"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Laravel WebRTC Test Suite">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">./src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BlaxSoftware\LaravelWebRtc\Contracts;
|
||||
namespace Blax\WebRtc\Contracts;
|
||||
|
||||
/**
|
||||
* The media backend for a WebRTC call.
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string,mixed> $message
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\WebRtc\Tests\Fixtures;
|
||||
|
||||
use Blax\WebRtc\Contracts\MediaEngine;
|
||||
|
||||
/**
|
||||
* A recording media-engine double: captures every call so the SignalingHandler
|
||||
* routing can be asserted, returns a deterministic answer, and can be told to
|
||||
* throw so the handler's error path is exercised.
|
||||
*/
|
||||
final class FakeMediaEngine implements MediaEngine
|
||||
{
|
||||
/** @var array<int,array<int,mixed>> */
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\WebRtc\Tests;
|
||||
|
||||
use Blax\WebRtc\Media\NullMediaEngine;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NullMediaEngineTest extends TestCase
|
||||
{
|
||||
public function test_it_refuses_to_terminate_media(): void
|
||||
{
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Blax\WebRtc\Tests;
|
||||
|
||||
use Blax\WebRtc\Signaling\SignalingHandler;
|
||||
use Blax\WebRtc\Tests\Fixtures\FakeMediaEngine;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SignalingHandlerTest extends TestCase
|
||||
{
|
||||
public function test_offer_returns_the_engine_answer(): void
|
||||
{
|
||||
$engine = new FakeMediaEngine;
|
||||
$result = (new SignalingHandler($engine))->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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue