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

View File

@@ -1,45 +1,60 @@
//! uo-link sidecar.
//!
//! Terminates the loopback link to the ServUO shard and (in later phases) exposes WebSocket + REST
//! to the website. This first cut proves the shard link: it receives real events and can send
//! commands back.
//! Terminates the loopback link to the ServUO shard and exposes a website-facing HTTP surface. So
//! far: the shard link (bidirectional) and a WebSocket live feed. REST queries and SQLite come next.
mod shard;
mod web;
use tokio::sync::mpsc;
use tokio::sync::{broadcast, mpsc};
use tracing::info;
use tracing_subscriber::EnvFilter;
const SHARD_ADDR: &str = "127.0.0.1:7788";
const WEB_ADDR: &str = "127.0.0.1:8080";
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
info!("uo-link sidecar starting");
// Shard link: events in, commands out.
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<shard::ShardEvent>();
let handle = shard::serve(SHARD_ADDR, event_tx).await?;
// Phase 1: log every event and tally by kind. Later phases fan this out to WS + SQLite.
// Live feed: every shard event fans out to all connected website WebSocket clients.
let (bcast_tx, _) = broadcast::channel::<String>(1024);
// Website-facing HTTP server.
let web_state = web::AppState {
events: bcast_tx.clone(),
};
tokio::spawn(async move {
if let Err(e) = web::serve(WEB_ADDR, web_state).await {
tracing::error!(error = %e, "web server exited");
}
});
// Event loop: log, then broadcast. Later phases also persist to SQLite here.
let feed_tx = bcast_tx.clone();
let mut total: u64 = 0;
tokio::spawn(async move {
while let Some(ev) = event_rx.recv().await {
total += 1;
match ev.kind.as_str() {
// These are the anchors worth surfacing at info; the rest are debug.
"server.hello" | "mob.login" | "mob.logout" | "player.death"
| "vendor.sale" | "house.decay" | "link.request" => {
"server.hello" | "mob.login" | "mob.logout" | "player.death" | "vendor.sale"
| "house.decay" | "link.request" => {
info!(kind = %ev.kind, n = total, "{}", ev.value);
}
_ => {
tracing::debug!(kind = %ev.kind, n = total, "{}", ev.value);
}
_ => tracing::debug!(kind = %ev.kind, n = total, "{}", ev.value),
}
// Fan out to WebSocket clients. Err just means nobody is subscribed right now.
let _ = feed_tx.send(ev.value.to_string());
}
});
// A tiny demonstration that the command path works: ping the shard once it connects.
// Heartbeat to the shard, exercising the command path.
let ping_handle = handle.clone();
tokio::spawn(async move {
loop {
@@ -52,7 +67,6 @@ async fn main() -> anyhow::Result<()> {
}
});
// Run until Ctrl-C.
tokio::signal::ctrl_c().await?;
info!("shutting down");
Ok(())