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}