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}