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}