Skip to main content

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}