Skip to main content

logicaffeine_system/
relay.rs

1//! Thin WebSocket relay — the v1 browser↔mesh bridge.
2//!
3//! A browser tab cannot open a raw socket or `listen`; its only outbound P2P
4//! primitive is a WebSocket. So a browser peer reaches the world through a relay:
5//! a plain WS server that a browser dials and that forwards its pub/sub traffic.
6//! This is deliberately *thin* — it carries no libp2p stack into the browser. The
7//! browser speaks the same [`RelayFrame`] wire protocol over `web-sys`/`gloo-net`
8//! WebSocket; this module is the native server + a native client (used for tests
9//! and native-to-native links).
10//!
11//! "Native servers are their own relays": any native node runs `serve_bridged`
12//! beside its mesh, which cross-forwards the relay's topics with the libp2p
13//! gossipsub mesh — so a browser that dials a native node is injected
14//! straight into the real mesh, and mesh traffic flows back out to the browser.
15//! The pure hub ([`serve`]) is the browser↔browser path and is fully testable
16//! without a live mesh; the gossip bridge adds browser↔native.
17
18#![cfg(all(not(target_arch = "wasm32"), feature = "relay"))]
19
20use std::collections::HashMap;
21use std::net::SocketAddr;
22use std::sync::atomic::{AtomicU64, Ordering};
23use std::sync::Arc;
24
25use futures::{SinkExt, StreamExt};
26use tokio::net::{TcpListener, TcpStream};
27use tokio::sync::{mpsc, Mutex};
28use tokio_tungstenite::tungstenite::Message;
29
30pub use crate::relay_proto::RelayFrame;
31
32/// Wrap a frame as a WebSocket binary message / read one back.
33fn encode(frame: &RelayFrame) -> Message {
34    Message::Binary(frame.to_bytes())
35}
36fn decode(bytes: &[u8]) -> Option<RelayFrame> {
37    RelayFrame::from_bytes(bytes)
38}
39
40type ConnId = u64;
41
42/// The relay's routing table: topic → the subscribed connections' outboxes.
43/// `gossip_out`, when present, carries a WS client's publishes outward to the
44/// libp2p mesh (the bridged mode); inbound mesh messages re-enter via
45/// [`Hub::deliver`], which is local-only so there is no echo back to the mesh.
46#[derive(Clone)]
47struct Hub {
48    inner: Arc<Mutex<HashMap<String, Vec<(ConnId, mpsc::UnboundedSender<RelayFrame>)>>>>,
49    gossip_out: Option<mpsc::UnboundedSender<(String, Vec<u8>)>>,
50}
51
52impl Hub {
53    fn new(gossip_out: Option<mpsc::UnboundedSender<(String, Vec<u8>)>>) -> Self {
54        Hub { inner: Arc::new(Mutex::new(HashMap::new())), gossip_out }
55    }
56
57    async fn subscribe(&self, conn: ConnId, topic: String, tx: mpsc::UnboundedSender<RelayFrame>) {
58        let mut map = self.inner.lock().await;
59        let subs = map.entry(topic).or_default();
60        if !subs.iter().any(|(c, _)| *c == conn) {
61            subs.push((conn, tx));
62        }
63    }
64
65    async fn unsubscribe(&self, conn: ConnId, topic: &str) {
66        let mut map = self.inner.lock().await;
67        if let Some(subs) = map.get_mut(topic) {
68            subs.retain(|(c, _)| *c != conn);
69        }
70    }
71
72    /// Deliver `data` to every connection subscribed to `topic` (LOCAL ONLY),
73    /// optionally skipping the originating connection `except` so a publisher does
74    /// not receive its own message (standard pub/sub, and what keeps a CRDT from
75    /// double-counting its own `Sync`). Dead outboxes are pruned; returns how many
76    /// subscribers received it.
77    async fn deliver_except(&self, topic: &str, data: &[u8], except: Option<ConnId>) -> usize {
78        let mut map = self.inner.lock().await;
79        let Some(subs) = map.get_mut(topic) else { return 0 };
80        let event = RelayFrame::Event { topic: topic.to_string(), data: data.to_vec() };
81        subs.retain(|(conn, tx)| {
82            if Some(*conn) == except {
83                return true; // keep the subscription, just don't echo to it
84            }
85            tx.send(event.clone()).is_ok()
86        });
87        subs.iter().filter(|(conn, _)| Some(*conn) != except).count()
88    }
89
90    /// Deliver to every subscriber (no skip) — the inbound path (a mesh message or
91    /// a server-side `inject`), which must not re-emit to the mesh.
92    async fn deliver(&self, topic: &str, data: &[u8]) -> usize {
93        self.deliver_except(topic, data, None).await
94    }
95
96    /// A WS client's publish: deliver to local subscribers (except the publisher)
97    /// AND, when bridged, forward to the libp2p mesh so native peers receive it.
98    async fn publish(&self, topic: &str, data: &[u8], from: ConnId) -> usize {
99        if let Some(gossip) = &self.gossip_out {
100            let _ = gossip.send((topic.to_string(), data.to_vec()));
101        }
102        self.deliver_except(topic, data, Some(from)).await
103    }
104
105    async fn drop_conn(&self, conn: ConnId) {
106        let mut map = self.inner.lock().await;
107        for subs in map.values_mut() {
108            subs.retain(|(c, _)| *c != conn);
109        }
110    }
111}
112
113/// A running relay server. Dropping the handle aborts the accept loop.
114pub struct RelayServer {
115    addr: SocketAddr,
116    hub: Hub,
117    accept: tokio::task::JoinHandle<()>,
118}
119
120impl RelayServer {
121    /// The bound address (resolve `127.0.0.1:0` to its actual port for clients).
122    pub fn addr(&self) -> SocketAddr {
123        self.addr
124    }
125
126    /// The `ws://…` URL a client dials.
127    pub fn url(&self) -> String {
128        format!("ws://{}", self.addr)
129    }
130
131    /// Inject a message into the relay from the server side — local delivery
132    /// only (no re-emit to the mesh), the hook the gossip bridge uses to forward
133    /// a received mesh message to local WS subscribers.
134    pub async fn inject(&self, topic: &str, data: &[u8]) -> usize {
135        self.hub.deliver(topic, data).await
136    }
137}
138
139impl Drop for RelayServer {
140    fn drop(&mut self) {
141        self.accept.abort();
142    }
143}
144
145/// Start a relay (a pure WebSocket pub/sub hub) on `addr` (e.g. `127.0.0.1:0`).
146/// This is the browser↔browser-via-relay path and needs no mesh.
147pub async fn serve(addr: &str) -> std::io::Result<RelayServer> {
148    serve_inner(addr, Hub::new(None)).await
149}
150
151/// Start a relay that is **bridged to the libp2p gossipsub mesh** — "a native
152/// server being its own relay". A WS client's publish is also gossiped to native
153/// peers, and mesh messages on `topics` are injected to WS subscribers. The
154/// browser dials this node's WS port and is thereby joined to the real mesh.
155///
156/// Requires a running mesh node ([`crate::network::MeshNode`]); without one the
157/// bridge tasks simply see no mesh traffic and it degrades to a local hub.
158///
159/// Only available with the `networking` feature (the gossip mesh it bridges to).
160#[cfg(feature = "networking")]
161pub async fn serve_bridged(addr: &str, topics: Vec<String>) -> std::io::Result<RelayServer> {
162    // Outbound: WS publishes → mesh. A pump task drains the channel and gossips.
163    let (gtx, mut grx) = mpsc::unbounded_channel::<(String, Vec<u8>)>();
164    let server = serve_inner(addr, Hub::new(Some(gtx))).await?;
165    tokio::spawn(async move {
166        while let Some((topic, data)) = grx.recv().await {
167            let _ = crate::network::gossip::publish_raw(&topic, data).await;
168        }
169    });
170    // Inbound: subscribe to each mesh topic; forward received messages to local
171    // WS subscribers via `deliver` (local-only ⇒ no echo back to the mesh).
172    for topic in topics {
173        let hub = server.hub.clone();
174        let mut rx = crate::network::gossip::subscribe(&topic).await;
175        tokio::spawn(async move {
176            while let Some(data) = rx.recv().await {
177                hub.deliver(&topic, &data).await;
178            }
179        });
180    }
181    Ok(server)
182}
183
184async fn serve_inner(addr: &str, hub: Hub) -> std::io::Result<RelayServer> {
185    let listener = TcpListener::bind(addr).await?;
186    let addr = listener.local_addr()?;
187    let hub_for_loop = hub.clone();
188    let next_id = Arc::new(AtomicU64::new(0));
189    let accept = tokio::spawn(async move {
190        while let Ok((stream, _peer)) = listener.accept().await {
191            let hub = hub_for_loop.clone();
192            let id = next_id.fetch_add(1, Ordering::Relaxed);
193            tokio::spawn(async move {
194                let _ = handle_conn(stream, hub, id).await;
195            });
196        }
197    });
198    Ok(RelayServer { addr, hub, accept })
199}
200
201async fn handle_conn(stream: TcpStream, hub: Hub, id: ConnId) -> Result<(), ()> {
202    let ws = tokio_tungstenite::accept_async(stream).await.map_err(|_| ())?;
203    let (mut write, mut read) = ws.split();
204
205    // Per-connection outbox: the hub pushes Events/SubAcks here; a writer pump
206    // forwards them to the socket.
207    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayFrame>();
208    let writer = tokio::spawn(async move {
209        while let Some(frame) = out_rx.recv().await {
210            if write.send(encode(&frame)).await.is_err() {
211                break;
212            }
213        }
214    });
215
216    while let Some(Ok(msg)) = read.next().await {
217        let bytes = match msg {
218            Message::Binary(b) => b,
219            Message::Text(t) => t.into_bytes(),
220            Message::Close(_) => break,
221            _ => continue,
222        };
223        let Some(frame) = decode(&bytes) else { continue };
224        match frame {
225            RelayFrame::Subscribe { topic } => {
226                hub.subscribe(id, topic.clone(), out_tx.clone()).await;
227                let _ = out_tx.send(RelayFrame::SubAck { topic });
228            }
229            RelayFrame::Unsubscribe { topic } => hub.unsubscribe(id, &topic).await,
230            RelayFrame::Publish { topic, data } => {
231                hub.publish(&topic, &data, id).await;
232            }
233            // Server→client frames are never received from a client.
234            RelayFrame::SubAck { .. } | RelayFrame::Event { .. } => {}
235        }
236    }
237
238    hub.drop_conn(id).await;
239    writer.abort();
240    Ok(())
241}
242
243/// A native relay client — and the reference for what the browser does over
244/// `web-sys` WebSocket with the same [`RelayFrame`] protocol.
245pub struct RelayClient {
246    out: mpsc::UnboundedSender<RelayFrame>,
247    events: mpsc::UnboundedReceiver<RelayFrame>,
248    acks: mpsc::UnboundedReceiver<String>,
249    _pump: tokio::task::JoinHandle<()>,
250}
251
252impl RelayClient {
253    /// Dial a relay at `url` (`ws://host:port`).
254    pub async fn connect(url: &str) -> Result<RelayClient, String> {
255        let (ws, _) = tokio_tungstenite::connect_async(url)
256            .await
257            .map_err(|e| format!("relay dial failed: {e}"))?;
258        let (mut write, mut read) = ws.split();
259
260        let (out_tx, mut out_rx) = mpsc::unbounded_channel::<RelayFrame>();
261        let (ev_tx, ev_rx) = mpsc::unbounded_channel::<RelayFrame>();
262        let (ack_tx, ack_rx) = mpsc::unbounded_channel::<String>();
263
264        // One pump task owns the socket: it writes outgoing frames and routes
265        // incoming frames to the events / acks channels.
266        let pump = tokio::spawn(async move {
267            loop {
268                tokio::select! {
269                    out = out_rx.recv() => match out {
270                        Some(frame) => {
271                            if write.send(encode(&frame)).await.is_err() { break; }
272                        }
273                        None => break,
274                    },
275                    inb = read.next() => match inb {
276                        Some(Ok(Message::Binary(b))) => {
277                            if let Some(frame) = decode(&b) {
278                                match frame {
279                                    RelayFrame::SubAck { topic } => { let _ = ack_tx.send(topic); }
280                                    ev @ RelayFrame::Event { .. } => { let _ = ev_tx.send(ev); }
281                                    _ => {}
282                                }
283                            }
284                        }
285                        Some(Ok(Message::Close(_))) | None => break,
286                        Some(Ok(_)) => {}
287                        Some(Err(_)) => break,
288                    },
289                }
290            }
291        });
292
293        Ok(RelayClient { out: out_tx, events: ev_rx, acks: ack_rx, _pump: pump })
294    }
295
296    /// Subscribe to `topic` and await the relay's acknowledgement, so a
297    /// subsequent publish from anyone cannot race ahead of the registration.
298    pub async fn subscribe(&mut self, topic: &str) -> Result<(), String> {
299        self.out
300            .send(RelayFrame::Subscribe { topic: topic.to_string() })
301            .map_err(|_| "relay connection closed".to_string())?;
302        loop {
303            match self.acks.recv().await {
304                Some(t) if t == topic => return Ok(()),
305                Some(_) => continue,
306                None => return Err("relay closed before subscribe ack".to_string()),
307            }
308        }
309    }
310
311    /// Publish `data` to `topic`.
312    pub fn publish(&self, topic: &str, data: Vec<u8>) -> Result<(), String> {
313        self.out
314            .send(RelayFrame::Publish { topic: topic.to_string(), data })
315            .map_err(|_| "relay connection closed".to_string())
316    }
317
318    /// Await the next event delivered for a subscribed topic.
319    pub async fn next_event(&mut self) -> Option<(String, Vec<u8>)> {
320        match self.events.recv().await {
321            Some(RelayFrame::Event { topic, data }) => Some((topic, data)),
322            _ => None,
323        }
324    }
325
326    /// Drain every event delivered so far WITHOUT blocking — the non-blocking
327    /// "drain at a sync point" primitive the interpreter's `Sync` uses.
328    pub fn drain_events(&mut self) -> Vec<(String, Vec<u8>)> {
329        let mut out = Vec::new();
330        while let Ok(RelayFrame::Event { topic, data }) = self.events.try_recv() {
331            out.push((topic, data));
332        }
333        out
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// Two clients on one relay: a subscriber receives a publisher's message —
342    /// the browser↔browser-via-relay path, end to end.
343    #[tokio::test]
344    async fn relay_pubsub_roundtrip() {
345        let relay = serve("127.0.0.1:0").await.expect("relay binds");
346        let url = relay.url();
347
348        let mut sub = RelayClient::connect(&url).await.expect("subscriber dials");
349        let pubr = RelayClient::connect(&url).await.expect("publisher dials");
350
351        sub.subscribe("chat").await.expect("subscribe acked");
352        pubr.publish("chat", b"hello".to_vec()).expect("publish");
353
354        let (topic, data) = tokio::time::timeout(std::time::Duration::from_secs(5), sub.next_event())
355            .await
356            .expect("event arrives in time")
357            .expect("event present");
358        assert_eq!(topic, "chat");
359        assert_eq!(data, b"hello");
360    }
361
362    /// A message on a topic nobody subscribes to reaches no one, and a second
363    /// topic is isolated from the first.
364    #[tokio::test]
365    async fn relay_topics_are_isolated() {
366        let relay = serve("127.0.0.1:0").await.expect("relay binds");
367        let url = relay.url();
368        let mut a = RelayClient::connect(&url).await.unwrap();
369        let b = RelayClient::connect(&url).await.unwrap();
370
371        a.subscribe("alpha").await.unwrap();
372        b.publish("beta", b"nope".to_vec()).unwrap(); // different topic
373        b.publish("alpha", b"yes".to_vec()).unwrap();
374
375        let (topic, data) = tokio::time::timeout(std::time::Duration::from_secs(5), a.next_event())
376            .await
377            .expect("event arrives")
378            .unwrap();
379        // The first event a receives must be the `alpha` one — `beta` was never
380        // delivered to it.
381        assert_eq!(topic, "alpha");
382        assert_eq!(data, b"yes");
383    }
384
385    /// The server-side `inject` hook (used by the gossip bridge) delivers to WS
386    /// subscribers exactly like a client publish.
387    #[tokio::test]
388    async fn relay_inject_reaches_subscribers() {
389        let relay = serve("127.0.0.1:0").await.expect("relay binds");
390        let mut sub = RelayClient::connect(&relay.url()).await.unwrap();
391        sub.subscribe("mesh").await.unwrap();
392
393        let delivered = relay.inject("mesh", b"from-gossip").await;
394        assert_eq!(delivered, 1, "one WS subscriber received the injected message");
395
396        let (topic, data) = tokio::time::timeout(std::time::Duration::from_secs(5), sub.next_event())
397            .await
398            .expect("event arrives")
399            .unwrap();
400        assert_eq!(topic, "mesh");
401        assert_eq!(data, b"from-gossip");
402    }
403}