177 lines
9.8 KiB
Markdown
177 lines
9.8 KiB
Markdown
[](https://github.com/blax-software)
|
|
|
|
# Laravel WebRTC
|
|
|
|
[](https://php.net)
|
|
[](https://laravel.com)
|
|
[](https://github.com/blax-software/reactphp-kernel)
|
|
[](#testing)
|
|
[](#testing)
|
|
[](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` → `<room>/<peer>.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** — 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
|
|
- 🔌 **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"
|
|
```
|
|
|
|
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
|
|
|
|
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": "<id>", "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": "<peer>", "data": { ...sdp | candidate... } }
|
|
← { "type": "relay", "from": "<peer>", "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 <room>/<peer>.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`.
|
|
|
|
## 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
|
|
|
|
```
|
|
reactphp-kernel shared backbone (loop, IPC, signals)
|
|
├─ laravel-ws WebSocket transport (RFC6455)
|
|
└─ laravel-webrtc (this)
|
|
├─ RelaySignalingHandler browser P2P calls (no media engine needed)
|
|
└─ MediaEngine (optional) server-terminated media
|
|
└─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/)
|
|
```
|
|
|
|
## 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
|
|
|
|
<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>
|