From 59bf3f65876ab51a9c2c95071c6a558dad442cf9 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 30 Mar 2026 14:27:44 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20WS=20relay=20spec=20=E2=80=94=20add=20W?= =?UTF-8?q?ebSocket=20listener=20to=20eliminate=20wzp-web=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detailed implementation plan for adding WS support directly to wzp-relay: - Abstract Participant over transport type (Quic + WebSocket enum) - New --ws-port flag for browser connections - Cross-transport fan-out (QUIC↔WS in same rooms) - Auth, room management, session cleanup unchanged - Eliminates wzp-web container entirely Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/WS_RELAY_SPEC.md | 257 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/WS_RELAY_SPEC.md diff --git a/docs/WS_RELAY_SPEC.md b/docs/WS_RELAY_SPEC.md new file mode 100644 index 0000000..46a55df --- /dev/null +++ b/docs/WS_RELAY_SPEC.md @@ -0,0 +1,257 @@ +# WS Support in wzp-relay — Implementation Spec + +## Goal + +Add WebSocket listener to `wzp-relay` so browsers connect directly, eliminating `wzp-web` bridge. + +``` +Before: Browser → WS → wzp-web → QUIC → wzp-relay +After: Browser → WS → wzp-relay (handles both WS + QUIC) +``` + +## Architecture + +``` +wzp-relay +├── QUIC listener (:4433) — native clients, inter-relay +├── WS listener (:8080) — browsers via Caddy +│ ├── GET /ws/{room} — WebSocket upgrade +│ └── Auth: first msg = {"type":"auth","token":"..."} +└── Shared RoomManager — both transports in same rooms +``` + +## Key Changes + +### 1. Abstract `Participant` over transport type + +**File: `room.rs`** + +Currently: +```rust +struct Participant { + id: ParticipantId, + _addr: std::net::SocketAddr, + transport: Arc, +} +``` + +Change to: +```rust +struct Participant { + id: ParticipantId, + _addr: std::net::SocketAddr, + sender: ParticipantSender, +} + +/// How to send a media packet to a participant. +enum ParticipantSender { + Quic(Arc), + WebSocket(tokio::sync::mpsc::Sender), +} +``` + +The `others()` method returns `Vec` instead of `Vec>`. + +`ParticipantSender` implements a `send_pcm(&self, data: &[u8])` method: +- **Quic**: wraps in `MediaPacket`, calls `transport.send_media()` +- **WebSocket**: sends raw binary frame via the mpsc channel + +### 2. Add `join_ws()` to RoomManager + +```rust +pub fn join_ws( + &mut self, + room_name: &str, + addr: std::net::SocketAddr, + sender: tokio::sync::mpsc::Sender, + fingerprint: Option<&str>, +) -> Result +``` + +### 3. Add WS listener in `main.rs` + +New flag: `--ws-port 8080` + +```rust +if let Some(ws_port) = config.ws_port { + let room_mgr = room_mgr.clone(); + let auth_url = config.auth_url.clone(); + let metrics = metrics.clone(); + tokio::spawn(run_ws_server(ws_port, room_mgr, auth_url, metrics)); +} +``` + +### 4. WebSocket handler (`ws.rs` — new file) + +```rust +use axum::{ + extract::{ws::{Message, WebSocket}, Path, WebSocketUpgrade}, + routing::get, + Router, +}; + +async fn ws_handler( + Path(room): Path, + ws: WebSocketUpgrade, + /* state */ +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws(socket, room, state)) +} + +async fn handle_ws(mut socket: WebSocket, room: String, state: WsState) { + let addr = /* peer addr */; + + // 1. Auth: first message must be {"type":"auth","token":"..."} + let fingerprint = if let Some(ref auth_url) = state.auth_url { + match socket.recv().await { + Some(Ok(Message::Text(text))) => { + let parsed: serde_json::Value = serde_json::from_str(&text)?; + if parsed["type"] == "auth" { + let token = parsed["token"].as_str().unwrap(); + let client = auth::validate_token(auth_url, token).await?; + Some(client.fingerprint) + } else { return; } + } + _ => return, + } + } else { None }; + + // 2. Create mpsc channel for outbound frames + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); + + // 3. Join room + let participant_id = { + let mut mgr = state.room_mgr.lock().await; + mgr.join_ws(&room, addr, tx, fingerprint.as_deref())? + }; + + // 4. Run send/recv loops + let (mut ws_tx, mut ws_rx) = socket.split(); + + // Outbound: mpsc rx → WS send + let send_task = tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if ws_tx.send(Message::Binary(data.to_vec())).await.is_err() { + break; + } + } + }); + + // Inbound: WS recv → fan-out to room + loop { + match ws_rx.next().await { + Some(Ok(Message::Binary(data))) => { + // Raw PCM Int16 from browser — fan-out to all others + let others = { + let mgr = state.room_mgr.lock().await; + mgr.others(&room, participant_id) + }; + for other in &others { + other.send_raw(&data); + } + } + Some(Ok(Message::Close(_))) | None => break, + _ => continue, + } + } + + // 5. Cleanup + send_task.abort(); + let mut mgr = state.room_mgr.lock().await; + mgr.leave(&room, participant_id); +} +``` + +### 5. Cross-transport fan-out + +When a QUIC participant sends audio → WS participants receive raw PCM bytes. +When a WS participant sends audio → QUIC participants receive a `MediaPacket`. + +The `ParticipantSender::send_raw()` method: +```rust +impl ParticipantSender { + async fn send_raw(&self, pcm_bytes: &[u8]) { + match self { + ParticipantSender::WebSocket(tx) => { + let _ = tx.try_send(bytes::Bytes::copy_from_slice(pcm_bytes)); + } + ParticipantSender::Quic(transport) => { + // Wrap raw PCM in a MediaPacket + let pkt = MediaPacket { + header: MediaHeader::default_pcm(), + payload: bytes::Bytes::copy_from_slice(pcm_bytes), + quality_report: None, + }; + let _ = transport.send_media(&pkt).await; + } + } + } +} +``` + +For QUIC→WS direction, `run_participant` extracts `pkt.payload` bytes and sends to WS channels. + +### 6. Dependencies to add + +```toml +# wzp-relay/Cargo.toml +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["full"] } # already present +``` + +### 7. Config change + +```rust +// config.rs +pub struct RelayConfig { + // ... existing fields ... + pub ws_port: Option, +} +``` + +### 8. Docker compose change (featherChat side) + +Remove `wzp-web` service entirely. Update Caddy to proxy `/audio/*` to relay's WS port: + +```yaml +# Before: +wzp-web: + entrypoint: ["wzp-web"] + command: ["--port", "8080", "--relay", "172.28.0.10:4433"] + +# After: REMOVED. Relay handles WS directly. + +wzp-relay: + command: + - "--listen" + - "0.0.0.0:4433" + - "--ws-port" + - "8080" + - "--auth-url" + - "http://warzone-server:7700/v1/auth/validate" +``` + +## What Stays the Same + +- Browser's `startAudio()` — unchanged, still connects WS to `/audio/ws/ROOM` +- Caddy proxies `/audio/*` → relay:8080 (same path, different backend) +- Auth flow — same JSON token as first message +- PCM format — same Int16 binary frames +- QUIC clients — unchanged, still connect to :4433 +- Room naming, ACL, session management — all unchanged + +## Testing + +1. Start relay with `--ws-port 8080 --listen 0.0.0.0:4433` +2. Open browser, initiate call via featherChat +3. Verify audio flows (both directions) +4. Verify QUIC + WS clients can be in same room (mixed mode) +5. Verify auth works +6. Verify room cleanup on disconnect + +## Migration Path + +1. Implement WS in relay +2. Test with featherChat (no featherChat changes needed) +3. Remove wzp-web from Docker stack +4. Later: add WebTransport alongside WS