[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) # Laravel WebRTC [![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue?style=flat-square)](https://php.net) [![Laravel](https://img.shields.io/badge/laravel-10.x--12.x-orange?style=flat-square)](https://laravel.com) [![Built on](https://img.shields.io/badge/on-reactphp--kernel-4b275f?style=flat-square)](https://github.com/blax-software/reactphp-kernel) [![Tests](https://img.shields.io/badge/tests-35%20passing-success?style=flat-square)](#testing) [![Assertions](https://img.shields.io/badge/assertions-99-blue?style=flat-square)](#testing) [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE) 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 - πŸ“ž **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 - πŸ‘₯ **Rooms & mesh signaling** β€” peer discovery on join, `peer-joined` / `peer-left` notifications, targeted `relay`, and room `broadcast` - 🏷️ **Typed rooms** β€” `public`, `private-*` (authorized), `presence-*` (authorized + member list), `open-presence-*` β€” derived from the room name, like laravel-websockets channels - ⏺️ **Record the full call** β€” the browser streams its mic over the WebSocket and the server stores each participant's audio (`FileRecordingStore` β†’ `/.webm`); + a call-event log seam for auditing even unrecorded calls - 🧩 **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 - πŸŽ™οΈ **Server-side media, when you need it** β€” a pluggable `MediaEngine` terminates media for **recording / intercept**, **AI-realtime bridging**, and **SFU** group calls - πŸ€– **AI realtime, provider hidden** β€” `OpenAiRealtimeBridge` relays a caller to OpenAI's Realtime API **server-side** (PCM in β†’ spoken reply out, recordable), so the browser only ever talks to *you* and the model is a config swap - πŸ”€ **Use it on any transport** β€” the domain layer (rooms, recording, the turn-based realtime bridge) is transport-agnostic: run it on the bundled ReactPHP server *or* drive it from a WebSocket you already have; a swappable `RoomStore` makes membership Cache-backed for fork-per-message hosts - πŸ”Œ **Pluggable engine** β€” `NullMediaEngine` (signaling-only) now; `Str0mMediaEngine` (Rust `str0m` via `ext-php-rs`, shipped in `rust/`) for real server-terminated media ## Installation ```bash composer require blax-software/laravel-webrtc php artisan vendor:publish --tag="webrtc-config" ``` The domain layer (rooms, recording, the realtime bridge) needs only `illuminate/*` + `textalk/websocket`. The **bundled ReactPHP server** (`webrtc:serve`) additionally uses [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) + [`laravel-ws`](https://github.com/blax-software/laravel-ws) β€” these are `require-dev`, so a host that runs the relay on its own WebSocket doesn't pull them (see [Use it on your own WebSocket](#use-it-on-your-own-websocket-no-bundled-server)). ## Quick Start Run the signaling relay (browsers connect here): ```bash php artisan webrtc:serve # ws://127.0.0.1:8090 by default ``` 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": "", "record": false } // record:true => also stream your mic β†’ { "type": "join", "room": "lobby", "auth": {}, "info": {} } ← { "type": "joined", "room", "roomType", "record", "peers", "members"? } // members: presence rooms only ← { "type": "peer-joined", "room", "peer", "info"? } // sent to the others β†’ { "type": "relay", "to": "", "data": { ...sdp | candidate... } } ← { "type": "relay", "from": "", "data": { ... } } // delivered to the target β†’ { "type": "broadcast", "data": { ... } } // to the rest of your room β†’ { "type": "leave" } ← { "type": "left" } / others get { "type": "peer-left", "peer" } (binary frames) β†’ appended to YOUR recording (when record=true) ``` ### Room types The type is derived from the room name prefix, like laravel-websockets channels: | Prefix | Type | Who can join | Member list shared | |---|---|---|---| | *(none)* | public | anyone | no | | `private-` | private | via `RoomAuthorizer` | no | | `presence-` | presence | via `RoomAuthorizer` | yes | | `open-presence-` | open presence | anyone | yes | `private-*` / `presence-*` joins are denied by default β€” bind an authorizer that validates the join and returns the member's presence info: ```dotenv WEBRTC_AUTHORIZER="App\WebRtc\MyRoomAuthorizer" ``` ```php final class MyRoomAuthorizer implements Blax\WebRtc\Contracts\RoomAuthorizer { public function authorize(string $peerId, string $roomId, array $auth): ?array { // validate $auth['token'] for $roomId; return presence info, or null to deny return ['name' => /* the authorized user's name */]; } } ``` ### Recording the full call A pure-P2P call's audio is DTLS-SRTP end-to-end, so the server can't capture it. To store the full call, the browser MediaRecords its mic and streams the chunks to the server as **binary WebSocket frames** when `record` is set; the server appends them per participant. ```dotenv WEBRTC_RECORDING=true WEBRTC_RECORDING_PATH=/var/recordings # writes /.webm ``` ```js // after `welcome` / `joined` reports record: true const rec = new MediaRecorder(micStream, { mimeType: 'audio/webm;codecs=opus' }) rec.ondataavailable = (e) => e.data.size && e.data.arrayBuffer().then((b) => ws.send(b)) // binary frame rec.start(250) ``` Recordings finalize on leave/disconnect. Swap `FileRecordingStore` for your own `RecordingStore` (S3, DB blob) and bind a `CallEventListener` (`WEBRTC_EVENTS`) to persist call records β€” participants, duration, and the finalized recording locator β€” even for calls you don't record. ### Server-terminated media (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 WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine" ``` 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`. ### Use it on your own WebSocket (no bundled server) The domain layer is transport-agnostic, so a host that already runs a WebSocket β€” including a classic **fork-per-message** Laravel WS β€” can drive the relay itself and skip this package's ReactPHP server entirely. Resolve the pieces and relay with your own transport: ```php use Blax\WebRtc\Rooms\RoomManager; use Blax\WebRtc\Contracts\RecordingStore; use Blax\WebRtc\Realtime\OpenAiRealtimeBridge; // Typed rooms + membership β€” join() returns the peers already there to notify: $others = app(RoomManager::class)->join($room, $peerId); foreach ($others as $peer) $yourWs->sendTo($peer, ['type' => 'peer-joined', 'peer' => $peerId]); // Store the call (your transport streams the mic to you however it likes): app(RecordingStore::class)->append($room, $peerId, $chunk); // An AI joins the call β€” provider hidden, turn-based (one round-trip per push-to-talk): $turn = app(OpenAiRealtimeBridge::class)->exchange($pcm, [ 'api_key' => config('services.openai.key'), 'voice' => 'alloy', 'instructions' => '…', ]); // $turn->pcm = PCM16 24kHz reply, $turn->transcript = text β€” play + record it. ``` Membership lives in a swappable `RoomStore`. The default `ArrayRoomStore` keeps it in process memory (right for the long-lived bundled server); a fork-per-message host, which has no shared memory across frames, binds a Cache/Redis-backed one so state survives across workers: ```php // config/webrtc.php 'room_store' => \App\Support\CacheRoomStore::class, // implements Blax\WebRtc\Contracts\RoomStore ``` `OpenAiRealtimeBridge` talks to a `RealtimeTransport` (textalk/websocket by default), so it's unit-testable with a scripted fake. This is exactly how learn-atc runs the relay + a recorded OpenAI ATC participant on its existing WebSocket, without `laravel-ws`/`reactphp-kernel` installed. ## Configuration `config/webrtc.php` covers the signaling bind (`host`/`port`/`tls`), `recording` (`enabled`/`path`/`format`), the `authorizer` (room join gate) and `events` (call log) bindings, the `media_engine`, advertised `ice_servers`, and the external `bridge` (e.g. OpenAI model + key). ## Status **Working package.** Browser-to-browser (mesh) calls, typed rooms (public / private / presence / open-presence) with authorization + presence, and full per-participant **call recording** (mic streamed over the WS, stored to disk) all work today and are tested (incl. a real WebSocket round-trip). The only remaining piece is *server-terminated SRTP* media (an in-path SFU/recorder for AI bridging or huge groups) β€” that's the `Str0mMediaEngine` + `rust/` core, the next build. ## Architecture ``` laravel-webrtc (this) β”œβ”€ Domain layer (transport-agnostic β€” needs no bundled server) β”‚ β”œβ”€ RoomManager + RoomStore typed rooms, membership, presence β”‚ β”œβ”€ RecordingStore per-participant call recording β”‚ └─ OpenAiRealtimeBridge AI joins the call, provider hidden β”‚ β”œβ”€ Bundled server (require-dev) run it for you… β”‚ reactphp-kernel shared backbone (loop, IPC, signals) β”‚ └─ laravel-ws WebSocket transport (RFC6455) β”‚ └─ RelaySignalingHandler browser P2P calls over the loop β”‚ └─ MediaEngine (optional) …or terminate media server-side └─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/) ``` The domain layer runs on **any** transport (the bundled server, or a host WS like learn-atc's fork-per-message WebSocket). The bundled server + media engine are opt-in. ## Testing ```bash composer install composer test ``` 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 MIT. See [LICENSE](LICENSE). ## Star History Star History Chart