feat: initial laravel-webrtc scaffold on the reactphp-kernel

WebRTC for Laravel on blax-software/reactphp-kernel. PHP owns signaling +
orchestration; a pluggable MediaEngine owns real-time media, with the production
engine backed by a Rust core (str0m via ext-php-rs) so PHP never runs per-20ms DSP.

- WebRtcServiceProvider + config/webrtc.php + webrtc:serve command
- Contracts\MediaEngine (offer/ice/record/connect/bridge/close)
- Media\NullMediaEngine (signaling-only) + Media\Str0mMediaEngine (Rust seam)
- Signaling\SignalingHandler (transport-agnostic) + Server\SignalingServer (on the kernel)
- rust/ crate stub (blax_webrtc: str0m + ext-php-rs) documenting the media plan

Enables: server-side record/intercept, provider-hidden AI-realtime bridging,
and party-to-party/group calls. rel learn-atc #1055 #1051 #1019

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-07 13:07:59 +02:00
commit 4d79aee2cc
15 changed files with 806 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
/vendor/
composer.lock
.phpunit.result.cache
.phpunit.cache/
*.log
.DS_Store
.idea/
.vscode/
# Rust core build artifacts
/rust/target/
/rust/Cargo.lock

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Blax Software
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# blax-software/laravel-webrtc
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.
```
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/)
```
## What it's for
One media path that terminates server-side unlocks all of:
- **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.
## 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
```bash
composer require blax-software/laravel-webrtc:dev-master
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`).
## Run
```bash
php artisan webrtc:serve # signaling on config(webrtc.host:port)
```
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.
## Signaling wire shape (JSON, newline-delimited)
```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": "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.
## License
MIT © Blax Software

56
composer.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "blax-software/laravel-webrtc",
"description": "WebRTC for Laravel on the blax ReactPHP kernel: signaling on the shared loop + a pluggable media engine (Rust/str0m via ext-php-rs) for recording, AI-realtime bridging, and party-to-party calls.",
"type": "library",
"license": "MIT",
"keywords": ["laravel", "webrtc", "reactphp", "sfu", "recording", "realtime", "openai", "voice"],
"authors": [
{
"name": "Blax Software",
"email": "office@blax.at"
}
],
"require": {
"php": ">=8.1",
"blax-software/reactphp-kernel": "dev-master",
"react/event-loop": "^1.5",
"react/socket": "^1.15",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/console": "^10.0|^11.0|^12.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"suggest": {
"ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).",
"blax-software/laravel-websockets": "Ride its WebSocket transport for browser signaling instead of the built-in raw signaling server."
},
"repositories": [
{
"type": "vcs",
"url": "ssh://git@git.blax.at:222/blax-software/reactphp-kernel.git"
}
],
"autoload": {
"psr-4": {
"BlaxSoftware\\LaravelWebRtc\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"BlaxSoftware\\LaravelWebRtc\\Tests\\": "tests/"
}
},
"extra": {
"laravel": {
"providers": [
"BlaxSoftware\\LaravelWebRtc\\WebRtcServiceProvider"
]
}
},
"config": {
"sort-packages": true
},
"minimum-stability": "dev",
"prefer-stable": true
}

68
config/webrtc.php Normal file
View File

@ -0,0 +1,68 @@
<?php
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
return [
/*
|--------------------------------------------------------------------------
| Signaling bind
|--------------------------------------------------------------------------
| Where the signaling server (on the shared ReactPHP kernel) listens. For
| browser clients you will typically front this with the laravel-websockets
| WS transport instead; this raw endpoint drives + tests the media engine.
*/
'host' => env('WEBRTC_HOST', '127.0.0.1'),
'port' => (int) env('WEBRTC_PORT', 8090),
// ReactPHP TLS context; leave both unset for plain TCP.
'tls' => array_filter([
'local_cert' => env('WEBRTC_TLS_CERT'),
'local_pk' => env('WEBRTC_TLS_KEY'),
]),
/*
|--------------------------------------------------------------------------
| Media engine
|--------------------------------------------------------------------------
| The backend that terminates media (ICE/DTLS/SRTP/RTP/Opus), records, and
| bridges to external realtime providers. NullMediaEngine = signaling only;
| Str0mMediaEngine requires the blax_webrtc Rust extension (see rust/).
*/
'media_engine' => env('WEBRTC_MEDIA_ENGINE', NullMediaEngine::class),
/*
|--------------------------------------------------------------------------
| Recording
|--------------------------------------------------------------------------
| Where server-side call recordings are written (attach them to your own
| transmission rows afterwards).
*/
'recording' => [
'disk' => env('WEBRTC_RECORDING_DISK', 'local'),
'path' => env('WEBRTC_RECORDING_PATH', 'webrtc/recordings'),
],
/*
|--------------------------------------------------------------------------
| ICE servers (STUN/TURN) advertised to browsers
|--------------------------------------------------------------------------
*/
'ice_servers' => [
// ['urls' => 'stun:stun.l.google.com:19302'],
// ['urls' => 'turn:turn.example.com:3478', 'username' => '...', 'credential' => '...'],
],
/*
|--------------------------------------------------------------------------
| External realtime bridge (provider hidden from the browser)
|--------------------------------------------------------------------------
| The media engine dials these; the browser only ever talks to us, so the AI
| provider is invisible and the model is swappable server-side.
*/
'bridge' => [
'openai' => [
'model' => env('WEBRTC_OPENAI_MODEL', 'gpt-realtime'),
'api_key' => env('OPENAI_API_KEY'),
],
],
];

26
rust/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "blax_webrtc"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Rust media core (str0m) for blax-software/laravel-webrtc, exposed to PHP via ext-php-rs."
publish = false
# Built as a PHP extension (cdylib). `cargo php install` loads it into your PHP.
[lib]
crate-type = ["cdylib"]
[dependencies]
# Sans-IO WebRTC (ICE/DTLS/SRTP/RTP), built for SFU + recording use cases.
str0m = "0.6"
# Zend bindings so PHP can drive the core (SDP/ICE/control across FFI).
ext-php-rs = "0.12"
# Added as the core grows:
# opus = "..." # audio (de)coding for recording / transcoding
# hound / ogg = "..." # write recordings to disk
# tokio = "..." # the OpenAI-realtime bridge (server-side WebSocket)
[profile.release]
lto = true
codegen-units = 1

39
rust/README.md Normal file
View File

@ -0,0 +1,39 @@
# blax_webrtc — Rust media core
The timing-critical WebRTC media path for `blax-software/laravel-webrtc`, so that
PHP never runs per-20ms real-time DSP.
## Why Rust here
Handling **connections** concurrently in PHP is a solved problem (Swoole,
FrankenPHP, ReactPHP). What is *not* proven is sustained per-packet media DSP
(decrypt SRTP + Opus + RTP jitter every 20ms) glitch-free in single-threaded PHP.
That work belongs in a language built for it. `str0m` is **sans-IO** (no threads
or async of its own — you feed it bytes), which makes it ideal to embed and drive
from PHP.
- `str0m` owns ICE / DTLS / SRTP / RTP / Opus.
- `ext-php-rs` exposes a thin function surface to PHP.
- PHP (`Str0mMediaEngine`) forwards SDP/ICE/control across the FFI boundary and
calls back into Laravel for persistence (recordings, transmission rows).
## Build
```bash
# one-time: cargo + the ext-php-rs helper
cargo install cargo-php
cd rust
cargo php install --release # compiles the cdylib, installs it as the `blax_webrtc` extension
php -m | grep blax_webrtc # verify it loaded
```
Then set `WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine`.
## Status
Scaffold. `src/lib.rs` documents the intended FFI functions
(`blax_webrtc_offer`, `..._add_ice_candidate`, `..._start_recording`,
`..._connect_peers`, `..._bridge`, `..._close`) that `Str0mMediaEngine` forwards
to. Implement them against `str0m`'s RTP/Frame API, then flip the PHP engine off
`NullMediaEngine`.

57
rust/src/lib.rs Normal file
View File

@ -0,0 +1,57 @@
//! blax_webrtc — the Rust media core for `blax-software/laravel-webrtc`.
//!
//! Owns the timing-critical WebRTC media path (ICE / DTLS / SRTP / RTP / Opus)
//! via `str0m` (a sans-IO Rust WebRTC implementation built for SFU + recording),
//! and exposes a thin surface to PHP via `ext-php-rs`. PHP drives signaling and
//! orchestration; the per-20ms media DSP runs here in Rust, so it never stalls
//! the PHP event loop or fights PHP's GC.
//!
//! STATUS: scaffold. The functions below are the intended FFI surface that
//! `BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` forwards to. They are not
//! implemented yet — this file documents the seam and keeps the crate buildable
//! once the dependencies are added.
//!
//! Build (once implemented):
//! cd rust && cargo php install --release
//! which compiles the cdylib and installs it as the `blax_webrtc` PHP extension.
// use ext_php_rs::prelude::*;
//
// /// Accept a browser SDP offer for `peer_id`; return the answer SDP.
// #[php_function]
// pub fn blax_webrtc_offer(peer_id: &str, sdp_offer: &str) -> String {
// todo!("create a str0m Rtc, apply the remote offer, return the local answer")
// }
//
// /// Feed a trickle-ICE candidate for `peer_id`.
// #[php_function]
// pub fn blax_webrtc_add_ice_candidate(peer_id: &str, candidate: &str) {
// todo!("parse + add the candidate to the peer's str0m Rtc")
// }
//
// /// Start recording `peer_id`'s inbound audio to `path` (Opus/WAV).
// #[php_function]
// pub fn blax_webrtc_start_recording(peer_id: &str, path: &str) {
// todo!("tee the peer's decoded audio frames to a file writer")
// }
//
// #[php_function]
// pub fn blax_webrtc_stop_recording(peer_id: &str) { todo!() }
//
// /// Forward media between two peers (party-to-party / group calls).
// #[php_function]
// pub fn blax_webrtc_connect_peers(peer_a: &str, peer_b: &str) { todo!() }
//
// /// Bridge `peer_id` to an external realtime provider (e.g. OpenAI Realtime over
// /// a server-side WebSocket). The browser never sees the provider.
// #[php_function]
// pub fn blax_webrtc_bridge(peer_id: &str, options_json: &str) { todo!() }
//
// /// Close + free a peer.
// #[php_function]
// pub fn blax_webrtc_close(peer_id: &str) { todo!() }
//
// #[php_module]
// pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
// module
// }

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\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 Illuminate\Console\Command;
class StartWebRtcServer extends Command
{
protected $signature = 'webrtc:serve {--host= : Bind host (defaults to config webrtc.host)} {--port= : Bind port (defaults to config webrtc.port)}';
protected $description = 'Start the blax WebRTC signaling server on the shared ReactPHP kernel.';
public function handle(): int
{
$host = (string) ($this->option('host') ?: config('webrtc.host', '127.0.0.1'));
$port = (int) ($this->option('port') ?: config('webrtc.port', 8090));
$engine = app(config('webrtc.media_engine', NullMediaEngine::class));
if (! $engine instanceof MediaEngine) {
$this->error('Configured webrtc.media_engine is not a ' . MediaEngine::class);
return self::FAILURE;
}
$kernel = (new Kernel())->reapChildren();
$kernel->register(new SignalingServer(
$host,
$port,
new SignalingHandler($engine),
(array) config('webrtc.tls', []),
));
$this->info("blax WebRTC signaling on {$host}:{$port} (engine: {$engine->name()}) — Ctrl-C to stop");
$kernel->run();
return self::SUCCESS;
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Contracts;
/**
* The media backend for a WebRTC call.
*
* PHP (this package) owns SIGNALING + orchestration; the MediaEngine owns the
* real-time media path (ICE, DTLS, SRTP, RTP, Opus the per-20ms DSP). The
* production engine (Str0mMediaEngine) delegates that to a Rust core (str0m,
* sans-IO) exposed to PHP via ext-php-rs, so the timing-critical work never runs
* in PHP itself. See the package `rust/` directory.
*
* Because the engine terminates media server-side, it is also the single place
* that can:
* - RECORD each participant's audio (startRecording),
* - BRIDGE a participant to an external realtime model, hiding the provider
* (bridge e.g. OpenAI Realtime over a WebSocket the browser never sees),
* - route audio between participants for party-to-party / group calls.
*/
interface MediaEngine
{
/** Human-readable engine id (diagnostics). */
public function name(): string;
/**
* Accept a browser's SDP offer for a peer and return the SDP answer. The
* engine sets up the peer connection and begins ICE.
*
* @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
*/
public function offer(string $peerId, string $sdpOffer): string;
/**
* Feed a trickle-ICE candidate received from the browser for a peer.
*
* @param array<string,mixed> $candidate {candidate, sdpMid, sdpMLineIndex}
*/
public function addIceCandidate(string $peerId, array $candidate): void;
/**
* Start recording a peer's inbound audio to $path (e.g. an Opus/WAV file the
* app then attaches to a transmission row).
*/
public function startRecording(string $peerId, string $path): void;
/** Stop an in-progress recording for a peer (if any). */
public function stopRecording(string $peerId): void;
/**
* Route a peer's audio to/from another peer (party-to-party / group calls).
* For an N-party room this is called per pair the engine must forward.
*/
public function connectPeers(string $peerA, string $peerB): void;
/**
* Bridge a peer to an external realtime endpoint (e.g. OpenAI Realtime),
* hiding the provider from the browser. Options are engine-specific
* (model, voice, instructions, api credentials handle, ...).
*
* @param array<string,mixed> $options
*/
public function bridge(string $peerId, array $options): void;
/** Close and clean up a peer (stops recording/bridging, frees the RTC state). */
public function close(string $peerId): void;
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Media;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
/**
* A no-op media engine so the signaling stack is runnable and testable before
* the Rust media core exists. It accepts signaling and logs, but does NOT
* terminate media any `offer()` fails loudly so a misconfigured deployment is
* obvious rather than silently medialess.
*/
final class NullMediaEngine implements MediaEngine
{
public function name(): string
{
return 'null';
}
public function offer(string $peerId, string $sdpOffer): string
{
throw new \RuntimeException(
'NullMediaEngine cannot terminate media. Configure webrtc.media_engine ' .
'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.'
);
}
public function addIceCandidate(string $peerId, array $candidate): void
{
// no-op
}
public function startRecording(string $peerId, string $path): void
{
// no-op
}
public function stopRecording(string $peerId): void
{
// no-op
}
public function connectPeers(string $peerA, string $peerB): void
{
// no-op
}
public function bridge(string $peerId, array $options): void
{
// no-op
}
public function close(string $peerId): void
{
// no-op
}
}

View File

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Media;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
/**
* Production media engine backed by a RUST core.
*
* The core is `str0m` (a sans-IO Rust WebRTC implementation built for SFU +
* recording) compiled into a PHP extension via `ext-php-rs` and shipped in this
* package's `rust/` directory (extension name: `blax_webrtc`). Rust owns the
* timing-critical media path (ICE/DTLS/SRTP/RTP/Opus, the per-20ms DSP); this
* PHP adapter is a thin forwarder that carries SDP/ICE/control across the FFI
* boundary and calls back into Laravel for persistence (recordings, transmissions).
*
* This is the SEAM, not the implementation. Until `rust/` is built and the
* extension is loaded, constructing this engine fails with a clear message.
* Each method documents the `blax_webrtc_*` call it will forward to.
*/
final class Str0mMediaEngine implements MediaEngine
{
public function __construct()
{
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), ' .
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
);
}
}
public function name(): string
{
return 'str0m';
}
public function offer(string $peerId, string $sdpOffer): string
{
// return blax_webrtc_offer($peerId, $sdpOffer);
throw new \LogicException('TODO: forward to blax_webrtc_offer() once the Rust core is built.');
}
public function addIceCandidate(string $peerId, array $candidate): void
{
// blax_webrtc_add_ice_candidate($peerId, $candidate);
throw new \LogicException('TODO: forward to blax_webrtc_add_ice_candidate().');
}
public function startRecording(string $peerId, string $path): void
{
// blax_webrtc_start_recording($peerId, $path);
throw new \LogicException('TODO: forward to blax_webrtc_start_recording().');
}
public function stopRecording(string $peerId): void
{
// blax_webrtc_stop_recording($peerId);
throw new \LogicException('TODO: forward to blax_webrtc_stop_recording().');
}
public function connectPeers(string $peerA, string $peerB): void
{
// blax_webrtc_connect_peers($peerA, $peerB);
throw new \LogicException('TODO: forward to blax_webrtc_connect_peers().');
}
public function bridge(string $peerId, array $options): void
{
// blax_webrtc_bridge($peerId, $options); // e.g. OpenAI Realtime over a server-side WS
throw new \LogicException('TODO: forward to blax_webrtc_bridge().');
}
public function close(string $peerId): void
{
// blax_webrtc_close($peerId);
throw new \LogicException('TODO: forward to blax_webrtc_close().');
}
}

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Server;
use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler;
use BlaxSoftware\ReactPhpKernel\Contracts\Server;
use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory;
use React\EventLoop\LoopInterface;
use React\Socket\ConnectionInterface;
use React\Socket\SocketServer;
/**
* A minimal signaling server that attaches to the shared ReactPHP Kernel: it
* accepts newline-delimited JSON signaling frames and routes them through the
* SignalingHandler to the MediaEngine.
*
* The raw-TCP/JSON transport here is enough to drive the media engine and to
* test the seam. For browsers, point the client at the WebSocket transport from
* blax-software/laravel-websockets and feed decoded frames into the SAME
* SignalingHandler the routing/media wiring does not change.
*/
final class SignalingServer implements Server
{
private ?SocketServer $socket = null;
/**
* @param array<string,mixed> $tls ReactPHP TLS context, or [] for plain TCP
*/
public function __construct(
private string $host,
private int $port,
private SignalingHandler $handler,
private array $tls = [],
) {
}
public function name(): string
{
return 'webrtc-signaling';
}
public function boot(LoopInterface $loop): void
{
$this->socket = SocketServerFactory::create("{$this->host}:{$this->port}", $loop, $this->tls);
$this->socket->on('connection', fn (ConnectionInterface $conn) => $this->onConnection($conn));
}
private function onConnection(ConnectionInterface $conn): void
{
$buffer = '';
$conn->on('data', function ($chunk) use (&$buffer, $conn) {
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
$line = trim(substr($buffer, 0, $pos));
$buffer = substr($buffer, $pos + 1);
if ($line === '') {
continue;
}
$message = json_decode($line, true);
if (! is_array($message)) {
$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");
}
}
});
}
public function shutdown(): void
{
$this->socket?->close();
$this->socket = null;
}
}

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Signaling;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
/**
* Transport-agnostic signaling router: decodes a signaling message and drives
* the MediaEngine, returning the reply to send back (or null when a message
* needs no reply, e.g. trickle ICE).
*
* Kept free of any socket/WS concern so it can be exercised in unit tests and so
* the browser-facing transport (this package's raw SignalingServer today, or the
* laravel-websockets WS layer later) is a swappable detail.
*
* Wire shape (JSON): { "type": "offer|ice|record|connect|bridge|close", "peer": "...", ... }
*/
final class SignalingHandler
{
public function __construct(private MediaEngine $engine)
{
}
/**
* @param array<string,mixed> $message
* @return array<string,mixed>|null
*/
public function handle(array $message): ?array
{
$type = (string) ($message['type'] ?? '');
$peer = (string) ($message['peer'] ?? '');
if ($peer === '') {
return ['type' => 'error', 'error' => 'missing peer'];
}
try {
return match ($type) {
'offer' => [
'type' => 'answer',
'peer' => $peer,
'sdp' => $this->engine->offer($peer, (string) ($message['sdp'] ?? '')),
],
'ice' => $this->trickle($peer, (array) ($message['candidate'] ?? [])),
'record' => $this->ack($peer, fn () => $this->engine->startRecording($peer, (string) ($message['path'] ?? ''))),
'connect' => $this->ack($peer, fn () => $this->engine->connectPeers($peer, (string) ($message['other'] ?? ''))),
'bridge' => $this->ack($peer, fn () => $this->engine->bridge($peer, (array) ($message['options'] ?? []))),
'close' => $this->ack($peer, fn () => $this->engine->close($peer)),
default => ['type' => 'error', 'error' => "unknown type: {$type}"],
};
} catch (\Throwable $e) {
return ['type' => 'error', 'peer' => $peer, 'error' => $e->getMessage()];
}
}
/** @param array<string,mixed> $candidate */
private function trickle(string $peer, array $candidate): ?array
{
$this->engine->addIceCandidate($peer, $candidate);
return null; // trickle ICE needs no reply
}
/** @return array<string,mixed> */
private function ack(string $peer, callable $action): array
{
$action();
return ['type' => 'ack', 'peer' => $peer];
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc;
use BlaxSoftware\LaravelWebRtc\Console\Commands\StartWebRtcServer;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine;
use Illuminate\Support\ServiceProvider;
class WebRtcServiceProvider extends ServiceProvider
{
public function register(): void
{
$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) {
$class = (string) config('webrtc.media_engine', NullMediaEngine::class);
return $app->make($class);
});
}
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
], 'webrtc-config');
$this->commands([
StartWebRtcServer::class,
]);
}
}
}