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.
|
||||||
123
README.md
123
README.md
|
|
@ -1,73 +1,98 @@
|
||||||
# blax-software/laravel-webrtc
|
[](https://github.com/blax-software)
|
||||||
|
|
||||||
WebRTC for Laravel, built on the shared
|
# Laravel WebRTC
|
||||||
[`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.
|
|
||||||
|
|
||||||
```
|
[](https://php.net)
|
||||||
reactphp-kernel shared backbone (loop, IPC, signals)
|
[](https://laravel.com)
|
||||||
└─ laravel-webrtc (this) signaling on the kernel
|
[](https://github.com/blax-software/reactphp-kernel)
|
||||||
├─ NullMediaEngine signaling-only (dev/tests)
|
[](#testing)
|
||||||
└─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/)
|
[](#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).
|
- 🎙️ **Record / intercept / log** — the engine terminates media server-side, so every participant's audio (human or AI) can be captured
|
||||||
- **AI realtime, provider hidden** — the engine bridges a caller to an external
|
- 🤖 **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
|
||||||
realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser
|
- 👥 **Party-to-party & group calls** — instructor↔student, controller↔pilot, guest↔admin are just peers the engine routes between
|
||||||
only ever talks to *us*, and the model is a server-side config swap.
|
- 🧩 **On the shared kernel** — signaling runs on `reactphp-kernel` alongside your WebSockets, one process, one loop
|
||||||
- **Party-to-party & group calls** — instructor↔student, controller↔pilot,
|
- 🔌 **Pluggable `MediaEngine`** — `NullMediaEngine` for signaling-only dev; `Str0mMediaEngine` for the real Rust-backed media
|
||||||
guest↔admin: participants are just peers the engine routes between.
|
- 🦀 **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
|
## Installation
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
composer require blax-software/laravel-webrtc:dev-master
|
composer require blax-software/laravel-webrtc
|
||||||
php artisan vendor:publish --tag=webrtc-config
|
php artisan vendor:publish --tag="webrtc-config"
|
||||||
```
|
```
|
||||||
|
|
||||||
It depends on `blax-software/reactphp-kernel` (resolved from git.blax.at via the
|
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`.
|
||||||
`repositories` entry in `composer.json`).
|
|
||||||
|
|
||||||
## Run
|
## Quick Start
|
||||||
|
|
||||||
|
Run the signaling server (on the shared kernel):
|
||||||
|
|
||||||
```bash
|
```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
|
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:
|
||||||
`offer()` loudly. Build the Rust core and set
|
|
||||||
`WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` for real
|
|
||||||
calls.
|
|
||||||
|
|
||||||
## Signaling wire shape (JSON, newline-delimited)
|
```dotenv
|
||||||
|
WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Signaling wire shape (newline-delimited JSON)
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
|
{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
|
||||||
{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
|
{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
|
||||||
{ "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... }
|
{ "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... }
|
||||||
{ "type": "connect", "peer": "p1", "other": "p2" } // party-to-party
|
{ "type": "connect", "peer": "p1", "other": "p2" } // party-to-party
|
||||||
{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
|
{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
|
||||||
{ "type": "close", "peer": "p1" }
|
{ "type": "close", "peer": "p1" }
|
||||||
```
|
```
|
||||||
|
|
||||||
`SignalingHandler` is transport-agnostic — feed it frames from this package's raw
|
`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.
|
||||||
`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
|
## 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": {
|
"require": {
|
||||||
"php": ">=8.1",
|
"php": "^8.1|^8.2|^8.3|^8.4",
|
||||||
"blax-software/reactphp-kernel": "dev-master",
|
"blax-software/reactphp-kernel": "dev-master",
|
||||||
"react/event-loop": "^1.5",
|
"react/event-loop": "^1.5",
|
||||||
"react/socket": "^1.15",
|
"react/socket": "^1.15",
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
"illuminate/console": "^10.0|^11.0|^12.0"
|
"illuminate/console": "^10.0|^11.0|^12.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^10.5"
|
"phpunit/phpunit": "^10.5|^11.0",
|
||||||
|
"laravel/pint": "^1.13"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).",
|
"ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).",
|
||||||
|
|
@ -33,18 +34,23 @@
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"BlaxSoftware\\LaravelWebRtc\\": "src/"
|
"Blax\\WebRtc\\": "src/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
"autoload-dev": {
|
||||||
"psr-4": {
|
"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": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"providers": [
|
"providers": [
|
||||||
"BlaxSoftware\\LaravelWebRtc\\WebRtcServiceProvider"
|
"Blax\\WebRtc\\WebRtcServiceProvider"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
|
use Blax\WebRtc\Media\NullMediaEngine;
|
||||||
|
|
||||||
return [
|
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace BlaxSoftware\LaravelWebRtc\Console\Commands;
|
namespace Blax\WebRtc\Console\Commands;
|
||||||
|
|
||||||
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
use Blax\ReactPhpKernel\Kernel;
|
||||||
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
|
use Blax\WebRtc\Contracts\MediaEngine;
|
||||||
use BlaxSoftware\LaravelWebRtc\Server\SignalingServer;
|
use Blax\WebRtc\Media\NullMediaEngine;
|
||||||
use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler;
|
use Blax\WebRtc\Server\SignalingServer;
|
||||||
use BlaxSoftware\ReactPhpKernel\Kernel;
|
use Blax\WebRtc\Signaling\SignalingHandler;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class StartWebRtcServer extends Command
|
class StartWebRtcServer extends Command
|
||||||
|
|
@ -24,12 +24,12 @@ class StartWebRtcServer extends Command
|
||||||
|
|
||||||
$engine = app(config('webrtc.media_engine', NullMediaEngine::class));
|
$engine = app(config('webrtc.media_engine', NullMediaEngine::class));
|
||||||
if (! $engine instanceof MediaEngine) {
|
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;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
$kernel = (new Kernel())->reapChildren();
|
$kernel = (new Kernel)->reapChildren();
|
||||||
$kernel->register(new SignalingServer(
|
$kernel->register(new SignalingServer(
|
||||||
$host,
|
$host,
|
||||||
$port,
|
$port,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace BlaxSoftware\LaravelWebRtc\Contracts;
|
namespace Blax\WebRtc\Contracts;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The media backend for a WebRTC call.
|
* 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 $peerId caller-assigned id, unique within a room/call
|
||||||
* @param string $sdpOffer the browser's offer SDP
|
* @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;
|
public function offer(string $peerId, string $sdpOffer): string;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
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
|
* 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
|
public function offer(string $peerId, string $sdpOffer): string
|
||||||
{
|
{
|
||||||
throw new \RuntimeException(
|
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.'
|
'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
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.
|
* Production media engine backed by a RUST core.
|
||||||
|
|
@ -26,8 +26,8 @@ final class Str0mMediaEngine implements MediaEngine
|
||||||
{
|
{
|
||||||
if (! \extension_loaded('blax_webrtc')) {
|
if (! \extension_loaded('blax_webrtc')) {
|
||||||
throw new \RuntimeException(
|
throw new \RuntimeException(
|
||||||
'The blax_webrtc extension (Rust/str0m media core) is not loaded. ' .
|
'The blax_webrtc extension (Rust/str0m media core) is not loaded. '.
|
||||||
'Build it from this package\'s rust/ directory (see rust/README.md), ' .
|
'Build it from this package\'s rust/ directory (see rust/README.md), '.
|
||||||
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
|
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace BlaxSoftware\LaravelWebRtc\Server;
|
namespace Blax\WebRtc\Server;
|
||||||
|
|
||||||
use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler;
|
use Blax\ReactPhpKernel\Contracts\Server;
|
||||||
use BlaxSoftware\ReactPhpKernel\Contracts\Server;
|
use Blax\ReactPhpKernel\Server\SocketServerFactory;
|
||||||
use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory;
|
use Blax\WebRtc\Signaling\SignalingHandler;
|
||||||
use React\EventLoop\LoopInterface;
|
use React\EventLoop\LoopInterface;
|
||||||
use React\Socket\ConnectionInterface;
|
use React\Socket\ConnectionInterface;
|
||||||
use React\Socket\SocketServer;
|
use React\Socket\SocketServer;
|
||||||
|
|
@ -33,8 +33,7 @@ final class SignalingServer implements Server
|
||||||
private int $port,
|
private int $port,
|
||||||
private SignalingHandler $handler,
|
private SignalingHandler $handler,
|
||||||
private array $tls = [],
|
private array $tls = [],
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function name(): string
|
public function name(): string
|
||||||
{
|
{
|
||||||
|
|
@ -64,14 +63,14 @@ final class SignalingServer implements Server
|
||||||
|
|
||||||
$message = json_decode($line, true);
|
$message = json_decode($line, true);
|
||||||
if (! is_array($message)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->handler->handle($message);
|
$response = $this->handler->handle($message);
|
||||||
if ($response !== null) {
|
if ($response !== null) {
|
||||||
$conn->write(json_encode($response) . "\n");
|
$conn->write(json_encode($response)."\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
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
|
* Transport-agnostic signaling router: decodes a signaling message and drives
|
||||||
|
|
@ -19,9 +19,7 @@ use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
||||||
*/
|
*/
|
||||||
final class SignalingHandler
|
final class SignalingHandler
|
||||||
{
|
{
|
||||||
public function __construct(private MediaEngine $engine)
|
public function __construct(private MediaEngine $engine) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string,mixed> $message
|
* @param array<string,mixed> $message
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,18 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace BlaxSoftware\LaravelWebRtc;
|
namespace Blax\WebRtc;
|
||||||
|
|
||||||
use BlaxSoftware\LaravelWebRtc\Console\Commands\StartWebRtcServer;
|
use Blax\WebRtc\Console\Commands\StartWebRtcServer;
|
||||||
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
|
use Blax\WebRtc\Contracts\MediaEngine;
|
||||||
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
|
use Blax\WebRtc\Media\NullMediaEngine;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class WebRtcServiceProvider extends ServiceProvider
|
class WebRtcServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
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.
|
// Resolve the configured media engine wherever a MediaEngine is type-hinted.
|
||||||
$this->app->bind(MediaEngine::class, function ($app) {
|
$this->app->bind(MediaEngine::class, function ($app) {
|
||||||
|
|
@ -27,7 +27,7 @@ class WebRtcServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
if ($this->app->runningInConsole()) {
|
if ($this->app->runningInConsole()) {
|
||||||
$this->publishes([
|
$this->publishes([
|
||||||
__DIR__ . '/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
|
__DIR__.'/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
|
||||||
], 'webrtc-config');
|
], 'webrtc-config');
|
||||||
|
|
||||||
$this->commands([
|
$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