Skip to main content

cellos_server/
ws.rs

1//! WebSocket → NATS bridge.
2//!
3//! `GET /ws/events` upgrades the connection, subscribes to
4//! `cellos.events.>` (or the optional `?subject=` override), and forwards
5//! every NATS message as a JSON envelope:
6//!
7//! ```json
8//! { "seq": 12345, "event": { /* CloudEvent */ } }
9//! ```
10//!
11//! `seq` is the cursor described in ADR-0015. When the underlying
12//! subscription is a JetStream consumer it is the JetStream stream
13//! sequence; when it is a core-NATS subscription (today's MVP path) it
14//! is a per-connection monotonic counter. Either way the contract on
15//! the wire is the same: `seq` is monotonic, and the snapshot
16//! endpoint's `cursor` is comparable to it on the same broker.
17//!
18//! ## ADR-0015 §D3 — `?since=<seq>` resume
19//!
20//! Clients open `/ws/events?since=<cursor>` to resume after a
21//! reconnect. With a JetStream consumer this maps directly to
22//! `DeliverPolicy::ByStartSequence { OptStartSeq: since + 1 }`. With
23//! the core-NATS bridge the parameter is *accepted but cannot replay
24//! history* — the bridge only delivers messages published after the
25//! subscription was created. The contract is preserved (every frame
26//! still carries `seq`) so clients work transparently against either
27//! bridge; migration to JetStream is tracked as a follow-up. Omitting
28//! `since` keeps today's "new messages only" behavior for ad-hoc
29//! subscribers (cellctl `--follow`, debug tools).
30//!
31//! ## ADR-0015 §D6 — heartbeat
32//!
33//! A WebSocket can sit idle on a quiet stream without either side
34//! knowing the underlying socket has died. The server sends a `Ping`
35//! frame every 25 seconds when idle; axum's WebSocket type handles the
36//! pong roundtrip transparently.
37
38use std::time::Duration;
39
40use axum::body::Bytes;
41use axum::extract::ws::{Message, WebSocket};
42use axum::extract::{Query, State, WebSocketUpgrade};
43use axum::http::HeaderMap;
44use axum::response::IntoResponse;
45use futures_util::{SinkExt, StreamExt};
46use serde::Deserialize;
47use tokio::time::interval;
48use tracing::{debug, info, warn};
49
50use crate::auth::require_bearer;
51use crate::error::AppError;
52use crate::jetstream::{looks_like_retention_exhausted, open_ws_message_stream, stream_first_seq};
53use crate::state::AppState;
54
55const DEFAULT_SUBJECT: &str = "cellos.events.>";
56/// ADR-0015 §D6 — heartbeat interval. The web view treats >45s of
57/// silence as a dead connection; 25s keeps us well inside that budget.
58const HEARTBEAT: Duration = Duration::from_secs(25);
59
60/// Per-frame / per-message ceiling on inbound WebSocket data.
61///
62/// axum defaults to 16 MiB / 64 MiB. `/ws/events` is a one-way feed
63/// from the server to the client — we deliberately ignore all inbound
64/// frames other than `Close`. Accepting the default ceiling would let
65/// an authed client park a 64 MiB frame in the kernel buffer for free
66/// (resource amplification post-auth). 64 KiB is far more than any
67/// control frame a future client might send and below the threshold
68/// where a single misbehaving client can hurt the server.
69const WS_MAX_FRAME_BYTES: usize = 64 * 1024;
70
71/// Per-frame send timeout for the data path. Red-team wave 2 (MED-W2A-2):
72/// without a bounded send, a client whose TCP receive window is wedged
73/// (suspended laptop, congested link) parks the entire WS task on
74/// `tx.send().await` — heartbeats stop, client-close goes unobserved,
75/// the JetStream pull stream backs up behind the un-drained channel,
76/// and the broker doesn't reclaim the consumer until
77/// `EPHEMERAL_INACTIVE_THRESHOLD` (5 min). 50s is comfortably above
78/// two heartbeat intervals; anything longer than that and the client
79/// is effectively gone.
80const WS_SEND_TIMEOUT: Duration = Duration::from_secs(50);
81
82#[derive(Debug, Deserialize)]
83pub struct WsParams {
84    /// Optional NATS subject filter. Defaults to `cellos.events.>` which
85    /// receives every CloudEvent the platform emits. Callers can scope
86    /// to a tenant with e.g. `?subject=cellos.events.tenant1.>`.
87    pub subject: Option<String>,
88    /// ADR-0015 §D3 — resume cursor. When present the server starts
89    /// delivery at `since + 1`. With the core-NATS MVP bridge this is
90    /// accepted but historical replay is unavailable; the contract
91    /// (every frame carries `seq`) is preserved either way so clients
92    /// don't branch on the bridge implementation.
93    pub since: Option<u64>,
94}
95
96pub async fn ws_events(
97    State(state): State<AppState>,
98    headers: HeaderMap,
99    Query(params): Query<WsParams>,
100    ws: WebSocketUpgrade,
101) -> Result<impl IntoResponse, AppError> {
102    // Auth runs BEFORE the upgrade so an unauthenticated client sees
103    // 401 problem+json rather than a confusing protocol error.
104    require_bearer(&headers, &state.api_token)?;
105
106    let subject = params.subject.unwrap_or_else(|| DEFAULT_SUBJECT.to_owned());
107    let since = params.since;
108    // Cap inbound frame/message size — see WS_MAX_FRAME_BYTES. axum's
109    // defaults (16 MiB / 64 MiB) are too generous for a one-way feed.
110    let ws = ws
111        .max_message_size(WS_MAX_FRAME_BYTES)
112        .max_frame_size(WS_MAX_FRAME_BYTES);
113    Ok(ws.on_upgrade(move |socket| handle_socket(socket, state, subject, since)))
114}
115
116async fn handle_socket(socket: WebSocket, state: AppState, subject: String, since: Option<u64>) {
117    let Some(ctx) = state.jetstream.clone() else {
118        warn!("ws connect with no JetStream context configured; closing");
119        let _ = socket
120            .send_close_with_reason("no upstream broker configured")
121            .await;
122        return;
123    };
124
125    let subject_filter = if subject == DEFAULT_SUBJECT {
126        None
127    } else {
128        Some(subject.as_str())
129    };
130
131    // ADR-0015 §D3 — open the JetStream consumer with the right
132    // DeliverPolicy. When `since` is provided we resume at `since+1`;
133    // otherwise we live-tail from the next published message.
134    let mut messages = match open_ws_message_stream(&ctx, subject_filter, since).await {
135        Ok(s) => s,
136        Err(e) => {
137            warn!(error = %format!("{e:#}"), subject = %subject, since = ?since, "jetstream consumer create failed");
138            // ADR-0015 §D7 — when `since` is older than the stream's
139            // retention floor, close with the 4410 retention-exhausted
140            // contract. The client treats 4410 as "drop cache,
141            // re-hydrate from snapshot, reconnect at new cursor".
142            if since.is_some() && looks_like_retention_exhausted(&e) {
143                let oldest = stream_first_seq(&ctx).await;
144                close_retention_exhausted(socket, oldest).await;
145            } else {
146                let _ = socket.send_close_with_reason("subscribe failed").await;
147            }
148            return;
149        }
150    };
151
152    info!(
153        subject = %subject,
154        since = ?since,
155        "ws client connected, bridging JetStream messages",
156    );
157    let (mut tx, mut rx) = socket.split();
158
159    let mut heartbeat = interval(HEARTBEAT);
160    // The first tick fires immediately; skip it so we don't ping
161    // before the client has had a chance to settle.
162    heartbeat.tick().await;
163
164    loop {
165        tokio::select! {
166            // Red-team wave 2 (MED-W2A-1): `biased` so client-close and
167            // heartbeat ticks take priority over a saturated message
168            // firehose. Without this, a flood on `cellos.events.>` could
169            // starve `rx.next()` and leave a dead consumer pinned on the
170            // broker for the full 5-min inactive-threshold window after
171            // the client gives up.
172            biased;
173            incoming = rx.next() => {
174                match incoming {
175                    Some(Ok(Message::Close(_))) | None => {
176                        debug!("ws client closed");
177                        break;
178                    }
179                    Some(Err(e)) => {
180                        warn!(error = %e, "ws recv error");
181                        break;
182                    }
183                    // Ignore inbound pings/pongs/text/binary — this is a
184                    // one-way feed for the MVP.
185                    Some(Ok(_)) => {}
186                }
187            }
188            _ = heartbeat.tick() => {
189                // ADR-0015 §D6 — keepalive. axum's WebSocket auto-
190                // replies to pings *from* the client; sending one
191                // *to* the client is on us. Empty payload is fine —
192                // the client only cares that a frame arrived.
193                match tokio::time::timeout(WS_SEND_TIMEOUT, tx.send(Message::Ping(Bytes::new()))).await {
194                    Ok(Ok(())) => {}
195                    Ok(Err(_)) => {
196                        debug!("ws heartbeat send failed; client gone");
197                        break;
198                    }
199                    Err(_) => {
200                        warn!("ws heartbeat send timed out after {:?}; closing", WS_SEND_TIMEOUT);
201                        break;
202                    }
203                }
204            }
205            msg = messages.next() => {
206                match msg {
207                    Some(Ok(m)) => {
208                        // ADR-0015 §D1 — seq is the JetStream stream
209                        // sequence, broker-authoritative across reconnects.
210                        let seq = match m.info() {
211                            Ok(info) => info.stream_sequence,
212                            Err(e) => {
213                                warn!(error = %e, "ws msg missing stream info; skipping");
214                                continue;
215                            }
216                        };
217                        let payload = match build_envelope(seq, &m.payload) {
218                            Ok(s) => s,
219                            Err(EnvelopeError::NotUtf8) => {
220                                warn!(subject = %subject, "dropping non-utf8 jetstream payload");
221                                continue;
222                            }
223                            Err(EnvelopeError::NotJson(e)) => {
224                                warn!(
225                                    subject = %subject,
226                                    error = %e,
227                                    "dropping non-json jetstream payload",
228                                );
229                                continue;
230                            }
231                        };
232                        // ADR-0015 §D2 — bump the projection cursor so
233                        // future snapshot fetches advertise the latest
234                        // applied seq. Monotonic; bump_cursor handles
235                        // out-of-order under concurrent connections.
236                        state.bump_cursor(seq);
237                        // Red-team wave 2 (MED-W2A-2): bounded send.
238                        // Without a timeout a wedged TCP receive window
239                        // (suspended laptop, congested link) parks the
240                        // entire WS task on `tx.send().await`, starving
241                        // heartbeats and rx, and pinning the broker
242                        // consumer for `EPHEMERAL_INACTIVE_THRESHOLD`.
243                        match tokio::time::timeout(WS_SEND_TIMEOUT, tx.send(Message::Text(payload.into()))).await {
244                            Ok(Ok(())) => {}
245                            Ok(Err(_)) => {
246                                debug!("ws send failed; client gone");
247                                break;
248                            }
249                            Err(_) => {
250                                warn!(seq, "ws send timed out after {:?}; closing", WS_SEND_TIMEOUT);
251                                break;
252                            }
253                        }
254                        // The consumer is created with `AckPolicy::None`
255                        // (see jetstream.rs::create_ephemeral_consumer)
256                        // so JetStream never expects an ack and never
257                        // redelivers. This call is a no-op under the
258                        // current policy; we keep it so that flipping
259                        // the consumer to `AckPolicy::Explicit` later
260                        // does not silently lose ack semantics.
261                        if let Err(e) = m.ack().await {
262                            debug!(seq, error = %e, "jetstream ack failed (AckPolicy::None)");
263                        }
264                    }
265                    Some(Err(e)) => {
266                        warn!(error = %e, "jetstream message error; closing ws");
267                        break;
268                    }
269                    None => {
270                        debug!("jetstream message stream ended");
271                        break;
272                    }
273                }
274            }
275        }
276    }
277
278    info!(subject = %subject, "ws client disconnected");
279}
280
281/// ADR-0015 §D7 — close the WebSocket with the 4410 retention-exhausted
282/// contract. Sends a problem+json text frame describing the failure,
283/// then the close frame with the custom 4410 code. The client treats
284/// this as "drop my cached projection, re-hydrate from snapshot, and
285/// reconnect at the new cursor".
286async fn close_retention_exhausted(mut socket: WebSocket, oldest_seq: Option<u64>) {
287    let problem = serde_json::json!({
288        "type": "/problems/ws/retention-exhausted",
289        "title": "Cursor older than stream retention",
290        "oldest_seq": oldest_seq,
291    });
292    let _ = socket.send(Message::Text(problem.to_string().into())).await;
293    let _ = socket
294        .send(Message::Close(Some(axum::extract::ws::CloseFrame {
295            code: 4410,
296            reason: "retention-exhausted".into(),
297        })))
298        .await;
299}
300
301/// Build the ADR-0015 `{seq, event}` envelope JSON for a single NATS
302/// payload. Pulled out of the hot loop so tests can exercise the
303/// contract without spinning up a NATS broker.
304///
305/// Returns `Err` if the payload is not valid UTF-8 or not valid JSON —
306/// the bridge logs and drops in both cases, but the test surface needs
307/// to distinguish them.
308pub(crate) fn build_envelope(seq: u64, payload: &[u8]) -> Result<String, EnvelopeError> {
309    let s = std::str::from_utf8(payload).map_err(|_| EnvelopeError::NotUtf8)?;
310    let event_value: serde_json::Value = serde_json::from_str(s).map_err(EnvelopeError::NotJson)?;
311    let envelope = serde_json::json!({ "seq": seq, "event": event_value });
312    Ok(envelope.to_string())
313}
314
315#[derive(Debug)]
316pub(crate) enum EnvelopeError {
317    NotUtf8,
318    NotJson(serde_json::Error),
319}
320
321/// Helper trait so the early-exit paths can write a close frame without
322/// re-stating the (code, reason) tuple at every call site.
323trait CloseExt {
324    async fn send_close_with_reason(self, reason: &'static str) -> Result<(), axum::Error>;
325}
326
327impl CloseExt for WebSocket {
328    async fn send_close_with_reason(mut self, reason: &'static str) -> Result<(), axum::Error> {
329        self.send(Message::Close(Some(axum::extract::ws::CloseFrame {
330            code: axum::extract::ws::close_code::POLICY,
331            reason: reason.into(),
332        })))
333        .await
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// ADR-0015 §D1 — every `/ws/events` frame is a JSON envelope
342    /// `{seq, event}` where `event` is the CloudEvent parsed as a
343    /// structured value (not a string-of-JSON). This is the contract
344    /// the web view reducer keys off of, so the test is deliberately
345    /// at the byte level.
346    #[test]
347    fn ws_envelope_carries_seq() {
348        let cloud_event = serde_json::json!({
349            "specversion": "1.0",
350            "type": "io.cellos.formation.v1.created",
351            "source": "/formations/abc",
352            "id": "evt-1",
353            "data": { "name": "demo" }
354        });
355        let payload = serde_json::to_vec(&cloud_event).unwrap();
356
357        let frame = build_envelope(42, &payload).expect("envelope build");
358        let parsed: serde_json::Value = serde_json::from_str(&frame).unwrap();
359
360        assert_eq!(
361            parsed["seq"].as_u64(),
362            Some(42),
363            "envelope must carry the seq as the cursor field; got {}",
364            parsed["seq"]
365        );
366        assert!(
367            parsed["event"].is_object(),
368            "event must be a structured JSON object, not a string-of-JSON; got {}",
369            parsed["event"]
370        );
371        assert_eq!(parsed["event"]["type"], "io.cellos.formation.v1.created");
372        assert_eq!(parsed["event"]["data"]["name"], "demo");
373    }
374
375    #[test]
376    fn ws_envelope_rejects_non_utf8_payload() {
377        // Invalid UTF-8 sequence; must be dropped rather than mangled
378        // so the producer-side bug surfaces on the wire test, not as
379        // garbled frames on the client.
380        let bad = [0xffu8, 0xfe, 0xfd];
381        match build_envelope(1, &bad) {
382            Err(EnvelopeError::NotUtf8) => {}
383            other => panic!("expected NotUtf8, got {other:?}"),
384        }
385    }
386
387    #[test]
388    fn ws_envelope_rejects_non_json_payload() {
389        // Producers MUST emit CloudEvent JSON. Plain text is a contract
390        // violation and the bridge drops it.
391        let bad = b"hello, world";
392        match build_envelope(1, bad) {
393            Err(EnvelopeError::NotJson(_)) => {}
394            other => panic!("expected NotJson, got {other:?}"),
395        }
396    }
397}