Skip to main content

arcly_http_realtime/realtime/
ws.rs

1//! WebSocket boundary: upgrade, per-socket read/write pumps, event dispatch.
2//!
3//! This is the *only* module that touches `axum::extract::ws` — the analogue of
4//! [`arcly_http_core::web::boundary`] for the real-time layer. Everything above it speaks
5//! arcly types ([`WsClient`], [`WsMessage`], [`GatewayRuntime`]).
6//!
7//! ## Per-connection model (no hot-path locks)
8//!
9//! ```text
10//!            ┌─────────────────── handle_socket task ───────────────────┐
11//!  socket ──>│ reader: stream.next() ─> dispatch(event) ─> handler fut  │
12//!            │ writer: rx.recv()     ─> sink.send(frame)                │
13//!            └───────────────────────────────────────────────────────────┘
14//! ```
15//! The reader and writer run as independent halves of the split socket. Inbound
16//! frames are parsed and routed through the gateway's `dispatch` table (an
17//! immutable `&HashMap` — lock-free read). Outbound frames are produced by any
18//! task via the registry's sharded channels and drained by this socket's writer.
19
20use std::sync::Arc;
21
22use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
23use axum::http::HeaderMap;
24use axum::routing::{get, MethodRouter};
25use futures::{SinkExt, StreamExt};
26use tokio::sync::{mpsc, oneshot};
27
28use crate::realtime::connection::{ConnectionRegistry, WsClient, WsMessage};
29use crate::realtime::gateway::GatewayRuntime;
30use arcly_http_core::core::engine::FrozenDiContainer;
31use arcly_http_core::web::context::Claims;
32
33/// Per-gateway runtime tuning, sourced from `LaunchConfig` at mount time.
34#[derive(Clone, Copy, Debug)]
35#[non_exhaustive]
36pub struct WsTuning {
37    /// Outbound queue depth per socket — the slow-client memory ceiling.
38    pub outbound_buffer: usize,
39    /// Hard cap on concurrent sockets across all gateways (`0` = unlimited);
40    /// beyond it upgrades are refused with `503` before any socket exists.
41    pub max_connections: usize,
42    /// Server→client Ping cadence (`ZERO` disables). Pings provoke pongs,
43    /// which feed the idle sweeper's `last_seen`.
44    pub ping_interval: std::time::Duration,
45}
46
47impl WsTuning {
48    /// Construct WS tuning from the launch config. A constructor (rather than a
49    /// struct literal) so the `arcly-http` facade can build it across the crate
50    /// boundary despite `#[non_exhaustive]`.
51    #[doc(hidden)]
52    pub fn new(
53        outbound_buffer: usize,
54        max_connections: usize,
55        ping_interval: std::time::Duration,
56    ) -> Self {
57        Self {
58            outbound_buffer,
59            max_connections,
60            ping_interval,
61        }
62    }
63}
64
65/// Build the axum `MethodRouter` that upgrades HTTP→WebSocket for one gateway.
66///
67/// If a `JwtService` has been provided in the DI container, the
68/// `Authorization: Bearer <token>` header is decoded during the WebSocket
69/// handshake and the resulting claims are threaded through to every `WsClient`
70/// so gateway handlers can call `client.claims()` for auth decisions.
71pub fn ws_route(
72    runtime: &'static GatewayRuntime,
73    registry: &'static ConnectionRegistry,
74    container: std::sync::Arc<FrozenDiContainer>,
75    tuning: WsTuning,
76) -> MethodRouter {
77    let handler = move |ws: WebSocketUpgrade, headers: HeaderMap| {
78        let container = container.clone();
79        async move {
80            // Admission control happens BEFORE the upgrade — past the cap no
81            // socket, queue, or registry entry is ever created.
82            if tuning.max_connections > 0 && registry.connection_count() >= tuning.max_connections {
83                metrics::counter!("ws_upgrades_refused_total").increment(1);
84                return axum::http::Response::builder()
85                    .status(503)
86                    .header("retry-after", "5")
87                    .body(axum::body::Body::from("websocket capacity reached"))
88                    .expect("static refusal");
89            }
90            // The SAME unified extraction as the HTTP boundary (pipeline):
91            // trace + tenant + credentials in one pass. The handshake
92            // authenticates once; gateway handlers see claims AND the resolved
93            // tenant, and the connection inherits the caller's trace identity.
94            let provenance =
95                arcly_http_core::pipeline::Provenance::from_headers(&headers, &container).await;
96            tracing::debug!(
97                trace_id = %arcly_http_core::observability::lean_telemetry::hex_encode(&provenance.trace.trace_id),
98                tenant = provenance.tenant.as_deref().map(|t| t.id.as_str()).unwrap_or(""),
99                "WS handshake provenance"
100            );
101            ws.on_upgrade(move |socket| {
102                handle_socket(
103                    socket,
104                    runtime,
105                    registry,
106                    provenance.claims,
107                    provenance.tenant,
108                    tuning,
109                )
110            })
111        }
112    };
113    get(handler)
114}
115
116/// Drive one upgraded socket to completion: register, pump, dispatch, drain.
117async fn handle_socket(
118    socket: WebSocket,
119    runtime: &'static GatewayRuntime,
120    registry: &'static ConnectionRegistry,
121    claims: Option<Arc<Claims>>,
122    tenant: Option<Arc<arcly_http_core::web::tenant::TenantConfig>>,
123    tuning: WsTuning,
124) {
125    let (mut sink, mut stream) = socket.split();
126
127    // Outbound queue: any task enqueues, this socket's writer drains.
128    // **Bounded** — the depth is the per-socket memory ceiling; a client
129    // that can't drain it gets evicted by the registry, never buffered
130    // without limit.
131    let (tx, mut rx) = mpsc::channel::<WsMessage>(tuning.outbound_buffer.max(1));
132    let id = registry.register(tx, claims.clone());
133    let client = WsClient::__new(id, registry, claims, tenant);
134
135    // One-shot signal: when the writer exits for *any* reason (peer error,
136    // server-initiated Close, or channel closed), the reader is unblocked so it
137    // stops polling the stream and runs on_disconnect + unregister. Without this,
138    // a server-initiated close would leave the reader blocked on stream.next()
139    // indefinitely if the peer never sends a Close echo.
140    let (close_tx, mut close_rx) = oneshot::channel::<()>();
141
142    // Writer half — owns the sink; exits when the queue closes or the peer
143    // dies. A periodic Ping (when configured) keeps NATs/proxies open and
144    // provokes pongs that feed the idle sweeper's `last_seen`.
145    let ping_every = tuning.ping_interval;
146    let writer = tokio::spawn(async move {
147        let mut ping = (!ping_every.is_zero()).then(|| {
148            let mut t = tokio::time::interval(ping_every);
149            t.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
150            t
151        });
152        loop {
153            let msg = if let Some(t) = ping.as_mut() {
154                tokio::select! {
155                    m = rx.recv() => m,
156                    _ = t.tick() => {
157                        if sink.send(Message::Ping(bytes::Bytes::new())).await.is_err() {
158                            break;
159                        }
160                        continue;
161                    }
162                }
163            } else {
164                rx.recv().await
165            };
166            let Some(msg) = msg else { break };
167            let frame = match msg {
168                WsMessage::Text(arc) => Message::Text(arc.as_ref().into()),
169                WsMessage::Ping => Message::Ping(bytes::Bytes::new()),
170                WsMessage::Close => {
171                    // Send close frame then exit immediately — do NOT loop back
172                    // to rx.recv(), which would keep the sink open indefinitely.
173                    let _ = sink.send(Message::Close(None)).await;
174                    break;
175                }
176            };
177            if sink.send(frame).await.is_err() {
178                break;
179            }
180        }
181        // Dropping close_tx signals the reader regardless of why we exited.
182        drop(close_tx);
183    });
184
185    (runtime.on_connect)(client.clone()).await;
186
187    // Reader half — routes inbound frames to subscribed handlers.
188    // Also watches for the writer-exit signal so a server-initiated close
189    // (WsMessage::Close enqueued by a handler) terminates the reader promptly.
190    loop {
191        tokio::select! {
192            biased;
193            // Writer exited (server-initiated close or peer write error).
194            _ = &mut close_rx => break,
195            frame = stream.next() => match frame {
196                None => break,
197                Some(Err(_)) => break,
198                Some(Ok(frame)) => {
199                    // Any inbound frame (including pongs from our pings)
200                    // proves the link is alive for the idle sweeper.
201                    registry.touch(id);
202                    match frame {
203                        Message::Text(text) => dispatch_event(runtime, &client, &text).await,
204                        Message::Binary(_) => { /* binary multiplexing not enabled */ }
205                        Message::Close(_) => break,
206                        Message::Ping(_) | Message::Pong(_) => { /* axum auto-replies to pings */ }
207                    }
208                }
209            }
210        }
211    }
212
213    (runtime.on_disconnect)(client.clone()).await;
214    registry.unregister(id);
215    writer.abort();
216}
217
218/// Parse one `{ "event": ..., "data": ... }` envelope and invoke its handler.
219/// Unknown events and malformed frames are ignored (a hostile client can't
220/// crash the dispatcher).
221async fn dispatch_event(runtime: &'static GatewayRuntime, client: &WsClient, raw: &str) {
222    let Ok(value) = serde_json::from_str::<serde_json::Value>(raw) else {
223        return;
224    };
225    let Some(event) = value.get("event").and_then(|e| e.as_str()) else {
226        return;
227    };
228    let Some(handler) = runtime.handler(event) else {
229        return;
230    };
231
232    let data = value
233        .get("data")
234        .cloned()
235        .unwrap_or(serde_json::Value::Null);
236    let data_str: Arc<str> = Arc::from(serde_json::to_string(&data).unwrap_or_default());
237
238    metrics::counter!("ws_messages_in_total").increment(1);
239    // Handler errors stay at the transport edge — gateways own their own
240    // error-to-client signalling — but they are counted and logged so a
241    // misbehaving event handler is visible on dashboards.
242    if let Err(e) = handler(client.clone(), data_str).await {
243        metrics::counter!("ws_handler_errors_total").increment(1);
244        tracing::debug!(conn = client.id(), event, error = %e, "gateway handler error");
245    }
246}