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            let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
154                .await
155                .expect("failed to bind web UI port");
156
157            loop {
158                let (stream, _) = match listener.accept().await {
159                    Ok(s) => s,
160                    Err(e) => {
161                        log::error!("web UI accept error: {}", e);
162                        continue;
163                    }
164                };
165                let acceptor = acceptor.clone();
166                let peer_id = peer_id.clone();
167                let metrics_clone = metrics.clone();
168                crate::tokio_spawn::spawn(async move {
169                    let stream = match acceptor.accept(stream).await {
170                        Ok(s) => s,
171                        Err(_) => return,
172                    };
173                    Self::handle_http_request(stream, &peer_id, &metrics_clone).await;
174                });
175            }
176        }
177
178        // Plain HTTP — manual handler (no warp dependency).
179        let _addr = format!("http://localhost:{}", port);
180        let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
181            .await
182            .expect("failed to bind web UI port");
183        loop {
184            let (stream, _) = match listener.accept().await {
185                Ok(s) => s,
186                Err(e) => {
187                    log::error!("web UI accept error: {}", e);
188                    continue;
189                }
190            };
191            let peer_id = peer_id.clone();
192            let metrics_clone = metrics.clone();
193            crate::tokio_spawn::spawn(async move {
194                Self::handle_http_request_plain(stream, &peer_id, &metrics_clone).await;
195            });
196        }
197    }
198
199    /// Handle a single HTTP request over a TLS stream.
200    ///
201    /// Reads one HTTP/1.1 request, responds based on the path:
202    /// - `/peer_id` — returns this node's peer ID as plain text
203    /// - `/metrics` — returns the current metrics snapshot as JSON
204    ///
205    /// Minimal handler — no routing framework needed for two endpoints.
206    /// Core HTTP request handler — shared between TLS and plain TCP paths.
207    ///
208    /// Routes: `/peer_id` → plain text, `/metrics` → JSON, else 404.
209    fn build_http_response(request: &str, peer_id: &str, metrics: &Metrics) -> Option<String> {
210        let request_line = request.lines().next().unwrap_or("");
211
212        if request_line.contains("GET /peer_id") {
213            Some(format!(
214                "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
215                peer_id.len(),
216                peer_id
217            ))
218        } else if request_line.contains("GET /metrics") {
219            let body = serde_json::to_string_pretty(&metrics.snapshot())
220                .unwrap_or_else(|_| "{}".to_string());
221            Some(format!(
222                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
223                body.len(),
224                body
225            ))
226        } else {
227            None
228        }
229    }
230
231    /// Handle a single HTTP request over a TLS stream.
232    async fn handle_http_request(
233        mut stream: tokio_native_tls::TlsStream<tokio::net::TcpStream>,
234        peer_id: &str,
235        metrics: &Metrics,
236    ) {
237        use tokio::io::{AsyncReadExt, AsyncWriteExt};
238
239        let mut buf = [0u8; 1024];
240        let n = match stream.read(&mut buf).await {
241            Ok(n) => n,
242            Err(_) => return,
243        };
244
245        let request = String::from_utf8_lossy(&buf[..n]);
246        let response = match Self::build_http_response(&request, peer_id, metrics) {
247            Some(r) => r,
248            None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
249                .to_string(),
250        };
251
252        let _ = stream.write_all(response.as_bytes()).await;
253        let _ = stream.shutdown().await;
254    }
255
256    /// Handle a single HTTP request over a plain TCP stream.
257    async fn handle_http_request_plain(
258        mut stream: tokio::net::TcpStream,
259        peer_id: &str,
260        metrics: &Metrics,
261    ) {
262        use tokio::io::{AsyncReadExt, AsyncWriteExt};
263
264        let mut buf = [0u8; 1024];
265        let n = match stream.read(&mut buf).await {
266            Ok(n) => n,
267            Err(_) => return,
268        };
269
270        let request = String::from_utf8_lossy(&buf[..n]);
271        let response = match Self::build_http_response(&request, peer_id, metrics) {
272            Some(r) => r,
273            None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
274                .to_string(),
275        };
276
277        let _ = stream.write_all(response.as_bytes()).await;
278        let _ = stream.shutdown().await;
279    }
280
281    /// Returns the current number of connected WebSocket peers.
282    ///
283    /// Test-only helper to poll mesh readiness without relying on
284    /// blind sleeps. The count reflects the number of [`WsConn`]
285    /// actors that have completed the WebSocket handshake and
286    /// registered themselves in the server's client set.
287    ///
288    /// # Use
289    ///
290    /// ```ignore
291    /// // Wait for both peers to connect before broadcasting a Put.
292    /// while ws_server.peer_count() < 2 {
293    ///     sleep(Duration::from_millis(50)).await;
294    /// }
295    /// ```
296    pub fn peer_count(&self) -> usize {
297        // Try a non-blocking read. If the lock is held, treat as "no
298        // observed peers yet" so the caller polls again. Blocking
299        // would risk stalling the actor's mailbox.
300        if let Ok(count) = self.clients.try_read() {
301            count.len()
302        } else {
303            0
304        }
305    }
306}
307
308#[async_trait]
309impl Actor for WsServer {
310    /// Relays wire-format messages (Put, Get, Hi, BatchPut, Flush, RtcSignal)
311    /// to all connected WebSocket clients except the sender. Internal
312    /// messages (CheckQuorumTimeouts, RegisterQuorum) are never relayed —
313    /// they are router-internal and would produce empty or malformed frames
314    /// on the wire.
315    async fn handle(&mut self, msg: Arc<Message>, _ctx: &ActorContext) {
316        // Only relay messages that have a valid wire representation.
317        // RegisterQuorum serializes to an empty string; CheckQuorumTimeouts
318        // serializes to "_tick_quorum" — neither is a valid Gun.js wire
319        // message and both would cause parse errors in connected peers.
320        match &*msg {
321            Message::Put(_)
322            | Message::Get(_)
323            | Message::BatchPut(_)
324            | Message::Hi { .. }
325            | Message::Flush(_)
326            | Message::RtcSignal(_) => {}
327            Message::CheckQuorumTimeouts | Message::RegisterQuorum { .. } => return,
328        }
329
330        for conn in self.clients.read().await.iter() {
331            if msg.is_from(conn) {
332                continue;
333            }
334            if conn.send((*msg).clone()).is_err() {
335                self.clients.write().await.remove(conn);
336            }
337        }
338    }
339
340    async fn pre_start(&mut self, ctx: &ActorContext) {
341        let addr = format!("0.0.0.0:{}", self.ws_config.port).to_string();
342        let ctx = ctx.clone();
343
344        let peer_id = ctx.peer_id.read().clone();
345        let config_clone = self.ws_config.clone();
346        let metrics = ctx.metrics.clone();
347        ctx.child_task(async move {
348            Self::start_web_server(config_clone, peer_id, metrics).await;
349        });
350
351        // Create the TCP listener
352        let try_socket = TcpListener::bind(&addr).await;
353        let listener = try_socket.expect("Failed to bind");
354
355        let allow_public_space = self.config.allow_public_space;
356        let clients = self.clients.clone();
357        if let Some(cert_path) = &self.ws_config.cert_path {
358            let mut cert_file = File::open(cert_path).unwrap();
359            let mut cert = vec![];
360            cert_file.read_to_end(&mut cert).unwrap();
361
362            let key_path = self.ws_config.key_path.as_ref().unwrap();
363            let mut key_file = File::open(key_path).unwrap();
364            let mut key = vec![];
365            key_file.read_to_end(&mut key).unwrap();
366
367            let identity = Identity::from_pkcs8(&cert, &key).unwrap();
368            let acceptor = tokio_native_tls::native_tls::TlsAcceptor::new(identity).unwrap();
369            let acceptor = tokio_native_tls::TlsAcceptor::from(acceptor);
370            let acceptor = Arc::new(acceptor);
371
372            let mut shutdown_rx = ctx.shutdown_rx.clone();
373            ctx.clone().child_task(async move {
374                loop {
375                    tokio::select! {
376                        biased;
377                        _ = shutdown_rx.changed() => {
378                            debug!("WsServer TLS accept loop shutting down");
379                            break;
380                        }
381                        result = listener.accept() => {
382                            if let Ok((stream, _)) = result {
383                                let acceptor = acceptor.clone();
384                                let clients = clients.clone();
385                                let ctx = ctx.clone();
386                                crate::tokio_spawn::spawn(async move {
387                                    let stream = acceptor.accept(stream).await;
388                                    if let Ok(stream) = stream {
389                                        Self::handle_stream(
390                                            stream,
391                                            &ctx,
392                                            clients.clone(),
393                                            allow_public_space,
394                                        )
395                                        .await;
396                                    }
397                                });
398                            }
399                        }
400                    }
401                }
402            });
403        } else {
404            let mut shutdown_rx = ctx.shutdown_rx.clone();
405            ctx.clone().child_task(async move {
406                loop {
407                    tokio::select! {
408                        biased;
409                        _ = shutdown_rx.changed() => {
410                            debug!("WsServer plain accept loop shutting down");
411                            break;
412                        }
413                        result = listener.accept() => {
414                            if let Ok((stream, _)) = result {
415                                Self::handle_stream(
416                                    stream,
417                                    &ctx,
418                                    clients.clone(),
419                                    allow_public_space,
420                                )
421                                .await;
422                            }
423                        }
424                    }
425                }
426            });
427        }
428    }
429
430    /// WsServer is a relay adapter — it fans out Puts to all connected
431    /// WebSocket clients. Must return `true` so the Router adds it to
432    /// `server_peers` and relays Put messages through it.
433    fn subscribe_to_everything(&self) -> bool {
434        true
435    }
436
437    /// WsServer is a relay server — it accepts incoming connections and
438    /// fans out to individual WsConn clients. The Router always relays
439    /// to WsServer (even for messages from remote peers) because the
440    /// WsServer handles per-connection echo-back via `msg.is_from(conn)`.
441    fn is_relay_server(&self) -> bool {
442        true
443    }
444
445    async fn stopping(&mut self, _context: &ActorContext) {
446        info!(
447            "WsServer stopping — closing {} client connections",
448            self.clients.read().await.len()
449        );
450        // Dropping the client Addr senders closes their channels. The WsConn
451        // actors will receive stop signals via ActorContext::stop() and send
452        // WebSocket Close frames in their own stopping(). Here we just clear
453        // the set so no new fan-out attempts are made.
454        self.clients.write().await.clear();
455    }
456}