beam/adapters/ws_server.rs
1//! WebSocket server adapter — accepts inbound WebSocket connections.
2//!
3//! [`WsServer`] listens on a TCP port and accepts incoming WebSocket
4//! connections. Each connection is handled by a [`WsConn`] actor. The
5//! server also starts a web server (on `port + 1`) for peer ID discovery.
6//!
7//! # TLS Support
8//!
9//! When `cert_path` and `key_path` are configured in [`WsServerConfig`],
10//! the server uses TLS for both the WebSocket and web server. Otherwise,
11//! plain TCP is used.
12//!
13//! # Ports
14//!
15//! - WebSocket port: `ws_config.port` (default 4944)
16//! - Web UI port: `ws_config.port + 1` (default 4945)
17
18use crate::Config;
19use crate::actor::{Actor, ActorContext, Addr};
20use crate::adapters::ws_conn::WsConn;
21use crate::message::Message;
22use crate::metrics::Metrics;
23
24use crate::utils::FxHashSet;
25use async_trait::async_trait;
26use std::fs::File;
27use std::io::Read;
28use std::sync::Arc;
29use tokio::sync::RwLock;
30
31use log::{debug, info};
32use tokio::net::TcpListener;
33use tokio_native_tls::native_tls::Identity;
34use tokio_websockets::ServerBuilder;
35
36/// Shared set of connected client addresses.
37type Clients = Arc<RwLock<FxHashSet<Addr>>>;
38
39/// Configuration for the [`WsServer`] adapter.
40#[derive(Clone)]
41pub struct WsServerConfig {
42 /// Port to listen for WebSocket connections (default: 4944).
43 pub port: u16,
44 /// Path to TLS certificate file (PEM/PKCS8). If `None`, plain TCP is used.
45 pub cert_path: Option<String>,
46 /// Path to TLS private key file. Required when `cert_path` is set.
47 pub key_path: Option<String>,
48}
49
50impl Default for WsServerConfig {
51 fn default() -> Self {
52 WsServerConfig {
53 port: 4944,
54 cert_path: None,
55 key_path: None,
56 }
57 }
58}
59
60/// WebSocket server adapter that accepts inbound connections.
61///
62/// Listens on a TCP port and spawns a [`WsConn`] actor for each incoming
63/// WebSocket connection. Optionally serves a web UI on `port + 1` for
64/// peer ID discovery.
65#[derive(Clone)]
66pub struct WsServer {
67 config: Config,
68 ws_config: WsServerConfig,
69 clients: Clients,
70}
71
72impl WsServer {
73 /// Creates a new WebSocket server with default config.
74 pub fn new(config: Config) -> Self {
75 Self::new_with_config(config, WsServerConfig::default())
76 }
77
78 /// Creates a new WebSocket server with custom config.
79 ///
80 /// # Arguments
81 ///
82 /// * `config` - Node configuration
83 /// * `ws_config` - WebSocket server config (port, TLS)
84 pub fn new_with_config(config: Config, ws_config: WsServerConfig) -> Self {
85 Self {
86 config,
87 ws_config,
88 clients: Clients::default(),
89 }
90 }
91
92 /// Handles a single incoming WebSocket stream by upgrading it and
93 /// spawning a [`WsConn`] actor.
94 /// Accepts a WebSocket upgrade on an incoming stream (plain TCP or TLS).
95 ///
96 /// The stream can be either a raw `TcpStream` (plain WS) or a
97 /// `TlsStream<TcpStream>` (secure WSS). Both implement
98 /// `AsyncRead + AsyncWrite` which `ServerBuilder::accept` requires.
99 async fn handle_stream<S>(
100 stream: S,
101 ctx: &ActorContext,
102 clients: Clients,
103 allow_public_space: bool,
104 ) where
105 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
106 {
107 // Configure with an unbounded flush_threshold so that `poll_ready`
108 // never triggers an implicit `poll_flush`. Without this, the default
109 // 8 KiB threshold causes `feed()` to block on TCP I/O once the
110 // internal frame queue exceeds 8 KiB, which starves the actor's
111 // scheduler on single-threaded runtimes and can deadlock under
112 // backpressure on multi-threaded runtimes. Flushing is handled
113 // explicitly by `WsConn::handle_batch` after all messages are queued.
114 let ws_config = tokio_websockets::Config::default().flush_threshold(usize::MAX);
115 let ws_stream = match ServerBuilder::new().config(ws_config).accept(stream).await {
116 Ok((_req, s)) => s,
117 Err(e) => {
118 log::warn!("WsServer WebSocket handshake failed: {}", e);
119 return;
120 }
121 };
122
123 let conn = WsConn::new(ws_stream, allow_public_space);
124 let addr = ctx.start_actor(Box::new(conn));
125 clients.write().await.insert(addr);
126 }
127
128 /// Starts the web server for peer ID discovery.
129 ///
130 /// Serves on `config.port + 1`. Routes:
131 /// - `/peer_id` — returns this node's peer ID
132 ///
133 /// When TLS is configured, the server uses `tokio_native_tls` (the same
134 /// TLS stack as the WebSocket server) rather than warp's built-in TLS,
135 /// which was removed in warp 0.4. This keeps one TLS implementation
136 /// across the codebase (DRY).
137 async fn start_web_server(config: WsServerConfig, peer_id: String, metrics: Arc<Metrics>) {
138 let port = config.port + 1;
139
140 if let Some(cert_path) = config.cert_path {
141 let key_path = config.key_path.unwrap();
142 let _addr = format!("https://localhost:{}", port);
143
144 // Load TLS identity (same pattern as WebSocket TLS above)
145 let cert = std::fs::read(cert_path).expect("failed to read cert file");
146 let key = std::fs::read(key_path).expect("failed to read key file");
147 let identity = tokio_native_tls::native_tls::Identity::from_pkcs8(&cert, &key)
148 .expect("failed to create TLS identity");
149 let acceptor = tokio_native_tls::TlsAcceptor::from(
150 tokio_native_tls::native_tls::TlsAcceptor::new(identity).unwrap(),
151 );
152
153 // Auxiliary surface: if the web UI port is unavailable (e.g. a
154 // lingering listener from a previous instance), degrade to
155 // running without the web UI rather than panicking — the
156 // WebSocket core is independent of it.
157 let listener = match tokio::net::TcpListener::bind(("0.0.0.0", port)).await {
158 Ok(l) => l,
159 Err(e) => {
160 log::warn!(
161 "web UI port {} unavailable ({}); continuing without web UI",
162 port,
163 e
164 );
165 return;
166 }
167 };
168
169 loop {
170 let (stream, _) = match listener.accept().await {
171 Ok(s) => s,
172 Err(e) => {
173 log::error!("web UI accept error: {}", e);
174 continue;
175 }
176 };
177 let acceptor = acceptor.clone();
178 let peer_id = peer_id.clone();
179 let metrics_clone = metrics.clone();
180 crate::tokio_spawn::spawn(async move {
181 let stream = match acceptor.accept(stream).await {
182 Ok(s) => s,
183 Err(_) => return,
184 };
185 Self::handle_http_request(stream, &peer_id, &metrics_clone).await;
186 });
187 }
188 }
189
190 // Plain HTTP — manual handler (no warp dependency).
191 // Auxiliary surface: degrade to no-web-UI on bind failure (see
192 // TLS branch above) — never panic the worker over it.
193 let _addr = format!("http://localhost:{}", port);
194 let listener = match tokio::net::TcpListener::bind(("0.0.0.0", port)).await {
195 Ok(l) => l,
196 Err(e) => {
197 log::warn!(
198 "web UI port {} unavailable ({}); continuing without web UI",
199 port,
200 e
201 );
202 return;
203 }
204 };
205 loop {
206 let (stream, _) = match listener.accept().await {
207 Ok(s) => s,
208 Err(e) => {
209 log::error!("web UI accept error: {}", e);
210 continue;
211 }
212 };
213 let peer_id = peer_id.clone();
214 let metrics_clone = metrics.clone();
215 crate::tokio_spawn::spawn(async move {
216 Self::handle_http_request_plain(stream, &peer_id, &metrics_clone).await;
217 });
218 }
219 }
220
221 /// Handle a single HTTP request over a TLS stream.
222 ///
223 /// Reads one HTTP/1.1 request, responds based on the path:
224 /// - `/peer_id` — returns this node's peer ID as plain text
225 /// - `/metrics` — returns the current metrics snapshot as JSON
226 ///
227 /// Minimal handler — no routing framework needed for two endpoints.
228 /// Core HTTP request handler — shared between TLS and plain TCP paths.
229 ///
230 /// Routes: `/peer_id` → plain text, `/metrics` → JSON, else 404.
231 fn build_http_response(request: &str, peer_id: &str, metrics: &Metrics) -> Option<String> {
232 let request_line = request.lines().next().unwrap_or("");
233
234 if request_line.contains("GET /peer_id") {
235 Some(format!(
236 "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
237 peer_id.len(),
238 peer_id
239 ))
240 } else if request_line.contains("GET /metrics") {
241 let body = serde_json::to_string_pretty(&metrics.snapshot())
242 .unwrap_or_else(|_| "{}".to_string());
243 Some(format!(
244 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
245 body.len(),
246 body
247 ))
248 } else {
249 None
250 }
251 }
252
253 /// Handle a single HTTP request over a TLS stream.
254 async fn handle_http_request(
255 mut stream: tokio_native_tls::TlsStream<tokio::net::TcpStream>,
256 peer_id: &str,
257 metrics: &Metrics,
258 ) {
259 use tokio::io::{AsyncReadExt, AsyncWriteExt};
260
261 let mut buf = [0u8; 1024];
262 let n = match stream.read(&mut buf).await {
263 Ok(n) => n,
264 Err(_) => return,
265 };
266
267 let request = String::from_utf8_lossy(&buf[..n]);
268 let response = match Self::build_http_response(&request, peer_id, metrics) {
269 Some(r) => r,
270 None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
271 .to_string(),
272 };
273
274 let _ = stream.write_all(response.as_bytes()).await;
275 let _ = stream.shutdown().await;
276 }
277
278 /// Handle a single HTTP request over a plain TCP stream.
279 async fn handle_http_request_plain(
280 mut stream: tokio::net::TcpStream,
281 peer_id: &str,
282 metrics: &Metrics,
283 ) {
284 use tokio::io::{AsyncReadExt, AsyncWriteExt};
285
286 let mut buf = [0u8; 1024];
287 let n = match stream.read(&mut buf).await {
288 Ok(n) => n,
289 Err(_) => return,
290 };
291
292 let request = String::from_utf8_lossy(&buf[..n]);
293 let response = match Self::build_http_response(&request, peer_id, metrics) {
294 Some(r) => r,
295 None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
296 .to_string(),
297 };
298
299 let _ = stream.write_all(response.as_bytes()).await;
300 let _ = stream.shutdown().await;
301 }
302
303 /// Returns the current number of connected WebSocket peers.
304 ///
305 /// Test-only helper to poll mesh readiness without relying on
306 /// blind sleeps. The count reflects the number of [`WsConn`]
307 /// actors that have completed the WebSocket handshake and
308 /// registered themselves in the server's client set.
309 ///
310 /// # Use
311 ///
312 /// ```ignore
313 /// // Wait for both peers to connect before broadcasting a Put.
314 /// while ws_server.peer_count() < 2 {
315 /// sleep(Duration::from_millis(50)).await;
316 /// }
317 /// ```
318 pub fn peer_count(&self) -> usize {
319 // Try a non-blocking read. If the lock is held, treat as "no
320 // observed peers yet" so the caller polls again. Blocking
321 // would risk stalling the actor's mailbox.
322 if let Ok(count) = self.clients.try_read() {
323 count.len()
324 } else {
325 0
326 }
327 }
328}
329
330#[async_trait]
331impl Actor for WsServer {
332 /// Relays wire-format messages (Put, Get, Hi, BatchPut, Flush, RtcSignal)
333 /// to all connected WebSocket clients except the sender. Internal
334 /// messages (CheckQuorumTimeouts, RegisterQuorum) are never relayed —
335 /// they are router-internal and would produce empty or malformed frames
336 /// on the wire.
337 async fn handle(&mut self, msg: Arc<Message>, _ctx: &ActorContext) {
338 // Only relay messages that have a valid wire representation.
339 // RegisterQuorum serializes to an empty string; CheckQuorumTimeouts
340 // serializes to "_tick_quorum" — neither is a valid Gun.js wire
341 // message and both would cause parse errors in connected peers.
342 match &*msg {
343 Message::Put(_)
344 | Message::Get(_)
345 | Message::BatchPut(_)
346 | Message::Hi { .. }
347 | Message::Flush(_)
348 | Message::RtcSignal(_) => {}
349 Message::CheckQuorumTimeouts | Message::RegisterQuorum { .. } => return,
350 }
351
352 for conn in self.clients.read().await.iter() {
353 if msg.is_from(conn) {
354 continue;
355 }
356 if conn.send((*msg).clone()).is_err() {
357 self.clients.write().await.remove(conn);
358 }
359 }
360 }
361
362 async fn pre_start(&mut self, ctx: &ActorContext) {
363 let addr = format!("0.0.0.0:{}", self.ws_config.port).to_string();
364 let ctx = ctx.clone();
365
366 let peer_id = ctx.peer_id.read().clone();
367 let config_clone = self.ws_config.clone();
368 let metrics = ctx.metrics.clone();
369 ctx.child_task(async move {
370 Self::start_web_server(config_clone, peer_id, metrics).await;
371 });
372
373 // Create the TCP listener
374 let try_socket = TcpListener::bind(&addr).await;
375 let listener = try_socket.expect("Failed to bind");
376
377 let allow_public_space = self.config.allow_public_space;
378 let clients = self.clients.clone();
379 if let Some(cert_path) = &self.ws_config.cert_path {
380 let mut cert_file = File::open(cert_path).unwrap();
381 let mut cert = vec![];
382 cert_file.read_to_end(&mut cert).unwrap();
383
384 let key_path = self.ws_config.key_path.as_ref().unwrap();
385 let mut key_file = File::open(key_path).unwrap();
386 let mut key = vec![];
387 key_file.read_to_end(&mut key).unwrap();
388
389 let identity = Identity::from_pkcs8(&cert, &key).unwrap();
390 let acceptor = tokio_native_tls::native_tls::TlsAcceptor::new(identity).unwrap();
391 let acceptor = tokio_native_tls::TlsAcceptor::from(acceptor);
392 let acceptor = Arc::new(acceptor);
393
394 let mut shutdown_rx = ctx.shutdown_rx.clone();
395 ctx.clone().child_task(async move {
396 loop {
397 tokio::select! {
398 biased;
399 _ = shutdown_rx.changed() => {
400 debug!("WsServer TLS accept loop shutting down");
401 break;
402 }
403 result = listener.accept() => {
404 if let Ok((stream, _)) = result {
405 let acceptor = acceptor.clone();
406 let clients = clients.clone();
407 let ctx = ctx.clone();
408 crate::tokio_spawn::spawn(async move {
409 let stream = acceptor.accept(stream).await;
410 if let Ok(stream) = stream {
411 Self::handle_stream(
412 stream,
413 &ctx,
414 clients.clone(),
415 allow_public_space,
416 )
417 .await;
418 }
419 });
420 }
421 }
422 }
423 }
424 });
425 } else {
426 let mut shutdown_rx = ctx.shutdown_rx.clone();
427 ctx.clone().child_task(async move {
428 loop {
429 tokio::select! {
430 biased;
431 _ = shutdown_rx.changed() => {
432 debug!("WsServer plain accept loop shutting down");
433 break;
434 }
435 result = listener.accept() => {
436 if let Ok((stream, _)) = result {
437 Self::handle_stream(
438 stream,
439 &ctx,
440 clients.clone(),
441 allow_public_space,
442 )
443 .await;
444 }
445 }
446 }
447 }
448 });
449 }
450 }
451
452 /// WsServer is a relay adapter — it fans out Puts to all connected
453 /// WebSocket clients. Must return `true` so the Router adds it to
454 /// `server_peers` and relays Put messages through it.
455 fn subscribe_to_everything(&self) -> bool {
456 true
457 }
458
459 /// WsServer is a relay server — it accepts incoming connections and
460 /// fans out to individual WsConn clients. The Router always relays
461 /// to WsServer (even for messages from remote peers) because the
462 /// WsServer handles per-connection echo-back via `msg.is_from(conn)`.
463 fn is_relay_server(&self) -> bool {
464 true
465 }
466
467 async fn stopping(&mut self, _context: &ActorContext) {
468 info!(
469 "WsServer stopping — closing {} client connections",
470 self.clients.read().await.len()
471 );
472 // Dropping the client Addr senders closes their channels. The WsConn
473 // actors will receive stop signals via ActorContext::stop() and send
474 // WebSocket Close frames in their own stopping(). Here we just clear
475 // the set so no new fan-out attempts are made.
476 self.clients.write().await.clear();
477 }
478}