# 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