Skip to main content

doido_cable/
server.rs

1//! The live WebSocket cable server: an axum `ws` upgrade handler that speaks the
2//! ActionCable wire protocol, a [`ChannelRegistry`] routing subscriptions to
3//! [`Channel`] handlers, a heartbeat ping loop, and a pub/sub bridge so
4//! broadcasts reach subscribed clients.
5
6use crate::channel::{Channel, ChannelContext, ChannelName};
7use crate::protocol::{CableFrame, ServerFrame};
8use crate::pubsub::PubSub;
9use doido_controller::axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
10use doido_controller::axum::extract::State;
11use doido_controller::axum::response::Response;
12use doido_controller::axum::routing::get;
13use doido_controller::axum::Router;
14use futures_util::{SinkExt, StreamExt};
15use std::collections::HashMap;
16use std::sync::Arc;
17use std::time::Duration;
18
19/// Default heartbeat when none is configured (matches ActionCable's 3s).
20const DEFAULT_HEARTBEAT: Duration = Duration::from_secs(3);
21
22/// Maps ActionCable channel names to their handlers, over a shared pub/sub
23/// backend (Rails' channel routing).
24pub struct ChannelRegistry {
25    channels: HashMap<String, Arc<dyn Channel>>,
26    pubsub: Arc<dyn PubSub>,
27    heartbeat: Duration,
28}
29
30impl ChannelRegistry {
31    pub fn new(pubsub: Arc<dyn PubSub>) -> Self {
32        Self {
33            channels: HashMap::new(),
34            pubsub,
35            heartbeat: DEFAULT_HEARTBEAT,
36        }
37    }
38
39    /// Set the heartbeat ping interval (from `cable.ping_interval`).
40    pub fn with_heartbeat(mut self, interval: Duration) -> Self {
41        self.heartbeat = interval;
42        self
43    }
44
45    /// Register a channel handler under an explicit name.
46    pub fn register(&mut self, name: impl Into<String>, channel: Arc<dyn Channel>) -> &mut Self {
47        self.channels.insert(name.into(), channel);
48        self
49    }
50
51    /// Register a channel by its `#[channel]`-derived name.
52    pub fn register_channel<C>(&mut self, channel: C) -> &mut Self
53    where
54        C: Channel + ChannelName + 'static,
55    {
56        self.register(C::channel_name(), Arc::new(channel))
57    }
58
59    /// Look up a channel handler by name.
60    pub fn get(&self, name: &str) -> Option<Arc<dyn Channel>> {
61        self.channels.get(name).cloned()
62    }
63
64    /// The pub/sub backend the registry shares with its connections.
65    pub fn pubsub(&self) -> Arc<dyn PubSub> {
66        self.pubsub.clone()
67    }
68}
69
70/// Extract the `channel` name from an ActionCable subscription identifier (a
71/// JSON string like `{"channel":"ChatChannel","room":"1"}`).
72fn channel_name_of(identifier: &str) -> Option<String> {
73    serde_json::from_str::<serde_json::Value>(identifier)
74        .ok()?
75        .get("channel")?
76        .as_str()
77        .map(str::to_string)
78}
79
80/// Send a server frame to a connection's outbound sink (best-effort).
81fn send_frame(tx: &tokio::sync::mpsc::UnboundedSender<String>, frame: &ServerFrame) {
82    if let Ok(json) = frame.to_json() {
83        let _ = tx.send(json);
84    }
85}
86
87/// Handle one client frame against a connection's subscription state. Factored
88/// out of [`handle_socket`] so it can be unit-tested without a live socket.
89async fn handle_frame(
90    frame: CableFrame,
91    registry: &ChannelRegistry,
92    tx: &tokio::sync::mpsc::UnboundedSender<String>,
93    subs: &mut HashMap<String, ChannelContext>,
94) {
95    match frame {
96        CableFrame::Subscribe { identifier } => {
97            let channel = channel_name_of(&identifier).and_then(|name| registry.get(&name));
98            let Some(channel) = channel else {
99                send_frame(tx, &ServerFrame::RejectSubscription { identifier });
100                return;
101            };
102            let ctx = ChannelContext::new(identifier.clone(), tx.clone(), registry.pubsub());
103            match channel.subscribed(&ctx).await {
104                Ok(()) => {
105                    send_frame(
106                        tx,
107                        &ServerFrame::ConfirmSubscription {
108                            identifier: identifier.clone(),
109                        },
110                    );
111                    subs.insert(identifier, ctx);
112                }
113                Err(_) => send_frame(tx, &ServerFrame::RejectSubscription { identifier }),
114            }
115        }
116        CableFrame::Message { identifier, data } => {
117            if let Some(ctx) = subs.get(&identifier) {
118                if let Some(channel) = channel_name_of(&identifier).and_then(|n| registry.get(&n)) {
119                    let _ = channel.received(ctx, data).await;
120                }
121            }
122        }
123        CableFrame::Unsubscribe { identifier } => {
124            if let Some(ctx) = subs.remove(&identifier) {
125                if let Some(channel) = channel_name_of(&identifier).and_then(|n| registry.get(&n)) {
126                    let _ = channel.unsubscribed(&ctx).await;
127                }
128                ctx.stop_all_streams().await;
129            }
130        }
131    }
132}
133
134/// Drive a single upgraded WebSocket connection: greet with `welcome`, ping on a
135/// heartbeat, and dispatch inbound ActionCable frames until the socket closes.
136pub async fn handle_socket(socket: WebSocket, registry: Arc<ChannelRegistry>) {
137    let (mut sink, mut stream) = socket.split();
138    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
139
140    // Writer task: fan every queued outbound string into the socket.
141    let writer = tokio::spawn(async move {
142        while let Some(msg) = rx.recv().await {
143            if sink.send(Message::text(msg)).await.is_err() {
144                break;
145            }
146        }
147    });
148
149    let _ = tx.send(ServerFrame::Welcome.to_json().unwrap_or_default());
150
151    // Heartbeat task: ActionCable `ping` frames on the configured interval.
152    let heartbeat_tx = tx.clone();
153    let heartbeat_interval = registry.heartbeat;
154    let heartbeat = tokio::spawn(async move {
155        let mut ticker = tokio::time::interval(heartbeat_interval);
156        ticker.tick().await; // consume the immediate first tick
157        loop {
158            ticker.tick().await;
159            let ping = crate::heartbeat::ping_now().to_json().unwrap_or_default();
160            if heartbeat_tx.send(ping).is_err() {
161                break;
162            }
163        }
164    });
165
166    let mut subs: HashMap<String, ChannelContext> = HashMap::new();
167    while let Some(Ok(message)) = stream.next().await {
168        match message {
169            Message::Text(text) => {
170                if let Ok(frame) = CableFrame::parse(text.as_str()) {
171                    handle_frame(frame, &registry, &tx, &mut subs).await;
172                }
173            }
174            Message::Close(_) => break,
175            _ => {}
176        }
177    }
178
179    heartbeat.abort();
180    writer.abort();
181    for (_, ctx) in subs {
182        ctx.stop_all_streams().await;
183    }
184}
185
186/// axum handler that upgrades a request to a cable WebSocket connection.
187pub async fn ws_handler(
188    ws: WebSocketUpgrade,
189    State(registry): State<Arc<ChannelRegistry>>,
190) -> Response {
191    ws.on_upgrade(move |socket| handle_socket(socket, registry))
192}
193
194/// A `Router` mounting the cable endpoint at `/cable`, backed by `registry`.
195pub fn route(registry: Arc<ChannelRegistry>) -> Router {
196    Router::new()
197        .route("/cable", get(ws_handler))
198        .with_state(registry)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::pubsub::MemoryPubSub;
205    use std::sync::atomic::{AtomicBool, Ordering};
206    use tokio::sync::mpsc;
207
208    struct EchoChannel {
209        subscribed: Arc<AtomicBool>,
210        received: Arc<AtomicBool>,
211    }
212
213    #[async_trait::async_trait]
214    impl Channel for EchoChannel {
215        async fn subscribed(&self, _ctx: &ChannelContext) -> doido_core::Result<()> {
216            self.subscribed.store(true, Ordering::SeqCst);
217            Ok(())
218        }
219        async fn unsubscribed(&self, _ctx: &ChannelContext) -> doido_core::Result<()> {
220            Ok(())
221        }
222        async fn received(
223            &self,
224            ctx: &ChannelContext,
225            data: serde_json::Value,
226        ) -> doido_core::Result<()> {
227            self.received.store(true, Ordering::SeqCst);
228            ctx.transmit(data);
229            Ok(())
230        }
231    }
232
233    impl ChannelName for EchoChannel {
234        fn channel_name() -> &'static str {
235            "EchoChannel"
236        }
237    }
238
239    fn registry_with(subscribed: Arc<AtomicBool>, received: Arc<AtomicBool>) -> ChannelRegistry {
240        let mut registry = ChannelRegistry::new(Arc::new(MemoryPubSub::new()));
241        registry.register_channel(EchoChannel {
242            subscribed,
243            received,
244        });
245        registry
246    }
247
248    #[test]
249    fn channel_name_of_reads_the_channel_field() {
250        assert_eq!(
251            channel_name_of(r#"{"channel":"EchoChannel","room":"1"}"#).as_deref(),
252            Some("EchoChannel")
253        );
254        assert_eq!(channel_name_of("not json"), None);
255    }
256
257    #[tokio::test]
258    async fn subscribe_confirms_and_calls_subscribed() {
259        let subscribed = Arc::new(AtomicBool::new(false));
260        let registry = registry_with(subscribed.clone(), Arc::new(AtomicBool::new(false)));
261        let (tx, mut rx) = mpsc::unbounded_channel();
262        let mut subs = HashMap::new();
263
264        let identifier = r#"{"channel":"EchoChannel"}"#.to_string();
265        handle_frame(
266            CableFrame::Subscribe {
267                identifier: identifier.clone(),
268            },
269            &registry,
270            &tx,
271            &mut subs,
272        )
273        .await;
274
275        assert!(subscribed.load(Ordering::SeqCst), "subscribed() ran");
276        assert!(subs.contains_key(&identifier), "subscription tracked");
277        let frame = ServerFrame::parse(&rx.recv().await.unwrap()).unwrap();
278        assert_eq!(frame, ServerFrame::ConfirmSubscription { identifier });
279    }
280
281    #[tokio::test]
282    async fn subscribe_to_unknown_channel_is_rejected() {
283        let registry = registry_with(
284            Arc::new(AtomicBool::new(false)),
285            Arc::new(AtomicBool::new(false)),
286        );
287        let (tx, mut rx) = mpsc::unbounded_channel();
288        let mut subs = HashMap::new();
289
290        let identifier = r#"{"channel":"NopeChannel"}"#.to_string();
291        handle_frame(
292            CableFrame::Subscribe {
293                identifier: identifier.clone(),
294            },
295            &registry,
296            &tx,
297            &mut subs,
298        )
299        .await;
300
301        assert!(subs.is_empty());
302        let frame = ServerFrame::parse(&rx.recv().await.unwrap()).unwrap();
303        assert_eq!(frame, ServerFrame::RejectSubscription { identifier });
304    }
305
306    #[tokio::test]
307    async fn message_dispatches_to_received() {
308        let received = Arc::new(AtomicBool::new(false));
309        let registry = registry_with(Arc::new(AtomicBool::new(false)), received.clone());
310        let (tx, mut rx) = mpsc::unbounded_channel();
311        let mut subs = HashMap::new();
312        let identifier = r#"{"channel":"EchoChannel"}"#.to_string();
313
314        handle_frame(
315            CableFrame::Subscribe {
316                identifier: identifier.clone(),
317            },
318            &registry,
319            &tx,
320            &mut subs,
321        )
322        .await;
323        let _confirm = rx.recv().await.unwrap();
324
325        handle_frame(
326            CableFrame::Message {
327                identifier: identifier.clone(),
328                data: serde_json::json!({ "text": "hi" }),
329            },
330            &registry,
331            &tx,
332            &mut subs,
333        )
334        .await;
335
336        assert!(received.load(Ordering::SeqCst), "received() ran");
337        // EchoChannel transmits the data back.
338        let echoed = crate::protocol::ServerMessage::parse(&rx.recv().await.unwrap()).unwrap();
339        assert_eq!(echoed.message["text"], "hi");
340    }
341}