feat: working browser-to-browser (P2P) MVP via WebSocket signaling relay
The key insight: a 2-party / small-mesh call needs NO server-side media — the server only relays SDP/ICE and the browsers connect peer-to-peer. So the webrtc package now has a genuinely working call MVP with no Rust/media engine. - Signaling\RelaySignalingHandler: a blax-software/laravel-ws MessageHandler that relays between browsers — welcome/join/joined/peer-joined/relay/broadcast/ peer-left. Peers in the kernel ConnectionRegistry, rooms in RoomManager. - webrtc:serve now runs the relay over a laravel-ws WebSocketServer (browser-usable ws://). require blax-software/laravel-ws. - The engine-backed path (SignalingHandler/SignalingServer + MediaEngine) stays for server-terminated media (recording/AI-bridge/SFU, Str0m = #1051). - Tests 26 / 60 incl a REAL WebSocket welcome round-trip (browser->laravel-ws->relay); verified with laravel-ws + reactphp-kernel resolved from Forgejo. README leads with the browser P2P quickstart + JS example. rel learn-atc #1055 #1051 #1019 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1479e36f49
commit
028eaadaa8
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -8,6 +8,20 @@ Blax Software's backward-compatibility guarantee.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Working browser-to-browser (P2P) MVP.** `Signaling\RelaySignalingHandler` (a
|
||||||
|
`blax-software/laravel-ws` `MessageHandler`) relays SDP/ICE between browsers so
|
||||||
|
they connect peer-to-peer — no server-side media engine required. Protocol:
|
||||||
|
`welcome` on connect, `join` → `joined` + `peer-joined`, targeted `relay`, room
|
||||||
|
`broadcast`, `leave`/disconnect → `peer-left`. Peers tracked in the kernel
|
||||||
|
`ConnectionRegistry`, membership in `RoomManager`.
|
||||||
|
- `webrtc:serve` now runs this relay over a `laravel-ws` WebSocketServer, so it is
|
||||||
|
browser-usable out of the box (`ws://host:port`).
|
||||||
|
- `require blax-software/laravel-ws`.
|
||||||
|
- Tests grew to 26 / 60 assertions, including a real WebSocket `welcome`
|
||||||
|
round-trip (browser → laravel-ws → relay).
|
||||||
|
|
||||||
|
### Added (earlier)
|
||||||
|
|
||||||
- `WebRtcServiceProvider` + `config/webrtc.php` + the `webrtc:serve` command
|
- `WebRtcServiceProvider` + `config/webrtc.php` + the `webrtc:serve` command
|
||||||
(boots signaling on the shared `blax-software/reactphp-kernel`).
|
(boots signaling on the shared `blax-software/reactphp-kernel`).
|
||||||
- `Contracts\MediaEngine` — the pluggable media backend
|
- `Contracts\MediaEngine` — the pluggable media backend
|
||||||
|
|
|
||||||
97
README.md
97
README.md
|
|
@ -5,21 +5,20 @@
|
||||||
[](https://php.net)
|
[](https://php.net)
|
||||||
[](https://laravel.com)
|
[](https://laravel.com)
|
||||||
[](https://github.com/blax-software/reactphp-kernel)
|
[](https://github.com/blax-software/reactphp-kernel)
|
||||||
[](#testing)
|
[](#testing)
|
||||||
[](#testing)
|
[](#testing)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
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.
|
WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) backbone. A **WebSocket signaling relay** connects browsers for **peer-to-peer calls today** (no server-side media needed); when you need the server *in* the media path — recording, AI bridging, an SFU — a pluggable **media engine** (a Rust `str0m` core via `ext-php-rs`) takes over.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎙️ **Record / intercept / log** — the engine terminates media server-side, so every participant's audio (human or AI) can be captured
|
- 📞 **Browser-to-browser calls, working today** — two browsers `join` a room and the server relays their SDP/ICE so they connect **peer-to-peer**; the audio never touches the server, so no media engine is required
|
||||||
- 🤖 **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
|
- 👥 **Rooms & mesh signaling** — peer discovery on join, `peer-joined` / `peer-left` notifications, targeted `relay`, and room `broadcast`
|
||||||
- 👥 **Party-to-party & group calls** — `join`/`leave` a room and the `RoomManager` meshes each new peer with everyone already there (instructor↔student, controller↔pilot, guest↔admin)
|
- 🧩 **On the shared kernel** — runs on `reactphp-kernel` over the [`laravel-ws`](https://github.com/blax-software/laravel-ws) WebSocket transport; one process, one loop, alongside your other realtime servers
|
||||||
- 🧩 **On the shared kernel** — signaling runs on `reactphp-kernel` alongside your WebSockets, one process, one loop
|
- 🎙️ **Server-side media, when you need it** — a pluggable `MediaEngine` terminates media for **recording / intercept**, **AI-realtime bridging**, and **SFU** group calls
|
||||||
- 🔌 **Pluggable `MediaEngine`** — `NullMediaEngine` for signaling-only dev; `Str0mMediaEngine` for the real Rust-backed media
|
- 🤖 **AI realtime, provider hidden** — bridge a caller to an external model (e.g. OpenAI Realtime) server-side; the browser only ever talks to *you*, and the model is a config swap
|
||||||
- 🦀 **Rust media core** — `str0m` (sans-IO WebRTC) via `ext-php-rs`, shipped in `rust/`
|
- 🔌 **Pluggable engine** — `NullMediaEngine` (signaling-only) now; `Str0mMediaEngine` (Rust `str0m` via `ext-php-rs`, shipped in `rust/`) for real server-terminated media
|
||||||
- 🎛️ **Transport-agnostic signaling** — route browser frames from this package's raw server or from `laravel-websockets` into the same handler
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -28,36 +27,64 @@ 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`](https://github.com/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) + [`blax-software/laravel-ws`](https://github.com/blax-software/laravel-ws), resolved from git.blax.at via the `repositories` entry in `composer.json`.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Run the signaling server (on the shared kernel):
|
Run the signaling relay (browsers connect here):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan webrtc:serve
|
php artisan webrtc:serve # ws://127.0.0.1:8090 by default
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
That's all a **peer-to-peer call** needs — the server only relays signaling; the browsers exchange audio directly:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ws = new WebSocket('ws://127.0.0.1:8090')
|
||||||
|
const pc = new RTCPeerConnection()
|
||||||
|
let peer
|
||||||
|
|
||||||
|
ws.onmessage = async ({ data }) => {
|
||||||
|
const m = JSON.parse(data)
|
||||||
|
if (m.type === 'welcome') ws.send(JSON.stringify({ type: 'join', room: 'lobby' }))
|
||||||
|
if (m.type === 'joined') m.peers.forEach(p => (peer = p, call(p))) // someone's already here → call them
|
||||||
|
if (m.type === 'peer-joined') peer = m.peer // they'll send us an offer
|
||||||
|
if (m.type === 'relay') { // SDP / ICE from the other browser
|
||||||
|
peer = m.from
|
||||||
|
if (m.data.sdp) { await pc.setRemoteDescription(m.data); if (m.data.type === 'offer') answer() }
|
||||||
|
if (m.data.candidate) await pc.addIceCandidate(m.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pc.onicecandidate = e => e.candidate && ws.send(JSON.stringify({ type: 'relay', to: peer, data: e.candidate }))
|
||||||
|
pc.ontrack = e => audioEl.srcObject = e.streams[0]
|
||||||
|
|
||||||
|
const send = (data) => ws.send(JSON.stringify({ type: 'relay', to: peer, data }))
|
||||||
|
async function call() { const o = await pc.createOffer(); await pc.setLocalDescription(o); send(o) }
|
||||||
|
async function answer() { const a = await pc.createAnswer(); await pc.setLocalDescription(a); send(a) }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relay protocol (JSON over WebSocket)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
← { "type": "welcome", "peer": "<yourId>" } // on connect
|
||||||
|
→ { "type": "join", "room": "lobby" }
|
||||||
|
← { "type": "joined", "room": "lobby", "peers": ["<id>", ...] } // who's already here
|
||||||
|
← { "type": "peer-joined", "room": "lobby", "peer": "<id>" } // sent to the others
|
||||||
|
→ { "type": "relay", "to": "<peerId>", "data": { ...sdp | candidate... } }
|
||||||
|
← { "type": "relay", "from": "<peerId>", "data": { ... } } // delivered to the target
|
||||||
|
→ { "type": "broadcast", "data": { ... } } // to the rest of your room
|
||||||
|
→ { "type": "leave" } ← { "type": "left" } / others get { "type": "peer-left", "peer" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server-terminated media (recording / AI bridge / SFU)
|
||||||
|
|
||||||
|
When the server must be *in* the media path, configure a `MediaEngine`. `NullMediaEngine` (default) is signaling-only; `Str0mMediaEngine` is backed by a Rust `str0m` core via `ext-php-rs` (see [`rust/README.md`](rust/README.md)):
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
|
WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Signaling wire shape (newline-delimited JSON)
|
The engine-backed path uses `Signaling\SignalingHandler` (offer/ice/record/connect/bridge/close) + `Server\SignalingServer`; it's independent of the browser relay above and shares the same `RoomManager`.
|
||||||
|
|
||||||
```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" } // low-level: mesh two peers
|
|
||||||
{ "type": "join", "peer": "p1", "room": "lobby" } // -> { "type": "joined", "peers": [...] } (auto-meshes)
|
|
||||||
{ "type": "leave", "peer": "p1" } // -> { "type": "left", "peers": [...] }
|
|
||||||
{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
|
|
||||||
{ "type": "close", "peer": "p1" } // closes media + leaves the room
|
|
||||||
```
|
|
||||||
|
|
||||||
`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
|
## Configuration
|
||||||
|
|
||||||
|
|
@ -65,15 +92,17 @@ WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
|
||||||
|
|
||||||
## Status
|
## 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.
|
**Working MVP.** Browser-to-browser (mesh) calls work today via the WebSocket signaling relay — no server-side media required. Rooms, peer discovery, targeted relay and broadcast are tested, including a real WebSocket round-trip. Server-terminated media (recording, AI bridge, SFU) is the pluggable `MediaEngine`; the real one (`Str0mMediaEngine` + `rust/`) is the next build.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
reactphp-kernel shared backbone (loop, IPC, signals)
|
reactphp-kernel shared backbone (loop, IPC, signals)
|
||||||
└─ laravel-webrtc (this) signaling on the kernel
|
├─ laravel-ws WebSocket transport (RFC6455)
|
||||||
├─ NullMediaEngine signaling-only (dev/tests)
|
└─ laravel-webrtc (this)
|
||||||
└─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/)
|
├─ RelaySignalingHandler browser P2P calls (no media engine needed)
|
||||||
|
└─ MediaEngine (optional) server-terminated media
|
||||||
|
└─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
@ -83,7 +112,7 @@ composer install
|
||||||
composer test
|
composer test
|
||||||
```
|
```
|
||||||
|
|
||||||
The signaling router and engines are unit-tested against a fake media engine — no browser, sockets, or external services required.
|
The relay + signaling routers and engines are unit-tested with fakes; a real WebSocket round-trip proves the relay over `laravel-ws`, and a raw-socket test drives the engine signaling server — no browser or external services required.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.1|^8.2|^8.3|^8.4",
|
"php": "^8.1|^8.2|^8.3|^8.4",
|
||||||
|
"blax-software/laravel-ws": "dev-master",
|
||||||
"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",
|
||||||
|
|
@ -23,13 +24,16 @@
|
||||||
"laravel/pint": "^1.13"
|
"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)."
|
||||||
"blax-software/laravel-websockets": "Ride its WebSocket transport for browser signaling instead of the built-in raw signaling server."
|
|
||||||
},
|
},
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
"url": "ssh://git@git.blax.at:222/blax-software/reactphp-kernel.git"
|
"url": "ssh://git@git.blax.at:222/blax-software/reactphp-kernel.git"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "ssh://git@git.blax.at:222/blax-software/laravel-ws.git"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|
|
||||||
|
|
@ -5,39 +5,30 @@ declare(strict_types=1);
|
||||||
namespace Blax\WebRtc\Console\Commands;
|
namespace Blax\WebRtc\Console\Commands;
|
||||||
|
|
||||||
use Blax\ReactPhpKernel\Kernel;
|
use Blax\ReactPhpKernel\Kernel;
|
||||||
use Blax\WebRtc\Contracts\MediaEngine;
|
use Blax\WebRtc\Signaling\RelaySignalingHandler;
|
||||||
use Blax\WebRtc\Media\NullMediaEngine;
|
use Blax\Ws\WebSocketServer;
|
||||||
use Blax\WebRtc\Server\SignalingServer;
|
|
||||||
use Blax\WebRtc\Signaling\SignalingHandler;
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class StartWebRtcServer extends 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 $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.';
|
protected $description = 'Start the blax WebRTC signaling relay (WebSocket) on the shared ReactPHP kernel — browsers connect here for peer-to-peer calls.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$host = (string) ($this->option('host') ?: config('webrtc.host', '127.0.0.1'));
|
$host = (string) ($this->option('host') ?: config('webrtc.host', '127.0.0.1'));
|
||||||
$port = (int) ($this->option('port') ?: config('webrtc.port', 8090));
|
$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 = (new Kernel)->reapChildren();
|
||||||
$kernel->register(new SignalingServer(
|
$kernel->register(new WebSocketServer(
|
||||||
$host,
|
$host,
|
||||||
$port,
|
$port,
|
||||||
new SignalingHandler($engine),
|
new RelaySignalingHandler,
|
||||||
(array) config('webrtc.tls', []),
|
(array) config('webrtc.tls', []),
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->info("blax WebRTC signaling on {$host}:{$port} (engine: {$engine->name()}) — Ctrl-C to stop");
|
$this->info("blax WebRTC signaling relay (WebSocket) on ws://{$host}:{$port} — browsers connect here for P2P calls. Ctrl-C to stop.");
|
||||||
|
|
||||||
$kernel->run();
|
$kernel->run();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\WebRtc\Signaling;
|
||||||
|
|
||||||
|
use Blax\ReactPhpKernel\Support\ConnectionRegistry;
|
||||||
|
use Blax\WebRtc\Rooms\RoomManager;
|
||||||
|
use Blax\Ws\Contracts\MessageHandler;
|
||||||
|
use Blax\Ws\Contracts\Sendable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The working browser-to-browser (mesh) signaling relay.
|
||||||
|
*
|
||||||
|
* For a peer-to-peer call the SERVER does not touch media at all — the browsers
|
||||||
|
* establish a direct WebRTC connection and exchange only SDP + ICE, which this
|
||||||
|
* relay forwards. Two browsers that `join` the same room discover each other and
|
||||||
|
* relay `offer`/`answer`/`candidate` payloads through here; the audio flows P2P.
|
||||||
|
*
|
||||||
|
* Runs as a Blax\Ws\Contracts\MessageHandler on a laravel-ws WebSocketServer, so
|
||||||
|
* `webrtc:serve` gives a browser-usable signaling endpoint out of the box. Server
|
||||||
|
* -terminated media (recording, AI bridge, SFU) is the separate MediaEngine seam
|
||||||
|
* (Str0mMediaEngine) — not needed for a P2P call.
|
||||||
|
*
|
||||||
|
* Protocol (JSON):
|
||||||
|
* ← { "type": "welcome", "peer": "<yourId>" } (on connect)
|
||||||
|
* → { "type": "join", "room": "lobby" }
|
||||||
|
* ← { "type": "joined", "room": "lobby", "peers": [...] } (existing peers)
|
||||||
|
* ← { "type": "peer-joined", "room": "lobby", "peer": "<id>" } (to the others)
|
||||||
|
* → { "type": "relay", "to": "<peerId>", "data": { ...sdp/ice... } }
|
||||||
|
* ← { "type": "relay", "from": "<peerId>", "data": { ... } } (to the target)
|
||||||
|
* → { "type": "broadcast", "data": { ... } } (to the rest of your room)
|
||||||
|
* → { "type": "leave" } ← { "type": "left" } / peers get { "type": "peer-left", "peer" }
|
||||||
|
*/
|
||||||
|
final class RelaySignalingHandler implements MessageHandler
|
||||||
|
{
|
||||||
|
private ConnectionRegistry $peers;
|
||||||
|
|
||||||
|
private RoomManager $rooms;
|
||||||
|
|
||||||
|
public function __construct(?ConnectionRegistry $peers = null, ?RoomManager $rooms = null)
|
||||||
|
{
|
||||||
|
$this->peers = $peers ?? new ConnectionRegistry;
|
||||||
|
$this->rooms = $rooms ?? new RoomManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function peers(): ConnectionRegistry
|
||||||
|
{
|
||||||
|
return $this->peers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rooms(): RoomManager
|
||||||
|
{
|
||||||
|
return $this->rooms;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onOpen(Sendable $connection): void
|
||||||
|
{
|
||||||
|
$this->peers->add($connection->id(), $connection);
|
||||||
|
$connection->send($this->encode(['type' => 'welcome', 'peer' => $connection->id()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onMessage(Sendable $connection, string $payload): void
|
||||||
|
{
|
||||||
|
$message = json_decode($payload, true);
|
||||||
|
if (! is_array($message)) {
|
||||||
|
$connection->send($this->error('invalid json'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$peer = $connection->id();
|
||||||
|
|
||||||
|
switch ((string) ($message['type'] ?? '')) {
|
||||||
|
case 'join':
|
||||||
|
$room = (string) ($message['room'] ?? '');
|
||||||
|
if ($room === '') {
|
||||||
|
$connection->send($this->error('missing room'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$others = $this->rooms->join($room, $peer);
|
||||||
|
$connection->send($this->encode(['type' => 'joined', 'room' => $room, 'peers' => $others]));
|
||||||
|
foreach ($others as $other) {
|
||||||
|
$this->sendTo($other, ['type' => 'peer-joined', 'room' => $room, 'peer' => $peer]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'leave':
|
||||||
|
foreach ($this->rooms->leave($peer) as $other) {
|
||||||
|
$this->sendTo($other, ['type' => 'peer-left', 'peer' => $peer]);
|
||||||
|
}
|
||||||
|
$connection->send($this->encode(['type' => 'left']));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'relay':
|
||||||
|
$to = (string) ($message['to'] ?? '');
|
||||||
|
if ($to === '') {
|
||||||
|
$connection->send($this->error('missing to'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! $this->sendTo($to, ['type' => 'relay', 'from' => $peer, 'data' => $message['data'] ?? null])) {
|
||||||
|
$connection->send($this->error("peer not connected: {$to}"));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'broadcast':
|
||||||
|
$room = $this->rooms->roomOf($peer);
|
||||||
|
if ($room !== null) {
|
||||||
|
foreach ($this->rooms->peersIn($room) as $other) {
|
||||||
|
if ($other !== $peer) {
|
||||||
|
$this->sendTo($other, ['type' => 'broadcast', 'from' => $peer, 'data' => $message['data'] ?? null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
$connection->send($this->error('unknown type'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onClose(Sendable $connection): void
|
||||||
|
{
|
||||||
|
$peer = $connection->id();
|
||||||
|
foreach ($this->rooms->leave($peer) as $other) {
|
||||||
|
$this->sendTo($other, ['type' => 'peer-left', 'peer' => $peer]);
|
||||||
|
}
|
||||||
|
$this->peers->remove($peer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliver a message to a specific peer; false if they're not connected. */
|
||||||
|
private function sendTo(string $peerId, array $message): bool
|
||||||
|
{
|
||||||
|
$connection = $this->peers->get($peerId);
|
||||||
|
if (! $connection instanceof Sendable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->send($this->encode($message));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function error(string $error): string
|
||||||
|
{
|
||||||
|
return $this->encode(['type' => 'error', 'error' => $error]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $message */
|
||||||
|
private function encode(array $message): string
|
||||||
|
{
|
||||||
|
return (string) json_encode($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\WebRtc\Tests\Fixtures;
|
||||||
|
|
||||||
|
use Blax\Ws\Contracts\Sendable;
|
||||||
|
|
||||||
|
/** A Sendable (WS connection) double that records everything sent to a peer. */
|
||||||
|
final class FakePeer implements Sendable
|
||||||
|
{
|
||||||
|
/** @var array<int,string> */
|
||||||
|
public array $sent = [];
|
||||||
|
|
||||||
|
public bool $closed = false;
|
||||||
|
|
||||||
|
public function __construct(private string $id) {}
|
||||||
|
|
||||||
|
public function id(): string
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $message): void
|
||||||
|
{
|
||||||
|
$this->sent[] = $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(): void
|
||||||
|
{
|
||||||
|
$this->closed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int,array<string,mixed>> */
|
||||||
|
public function jsonMessages(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($s) => json_decode($s, true) ?? [], $this->sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed> */
|
||||||
|
public function lastJson(): array
|
||||||
|
{
|
||||||
|
$last = end($this->sent);
|
||||||
|
|
||||||
|
return $last === false ? [] : (json_decode($last, true) ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\WebRtc\Tests;
|
||||||
|
|
||||||
|
use Blax\WebRtc\Signaling\RelaySignalingHandler;
|
||||||
|
use Blax\WebRtc\Tests\Fixtures\FakePeer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class RelaySignalingHandlerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_welcomes_a_peer_on_connect_with_its_id(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
|
||||||
|
$handler->onOpen($a);
|
||||||
|
|
||||||
|
$this->assertSame(['type' => 'welcome', 'peer' => 'a'], $a->lastJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_join_returns_existing_peers_and_notifies_them(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
$b = new FakePeer('b');
|
||||||
|
$handler->onOpen($a);
|
||||||
|
$handler->onOpen($b);
|
||||||
|
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby']));
|
||||||
|
$handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby']));
|
||||||
|
|
||||||
|
// b learns 'a' is already there
|
||||||
|
$this->assertSame(['type' => 'joined', 'room' => 'lobby', 'peers' => ['a']], $b->lastJson());
|
||||||
|
// a is told b joined
|
||||||
|
$this->assertContains(['type' => 'peer-joined', 'room' => 'lobby', 'peer' => 'b'], $a->jsonMessages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_relay_forwards_the_payload_to_the_target_with_the_sender(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
$b = new FakePeer('b');
|
||||||
|
$handler->onOpen($a);
|
||||||
|
$handler->onOpen($b);
|
||||||
|
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'relay', 'to' => 'b', 'data' => ['sdp' => 'OFFER']]));
|
||||||
|
|
||||||
|
$this->assertSame(['type' => 'relay', 'from' => 'a', 'data' => ['sdp' => 'OFFER']], $b->lastJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_relay_to_an_unknown_peer_errors_the_sender(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
$handler->onOpen($a);
|
||||||
|
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'relay', 'to' => 'ghost', 'data' => []]));
|
||||||
|
|
||||||
|
$this->assertSame('error', $a->lastJson()['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_leave_notifies_the_remaining_peers(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
$b = new FakePeer('b');
|
||||||
|
$handler->onOpen($a);
|
||||||
|
$handler->onOpen($b);
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby']));
|
||||||
|
$handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby']));
|
||||||
|
|
||||||
|
$handler->onMessage($b, $this->json(['type' => 'leave']));
|
||||||
|
|
||||||
|
$this->assertContains(['type' => 'peer-left', 'peer' => 'b'], $a->jsonMessages());
|
||||||
|
$this->assertSame(['type' => 'left'], $b->lastJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_disconnect_leaves_the_room_and_forgets_the_peer(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
$b = new FakePeer('b');
|
||||||
|
$handler->onOpen($a);
|
||||||
|
$handler->onOpen($b);
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'join', 'room' => 'lobby']));
|
||||||
|
$handler->onMessage($b, $this->json(['type' => 'join', 'room' => 'lobby']));
|
||||||
|
|
||||||
|
$handler->onClose($b);
|
||||||
|
|
||||||
|
$this->assertContains(['type' => 'peer-left', 'peer' => 'b'], $a->jsonMessages());
|
||||||
|
$this->assertFalse($handler->peers()->has('b'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_broadcast_reaches_the_rest_of_the_room_only(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$peers = [new FakePeer('a'), new FakePeer('b'), new FakePeer('c')];
|
||||||
|
foreach ($peers as $p) {
|
||||||
|
$handler->onOpen($p);
|
||||||
|
$handler->onMessage($p, $this->json(['type' => 'join', 'room' => 'r']));
|
||||||
|
}
|
||||||
|
[$a, $b, $c] = $peers;
|
||||||
|
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'broadcast', 'data' => ['x' => 1]]));
|
||||||
|
|
||||||
|
$expected = ['type' => 'broadcast', 'from' => 'a', 'data' => ['x' => 1]];
|
||||||
|
$this->assertContains($expected, $b->jsonMessages());
|
||||||
|
$this->assertContains($expected, $c->jsonMessages());
|
||||||
|
$this->assertNotContains($expected, $a->jsonMessages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_invalid_json_and_unknown_type_are_errors(): void
|
||||||
|
{
|
||||||
|
$handler = new RelaySignalingHandler;
|
||||||
|
$a = new FakePeer('a');
|
||||||
|
$handler->onOpen($a);
|
||||||
|
|
||||||
|
$handler->onMessage($a, 'not json');
|
||||||
|
$this->assertSame('error', $a->lastJson()['type']);
|
||||||
|
|
||||||
|
$handler->onMessage($a, $this->json(['type' => 'wat']));
|
||||||
|
$this->assertSame('error', $a->lastJson()['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $data */
|
||||||
|
private function json(array $data): string
|
||||||
|
{
|
||||||
|
return (string) json_encode($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\WebRtc\Tests;
|
||||||
|
|
||||||
|
use Blax\WebRtc\Signaling\RelaySignalingHandler;
|
||||||
|
use Blax\Ws\WebSocketServer;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use React\EventLoop\StreamSelectLoop;
|
||||||
|
use React\Socket\ConnectionInterface;
|
||||||
|
use React\Socket\Connector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end proof that the WebRTC relay composes with the laravel-ws
|
||||||
|
* WebSocketServer: a browser (hand-rolled RFC6455 client) connecting to
|
||||||
|
* `webrtc:serve` gets a `welcome` with its peer id over a real WebSocket.
|
||||||
|
*/
|
||||||
|
final class WebSocketRelayServerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_a_browser_receives_welcome_over_a_real_websocket(): void
|
||||||
|
{
|
||||||
|
$loop = new StreamSelectLoop;
|
||||||
|
|
||||||
|
$server = new WebSocketServer('127.0.0.1', 0, new RelaySignalingHandler);
|
||||||
|
$server->boot($loop);
|
||||||
|
|
||||||
|
$address = $server->address();
|
||||||
|
$this->assertNotNull($address);
|
||||||
|
$port = (int) parse_url($address, PHP_URL_PORT);
|
||||||
|
|
||||||
|
$handshakeOk = false;
|
||||||
|
$welcome = null;
|
||||||
|
|
||||||
|
(new Connector([], $loop))
|
||||||
|
->connect("127.0.0.1:{$port}")
|
||||||
|
->then(function (ConnectionInterface $conn) use (&$handshakeOk, &$welcome, $loop) {
|
||||||
|
$buffer = '';
|
||||||
|
$upgraded = false;
|
||||||
|
|
||||||
|
$conn->on('data', function ($data) use (&$buffer, &$upgraded, &$handshakeOk, &$welcome, $conn, $loop) {
|
||||||
|
$buffer .= $data;
|
||||||
|
|
||||||
|
if (! $upgraded) {
|
||||||
|
$end = strpos($buffer, "\r\n\r\n");
|
||||||
|
if ($end === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$handshakeOk = str_contains(substr($buffer, 0, $end), ' 101 ');
|
||||||
|
$buffer = substr($buffer, $end + 4);
|
||||||
|
$upgraded = true;
|
||||||
|
if (! $handshakeOk) {
|
||||||
|
$loop->stop();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server pushes a `welcome` frame on connect (no client frame needed).
|
||||||
|
$decoded = self::decodeServerTextFrame($buffer);
|
||||||
|
if ($decoded !== null) {
|
||||||
|
$welcome = json_decode($decoded, true);
|
||||||
|
$conn->close();
|
||||||
|
$loop->stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$key = base64_encode(random_bytes(16));
|
||||||
|
$conn->write(
|
||||||
|
"GET / HTTP/1.1\r\n".
|
||||||
|
"Host: 127.0.0.1\r\n".
|
||||||
|
"Upgrade: websocket\r\n".
|
||||||
|
"Connection: Upgrade\r\n".
|
||||||
|
"Sec-WebSocket-Key: {$key}\r\n".
|
||||||
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
$loop->addTimer(3.0, fn () => $loop->stop());
|
||||||
|
$loop->run();
|
||||||
|
$server->shutdown();
|
||||||
|
|
||||||
|
$this->assertTrue($handshakeOk, 'server should return a 101 upgrade');
|
||||||
|
$this->assertSame('welcome', $welcome['type'] ?? null);
|
||||||
|
$this->assertArrayHasKey('peer', (array) $welcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode one unmasked server->client text frame (payload < 126 bytes), or null. */
|
||||||
|
private static function decodeServerTextFrame(string $data): ?string
|
||||||
|
{
|
||||||
|
if (strlen($data) < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$length = ord($data[1]) & 0x7F;
|
||||||
|
if ($length >= 126 || strlen($data) < 2 + $length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($data, 2, $length);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue