docs: WS relay spec — add WebSocket listener to eliminate wzp-web bridge
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) <noreply@anthropic.com>
This commit is contained in:
257
docs/WS_RELAY_SPEC.md
Normal file
257
docs/WS_RELAY_SPEC.md
Normal file
@@ -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<wzp_transport::QuinnTransport>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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<wzp_transport::QuinnTransport>),
|
||||||
|
WebSocket(tokio::sync::mpsc::Sender<bytes::Bytes>),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `others()` method returns `Vec<ParticipantSender>` instead of `Vec<Arc<QuinnTransport>>`.
|
||||||
|
|
||||||
|
`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<bytes::Bytes>,
|
||||||
|
fingerprint: Option<&str>,
|
||||||
|
) -> Result<ParticipantId, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<String>,
|
||||||
|
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::<bytes::Bytes>(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<u16>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
Reference in New Issue
Block a user