Sidecar: WebSocket live feed

web.rs adds the website-facing HTTP surface (axum): GET /health and GET /ws.
Every shard event is broadcast to all connected WebSocket clients as a JSON text
frame. main.rs's event loop now broadcast::sends each event after logging it.

A lagging client is warned and kept live (misses events) rather than stalling
the others; a dead-but-not-closed socket is caught by a 30s server ping. The feed
is live-only -- no replay -- since history belongs to REST + SQLite. This side may
be exposed beyond loopback (it is the gatekeeper); it defaults to 127.0.0.1:8080
and wants auth before going public.

Verified end to end: a WebSocket client connected to /ws, received ws.hello, then
live pong events relayed from the shard through the shard-link -> broadcast -> ws
path -- the same path a login, sale, or IDOC alert will take to a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:04:58 -05:00
parent b46005b333
commit aff10e846c
5 changed files with 652 additions and 16 deletions

104
sidecar/src/web.rs Normal file
View File

@@ -0,0 +1,104 @@
//! The website-facing HTTP surface: a WebSocket live feed now, REST queries later.
//!
//! Unlike the shard link, this side *may* be exposed beyond loopback — it is the website's entry
//! point, and the sidecar is the gatekeeper. It defaults to loopback anyway; widen the bind address
//! deliberately (and add auth) when the website runs on another host.
use std::time::Duration;
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
extract::State,
response::IntoResponse,
routing::get,
Router,
};
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
/// Shared state handed to each request handler. `events` is the live broadcast every WebSocket
/// client subscribes to.
#[derive(Clone)]
pub struct AppState {
pub events: broadcast::Sender<String>,
}
/// Binds the website-facing HTTP server and serves until the process ends.
pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> {
let app = Router::new()
.route("/health", get(health))
.route("/ws", get(ws_upgrade))
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
info!(%addr, "web server listening");
axum::serve(listener, app).await?;
Ok(())
}
async fn health() -> impl IntoResponse {
"ok"
}
async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| ws_client(socket, state))
}
/// One connected website client. Forwards every live event to it as a JSON text frame. A slow
/// client that falls behind the broadcast buffer is dropped rather than allowed to stall others.
async fn ws_client(mut socket: WebSocket, state: AppState) {
let mut rx = state.events.subscribe();
info!("ws client connected");
// Greet with a small hello so the client knows the feed is live.
if socket
.send(Message::Text(r#"{"kind":"ws.hello"}"#.to_string()))
.await
.is_err()
{
return;
}
loop {
tokio::select! {
// Live event to push out.
recv = rx.recv() => {
match recv {
Ok(line) => {
if socket.send(Message::Text(line)).await.is_err() {
break; // client went away
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "ws client lagged; dropping missed events");
// keep going; the client stays live, just missed some
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
// Client -> server frames. We accept and mostly ignore them (a control channel for
// later); respond to pings and honor close.
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => break,
Some(Ok(Message::Ping(p))) => {
let _ = socket.send(Message::Pong(p)).await;
}
Some(Ok(other)) => debug!(?other, "ws client message ignored"),
Some(Err(e)) => {
debug!(error = %e, "ws client error");
break;
}
}
}
// Cheap liveness ping so a dead-but-not-closed socket is noticed.
_ = tokio::time::sleep(Duration::from_secs(30)) => {
if socket.send(Message::Ping(Vec::new())).await.is_err() {
break;
}
}
}
}
info!("ws client disconnected");
}