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