Skip to main content

beam/adapters/
ws_conn.rs

1//! Per-connection WebSocket actor — bridges Gun protocol messages over WebSocket.
2//!
3//! [`WsConn`] wraps a single WebSocket connection (either inbound via
4//! [`crate::adapters::WsServer`] or outbound via
5//! [`crate::adapters::OutgoingWebsocketManager`]). It:
6//!
7//! - Sends outgoing messages as `WsMessage::Text` on the WebSocket
8//! - Receives incoming messages, parses them via [`Message::try_from`], and
9//!   forwards to the [`crate::router::Router`]
10//! - Sends a `Message::Hi` on startup to register with the router
11//!
12//! # Lifecycle
13//!
14//! 1. `pre_start`: Send `Hi` message, spawn receive loop
15//! 2. `handle`: Forward outgoing messages to WebSocket
16//! 3. `stopping`: Log shutdown
17//!
18//! The receive loop runs as a child task and calls `ctx.stop()` when the
19//! WebSocket closes, triggering actor shutdown.
20
21use crate::actor::{Actor, ActorContext};
22use crate::message::Message;
23use bytes::Bytes;
24use futures_util::{
25    SinkExt, StreamExt,
26    stream::{SplitSink, SplitStream},
27};
28use std::sync::Arc;
29
30use async_trait::async_trait;
31
32use log::{debug, info};
33use web_time::Duration;
34
35use tokio_websockets::{Message as WsMessage, WebSocketStream};
36
37/// A per-connection WebSocket actor that bridges Gun protocol messages.
38///
39/// Created by [`crate::adapters::WsServer`] (inbound) or
40/// [`crate::adapters::OutgoingWebsocketManager`] (outbound). Each `WsConn`
41/// manages a single WebSocket connection and translates between the
42/// Gun wire format (text) and [`Message`] enum values.
43/// A per-connection WebSocket actor that bridges Gun protocol messages.
44///
45/// Generic over the underlying stream type `S` (plain `TcpStream` or
46/// `TlsStream<TcpStream>`). Created by [`crate::adapters::WsServer`]
47/// (inbound) or [`crate::adapters::OutgoingWebsocketManager`] (outbound).
48/// Each `WsConn` manages a single WebSocket connection and translates
49/// between the Gun wire format (text) and [`Message`] enum values.
50pub struct WsConn<S> {
51    /// Write half of the WebSocket (for sending messages in `handle`).
52    ws_sink: Option<SplitSink<WebSocketStream<S>, WsMessage>>,
53    /// Read half of the WebSocket (moved into receive loop in `pre_start`).
54    ws_stream: Option<SplitStream<WebSocketStream<S>>>,
55    allow_public_space: bool,
56    /// Reusable serialization buffer — eliminates per-message allocation.
57    send_buf: Vec<u8>,
58}
59
60impl<S> WsConn<S>
61where
62    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
63{
64    /// Creates a new `WsConn` from a `WebSocketStream`.
65    pub fn new(ws: WebSocketStream<S>, allow_public_space: bool) -> Self {
66        let (sink, stream) = futures_util::StreamExt::split(ws);
67        Self {
68            ws_sink: Some(sink),
69            ws_stream: Some(stream),
70            allow_public_space,
71            send_buf: Vec::with_capacity(512),
72        }
73    }
74
75    /// Serialize a message into `send_buf` and feed it as a WS text frame
76    /// into the sink's internal buffer.
77    ///
78    /// With `flush_threshold(usize::MAX)` configured on the WebSocket
79    /// builder, `feed()` never triggers an implicit flush — it simply
80    /// queues the frame. The caller is responsible for calling
81    /// `flush()` after all messages are queued.
82    /// Serialize a message and feed it as a WS text frame into the sink.
83    ///
84    /// For `Message::Put`, uses `Put::get_or_serialize` to cache the
85    /// wire bytes on first serialization. When the same `Arc<Message>`
86    /// is relayed to multiple peers, only the first WsConn serializes —
87    /// subsequent peers receive cached bytes (refcount bump, zero-copy).
88    /// Mirrors Gun.js's `meta.raw` caching in `mesh.raw()`.
89    async fn send_msg(&mut self, msg: &Arc<Message>, ctx: &ActorContext) {
90        let bytes = match msg.as_ref() {
91            Message::Put(put) => put.get_or_serialize(),
92            _ => {
93                msg.to_writer(&mut self.send_buf);
94                Bytes::from(std::mem::take(&mut self.send_buf))
95            }
96        };
97        ctx.metrics.record_serialization();
98        if let Some(sink) = &mut self.ws_sink {
99            let _ = sink
100                .feed(WsMessage::text(
101                    String::from_utf8(bytes.to_vec()).expect("wire format is valid UTF-8"),
102                ))
103                .await;
104        }
105        ctx.metrics.record_ws_sent();
106    }
107
108    /// Flush the WS sink's internal buffer to the underlying TCP stream.
109    async fn flush_sink(&mut self) {
110        if let Some(sink) = &mut self.ws_sink {
111            let _ = sink.flush().await;
112        }
113    }
114}
115
116#[async_trait]
117impl<S> Actor for WsConn<S>
118where
119    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
120{
121    /// Fallback single-message handler. Feeds one message then flushes.
122    /// Used when the actor runtime calls `handle` instead of `handle_batch`.
123    async fn handle(&mut self, msg: Arc<Message>, ctx: &ActorContext) {
124        self.send_msg(&msg, ctx).await;
125        self.flush_sink().await;
126    }
127
128    /// Batch handler — packs all messages into a single WebSocket text
129    /// frame as a JSON array, then flushes once.
130    ///
131    /// Mirrors Gun.js's `peer.batch` packing in `mesh.say`: messages
132    /// are accumulated as `[{msg1},{msg2},...]` and flushed as a single
133    /// WS frame, amortizing frame header overhead.
134    ///
135    /// For single-message batches, the message is sent as-is (no array
136    /// wrapper) — preserving backwards compatibility with peers that
137    /// may not expect array frames.
138    ///
139    /// # Serialized Message Cache (Sprint 1)
140    ///
141    /// For `Message::Put`, uses `Put::get_or_serialize` to reuse
142    /// cached wire bytes. When the same `Arc<Message>` is relayed to
143    /// multiple peers, only the first WsConn serializes — subsequent
144    /// peers get cached bytes (refcount bump).
145    ///
146    /// # Cooperative Scheduling
147    ///
148    /// On `current_thread` runtime, we call `yield_now()` every 16
149    /// messages to ensure the relay's router and other actors get
150    /// scheduled. Without this, a sender's WsConn can starve the
151    /// relay's receive loop, causing a deadlock.
152    ///
153    /// # Frame Size Safety
154    ///
155    /// If the accumulated buffer exceeds `MAX_BATCH_FRAME_SIZE`, we
156    /// flush early and start a new array. This prevents exceeding
157    /// WebSocket frame size limits on peers with restrictive configs.
158    async fn handle_batch(&mut self, batch: &mut Vec<Arc<Message>>, ctx: &ActorContext) {
159        if self.ws_sink.is_none() {
160            batch.clear();
161            return;
162        }
163
164        match batch.len() {
165            0 => {}
166            1 => {
167                // Single message — send as-is (no array wrapper needed).
168                let msg = batch.drain(..).next().unwrap();
169                self.send_msg(&msg, ctx).await;
170                self.flush_sink().await;
171            }
172            _ => {
173                // Multiple messages — pack into a JSON array frame.
174                // Gun.js: peer.batch = '['; peer.batch += ',' + raw; ... flush
175                self.send_buf.clear();
176                self.send_buf.push(b'[');
177                let mut first = true;
178                let mut count = 0;
179
180                for msg in batch.drain(..) {
181                    if !first {
182                        self.send_buf.push(b',');
183                    }
184                    first = false;
185
186                    // Use cached serialization for Put, direct for others.
187                    let bytes = match msg.as_ref() {
188                        Message::Put(put) => put.get_or_serialize(),
189                        _ => {
190                            let mut buf = Vec::with_capacity(64);
191                            msg.to_writer(&mut buf);
192                            Bytes::from(buf)
193                        }
194                    };
195                    self.send_buf.extend_from_slice(&bytes);
196                    ctx.metrics.record_serialization();
197                    ctx.metrics.record_ws_sent();
198
199                    count += 1;
200                    if count % 16 == 0 {
201                        crate::tokio_spawn::yield_now().await;
202                    }
203                }
204
205                self.send_buf.push(b']');
206
207                // Feed the batched array as a single WS text frame.
208                let buf = std::mem::take(&mut self.send_buf);
209                if let Some(sink) = &mut self.ws_sink {
210                    let _ = sink
211                        .feed(WsMessage::text(
212                            String::from_utf8(buf).expect("wire format is valid UTF-8"),
213                        ))
214                        .await;
215                }
216
217                self.flush_sink().await;
218            }
219        }
220    }
221
222    async fn pre_start(&mut self, ctx: &ActorContext) {
223        // Send Hi message to register with the relay.
224        let hi = Message::Hi {
225            from: ctx.addr.clone(),
226            peer_id: ctx.peer_id.read().clone(),
227            is_ack: None, // Initial contact — Gun.js should ack with dam: "?" + "@"
228            msg_id: crate::utils::random_string(8),
229        };
230        hi.to_writer(&mut self.send_buf);
231        ctx.metrics.record_serialization();
232        if let Some(sink) = &mut self.ws_sink {
233            let buf = std::mem::take(&mut self.send_buf);
234            let _ = sink
235                .feed(WsMessage::text(
236                    String::from_utf8(buf).expect("wire format is valid UTF-8"),
237                ))
238                .await;
239        }
240        ctx.metrics.record_ws_sent();
241        self.flush_sink().await;
242
243        // Move the read half into a child task for the receive loop.
244        let reader = self.ws_stream.take().expect("ws_stream already taken");
245        let ctx2 = ctx.clone();
246        let allow_public_space = self.allow_public_space;
247        ctx.child_task(async move {
248            let mut reader = reader;
249            while let Some(result) = reader.next().await {
250                let ws_msg = match result {
251                    Ok(m) => m,
252                    Err(_e) => {
253                        break;
254                    }
255                };
256                if ws_msg.is_text() {
257                    let text = ws_msg.as_text().unwrap_or("");
258                    if text.is_empty() {
259                        continue;
260                    }
261                    ctx2.metrics.record_ws_received();
262                    match Message::try_from(text, ctx2.addr.clone(), allow_public_space) {
263                        Ok(msgs) => {
264                            ctx2.metrics.record_parsed();
265                            for msg in msgs {
266                                let _ = ctx2.router.read().send(msg);
267                            }
268                        }
269                        Err(e) => {
270                            debug!("[WS] parse error: {} (len={})", e, text.len());
271                        }
272                    }
273                } else if ws_msg.is_binary() {
274                    debug!("[WS] binary frame (ignored)");
275                } else if ws_msg.is_close() {
276                    debug!("[WS] close frame received from peer");
277                    break;
278                } else if ws_msg.is_ping() {
279                    debug!("[WS] ping frame (ignored)");
280                } else if ws_msg.is_pong() {
281                    debug!("[WS] pong frame (ignored)");
282                }
283            }
284            debug!("[WS] receive loop ended — stopping actor");
285            ctx2.stop();
286        });
287    }
288
289    async fn stopping(&mut self, _context: &ActorContext) {
290        info!("WsConn stopping — sending WebSocket Close frame");
291        if let Some(sink) = &mut self.ws_sink {
292            let close_result =
293                crate::tokio_time::timeout(Duration::from_secs(2), sink.close()).await;
294            match close_result {
295                Ok(Ok(())) => debug!("WsConn Close frame acknowledged"),
296                Ok(Err(e)) => debug!("WsConn Close error (non-fatal): {}", e),
297                Err(_) => debug!("WsConn Close timed out — connection dropped"),
298            }
299        }
300    }
301}