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