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}