Skip to main content

infinity_bridge_host/
server.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use axum::Router;
5use axum::extract::State;
6use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
7use axum::response::IntoResponse;
8use axum::routing::get;
9use futures::{SinkExt, StreamExt};
10use infinity_bridge_wire::{BridgeError, EventPayload, READY_EVENT, WireMsg};
11use serde_json::Value;
12use tokio::sync::{broadcast, mpsc, oneshot, watch};
13
14use crate::hub::{ClientInfo, Hub};
15
16pub struct ServerConfig {
17    pub bind_addr: String,
18    pub ws_path: String,
19    pub event_capacity: usize,
20    pub ping_interval: Duration,
21    pub ping_timeout: Duration,
22}
23
24impl ServerConfig {
25    pub fn new(bind_addr: impl Into<String>, ws_path: impl Into<String>) -> Self {
26        Self {
27            bind_addr: bind_addr.into(),
28            ws_path: ws_path.into(),
29            event_capacity: 256,
30            ping_interval: Duration::from_secs(5),
31            ping_timeout: Duration::from_secs(15),
32        }
33    }
34}
35
36#[derive(Clone)]
37pub struct BridgeServer {
38    hub: Arc<Hub>,
39    config: Arc<ServerConfig>,
40}
41
42impl BridgeServer {
43    /// Start the bridge server and begin accepting connections.
44    ///
45    /// This spawns a tokio task running the axum HTTP server.
46    /// The returned `BridgeServer` handle can be used to interact with
47    /// connected gauges.
48    pub async fn start(config: ServerConfig) -> Result<Self, BridgeError> {
49        let hub = Hub::new(config.event_capacity);
50        let config = Arc::new(config);
51
52        let server = Self {
53            hub: Arc::clone(&hub),
54            config: Arc::clone(&config),
55        };
56
57        let app_state = AppState {
58            hub: Arc::clone(&hub),
59            config: Arc::clone(&config),
60        };
61
62        let app = Router::new()
63            .route(&config.ws_path, get(ws_upgrade))
64            .route("/health", get(health))
65            .with_state(app_state);
66
67        let listener = tokio::net::TcpListener::bind(&config.bind_addr)
68            .await
69            .map_err(|e| BridgeError::transport(format!("bind failed: {e}")))?;
70
71        tokio::spawn(async move {
72            if let Err(e) = axum::serve(listener, app).await {
73                eprintln!("[infinity-bridge-host] Server error: {e}");
74            }
75        });
76
77        Ok(server)
78    }
79
80    /// Build an axum [`Router`] without starting a listener.
81    ///
82    /// Useful when you want to mount the bridge as a nested route
83    /// inside an existing axum application.
84    pub fn router(config: ServerConfig) -> (Self, Router) {
85        let hub = Hub::new(config.event_capacity);
86        let config = Arc::new(config);
87
88        let server = Self {
89            hub: Arc::clone(&hub),
90            config: Arc::clone(&config),
91        };
92
93        let app_state = AppState {
94            hub: Arc::clone(&hub),
95            config: Arc::clone(&config),
96        };
97
98        let router = Router::new()
99            .route(&config.ws_path, get(ws_upgrade))
100            .route("/health", get(health))
101            .with_state(app_state);
102
103        (server, router)
104    }
105
106    // ── Commands ─────────────────────────────────────────────────────
107
108    /// Send a named command to all connected gauges and await the first ack.
109    pub async fn command(
110        &self,
111        name: &str,
112        payload: Value,
113        timeout: Duration,
114    ) -> Result<Value, BridgeError> {
115        self.hub.command(Some(name), payload, timeout).await
116    }
117
118    /// Send an unnamed command (payload-only) and await the first ack.
119    pub async fn command_raw(
120        &self,
121        payload: Value,
122        timeout: Duration,
123    ) -> Result<Value, BridgeError> {
124        self.hub.command(None, payload, timeout).await
125    }
126
127    // ── Events ───────────────────────────────────────────────────────
128
129    /// Send a fire-and-forget event to all connected gauges.
130    ///
131    /// Returns `Ok(())` if the event was dispatched to at least one gauge.
132    /// Returns `Err(NoClients)` if no gauges are connected.
133    pub async fn emit(&self, name: impl Into<String>, data: Value) -> Result<(), BridgeError> {
134        self.hub.emit(name, data).await
135    }
136
137    /// Subscribe to events received from gauges.
138    ///
139    /// Returns a broadcast receiver. Events are delivered as they arrive
140    /// from any connected gauge. If the receiver falls behind by
141    /// `event_capacity` messages, older events are dropped.
142    pub fn subscribe_events(&self) -> broadcast::Receiver<EventPayload> {
143        self.hub.subscribe_events()
144    }
145
146    // ── Connection status ────────────────────────────────────────────
147
148    /// Returns `true` if at least one gauge is connected.
149    ///
150    /// An open socket only proves the *relay* is alive. Use [`Self::is_ready`]
151    /// when you need to know whether the module behind it can be reached.
152    pub async fn is_connected(&self) -> bool {
153        self.hub.is_connected().await
154    }
155
156    /// Returns `true` if at least one connected gauge has reported its
157    /// downstream link bound (see [`infinity_bridge_wire::READY_EVENT`]).
158    ///
159    /// Always `false` with relays that predate the ready event, so treat this
160    /// as a positive signal only — `false` means "not known to be reachable".
161    pub async fn is_ready(&self) -> bool {
162        self.hub.is_ready().await
163    }
164
165    /// Wait until at least one gauge connects.
166    pub async fn wait_connected(&self) {
167        self.hub.wait_connected().await
168    }
169
170    /// Get a watch channel that tracks connection status.
171    ///
172    /// The value is `true` when at least one gauge is connected,
173    /// `false` when all gauges have disconnected.
174    pub fn connection_status(&self) -> watch::Receiver<bool> {
175        self.hub.subscribe_connection_status()
176    }
177
178    /// List all currently connected clients.
179    pub async fn clients(&self) -> Vec<ClientInfo> {
180        self.hub.connected_clients().await
181    }
182}
183
184#[derive(Clone)]
185struct AppState {
186    hub: Arc<Hub>,
187    config: Arc<ServerConfig>,
188}
189
190async fn health() -> impl IntoResponse {
191    "ok"
192}
193
194async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
195    ws.on_upgrade(move |socket| handle_socket(socket, state))
196}
197
198async fn handle_socket(socket: WebSocket, state: AppState) {
199    let (mut ws_tx, mut ws_rx) = socket.split();
200    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<String>();
201
202    let client_id = state.hub.register_client(out_tx.clone()).await;
203
204    let writer = tokio::spawn(async move {
205        while let Some(text) = out_rx.recv().await {
206            if ws_tx.send(Message::Text(text.into())).await.is_err() {
207                break;
208            }
209        }
210    });
211
212    let hub_for_ping = Arc::clone(&state.hub);
213    let ping_interval = state.config.ping_interval;
214    let ping_timeout = state.config.ping_timeout;
215    // Signals the read loop that this connection was reaped or gave up, so the
216    // socket is torn down with it. Without this the reaper removes the client
217    // from the hub while its reader keeps running: `touch_client` on a removed
218    // id is a no-op, so the connection can never re-register, the host can no
219    // longer address it, and the gauge — whose socket is still open — never
220    // sees a close and so never reconnects. That state persists for the whole
221    // session and is indistinguishable, from the caller, from a hung module.
222    let (reaped_tx, mut reaped_rx) = oneshot::channel::<()>();
223    let ping_task = tokio::spawn(async move {
224        let mut interval = tokio::time::interval(ping_interval);
225        interval.tick().await;
226        let mut reaped_tx = Some(reaped_tx);
227        loop {
228            interval.tick().await;
229
230            {
231                let dead = hub_for_ping.reap_dead_clients(ping_timeout).await;
232                if dead.contains(&client_id) {
233                    if let Some(tx) = reaped_tx.take() {
234                        let _ = tx.send(());
235                    }
236                    break;
237                }
238            }
239
240            let ping = WireMsg::Ping {
241                ts: Some(
242                    std::time::SystemTime::now()
243                        .duration_since(std::time::UNIX_EPOCH)
244                        .unwrap_or_default()
245                        .as_millis() as u64,
246                ),
247            };
248            let json = match ping.to_json() {
249                Ok(j) => j,
250                Err(_) => continue,
251            };
252            if hub_for_ping.send_to(client_id, json).await.is_err() {
253                if let Some(tx) = reaped_tx.take() {
254                    let _ = tx.send(());
255                }
256                break;
257            }
258        }
259    });
260
261    loop {
262        let msg = tokio::select! {
263            // Reaped (or the writer died). Drop out of the loop so the socket
264            // is closed and the gauge's reconnect timer takes over.
265            _ = &mut reaped_rx => break,
266            next = ws_rx.next() => match next {
267                Some(Ok(msg)) => msg,
268                _ => break,
269            },
270        };
271
272        match msg {
273            Message::Text(text) => {
274                state.hub.touch_client(client_id).await;
275
276                let wire = match WireMsg::from_json(&text) {
277                    Ok(w) => w,
278                    Err(_) => continue,
279                };
280
281                match wire {
282                    WireMsg::Hello(hello) => {
283                        state.hub.set_client_hello(client_id, hello).await;
284                    }
285                    WireMsg::Ack(ack) => {
286                        state.hub.dispatch_ack(ack).await;
287                    }
288                    // Readiness is connection state, not application data —
289                    // record it and don't fan it out to event subscribers.
290                    WireMsg::Event(event) if event.name == READY_EVENT => {
291                        let ready = event
292                            .data
293                            .get("ready")
294                            .and_then(Value::as_bool)
295                            .unwrap_or(true);
296                        state.hub.set_client_ready(client_id, ready).await;
297                    }
298                    WireMsg::Event(event) => {
299                        state.hub.dispatch_event(event);
300                    }
301                    WireMsg::Pong { .. } => {
302                        // last_seen already updated above via touch_client
303                    }
304                    WireMsg::Cmd(cmd) => {
305                        state.hub.dispatch_event(EventPayload::new(
306                            cmd.name.unwrap_or_else(|| "cmd".into()),
307                            cmd.payload,
308                        ));
309                    }
310                    WireMsg::Ping { ts } => {
311                        let pong = WireMsg::Pong { ts };
312                        if let Ok(json) = pong.to_json() {
313                            let _ = state.hub.send_to(client_id, json).await;
314                        }
315                    }
316                }
317            }
318            Message::Close(_) => break,
319            _ => {}
320        }
321    }
322
323    state.hub.unregister_client(client_id).await;
324    ping_task.abort();
325    drop(out_tx);
326    let _ = writer.await;
327}