Sidecar: REST query layer
rpc.rs bridges synchronous REST to the async shard stream. A call registers a
pending entry under a correlation id, sends the command, and awaits the reply
(10s timeout). The event loop routes any incoming line whose id is pending back
to the waiting caller; everything else stays a live event and is broadcast. Three
correlation fields are recognized, matching what the plugin echoes: reqId
(queries), code (link.confirm), id (towncrier).
web.rs adds the routes: GET /char/{account}/{slot}, /char/serial/{serial},
/roster/{account}, /vendors/{account}; POST /link/confirm, POST /towncrier,
DELETE /towncrier/{id}. A shard *.error reply maps to 404 or 400; no shard -> 503;
no reply in time -> 504.
Verified end to end against the live shard: roster and full char profile returned
as JSON (reqId correlation visible as r-1, r-2, ...), an unknown account returned
bridge.error as HTTP 404, vendor snapshot returned seed_000's two shops, towncrier
publish and remove returned towncrier.ok, and a bad link code returned link.error
as 404. The website can now query the game and push commands, all correlated over
the single loopback socket, all through the sidecar the game never directly
exposes.
Only SQLite persistence remains on the sidecar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//! The website-facing HTTP surface: a WebSocket live feed now, REST queries later.
|
||||
//! The website-facing HTTP surface: a WebSocket live feed and REST queries.
|
||||
//!
|
||||
//! 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
|
||||
@@ -8,26 +8,40 @@ use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
extract::ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
extract::State,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
Router,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
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.
|
||||
use crate::rpc::{Rpc, RpcError};
|
||||
use crate::shard::ShardHandle;
|
||||
|
||||
/// Shared state handed to each request handler.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub events: broadcast::Sender<String>,
|
||||
pub shard: ShardHandle,
|
||||
pub rpc: Rpc,
|
||||
}
|
||||
|
||||
/// 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))
|
||||
// Queries (shard reply correlated by reqId).
|
||||
.route("/char/:account/:slot", get(char_by_slot))
|
||||
.route("/char/serial/:serial", get(char_by_serial))
|
||||
.route("/roster/:account", get(roster))
|
||||
.route("/vendors/:account", get(vendors))
|
||||
// Inbound commands (correlated by code / id).
|
||||
.route("/link/confirm", post(link_confirm))
|
||||
.route("/towncrier", post(towncrier_add))
|
||||
.route("/towncrier/:id", axum::routing::delete(towncrier_remove))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
@@ -40,17 +54,129 @@ async fn health() -> impl IntoResponse {
|
||||
"ok"
|
||||
}
|
||||
|
||||
// ---- shared reply handling ----
|
||||
|
||||
/// Turns an RPC result into an HTTP response. A `bridge.error` reply from the shard becomes a 4xx;
|
||||
/// a real reply is returned as-is; transport failures map to 503/504.
|
||||
fn respond(result: Result<Value, RpcError>) -> (StatusCode, Json<Value>) {
|
||||
match result {
|
||||
Ok(value) => {
|
||||
let kind = value.get("kind").and_then(|k| k.as_str()).unwrap_or("");
|
||||
if kind == "bridge.error" || kind.ends_with(".error") {
|
||||
let reason = value
|
||||
.get("reason")
|
||||
.and_then(|r| r.as_str())
|
||||
.unwrap_or("request rejected");
|
||||
// "unknown account" / "no character" are not-founds; the rest are bad requests.
|
||||
let code = if reason.contains("unknown") || reason.contains("no ") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
(code, Json(value))
|
||||
} else {
|
||||
(StatusCode::OK, Json(value))
|
||||
}
|
||||
}
|
||||
Err(RpcError::NoShard) => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({"error": "shard not connected"})),
|
||||
),
|
||||
Err(RpcError::Timeout) => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(json!({"error": "shard did not reply in time"})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- query handlers ----
|
||||
|
||||
async fn char_by_slot(
|
||||
State(st): State<AppState>,
|
||||
Path((account, slot)): Path<(String, i64)>,
|
||||
) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({"kind":"char.request","reqId":req_id,"account":account,"slot":slot});
|
||||
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
}
|
||||
|
||||
async fn char_by_serial(
|
||||
State(st): State<AppState>,
|
||||
Path(serial): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({"kind":"char.request","reqId":req_id,"serial":serial});
|
||||
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
}
|
||||
|
||||
async fn roster(State(st): State<AppState>, Path(account): Path<String>) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({"kind":"account.roster","reqId":req_id,"account":account});
|
||||
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
}
|
||||
|
||||
async fn vendors(State(st): State<AppState>, Path(account): Path<String>) -> impl IntoResponse {
|
||||
let req_id = st.rpc.next_req_id();
|
||||
let cmd = json!({"kind":"vendor.snapshot","reqId":req_id,"account":account});
|
||||
respond(st.rpc.call(&st.shard, cmd, &req_id).await)
|
||||
}
|
||||
|
||||
// ---- inbound-command handlers ----
|
||||
|
||||
/// Body: {"code":"AB12CD","websiteUserId":"9931"}. Correlated on `code`.
|
||||
async fn link_confirm(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let code = body.get("code").and_then(|c| c.as_str()).unwrap_or_default();
|
||||
let web_id = body
|
||||
.get("websiteUserId")
|
||||
.and_then(|w| w.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
if code.is_empty() || web_id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "code and websiteUserId are required"})),
|
||||
);
|
||||
}
|
||||
|
||||
let cmd = json!({"kind":"link.confirm","code":code,"websiteUserId":web_id});
|
||||
let code = code.to_string();
|
||||
respond(st.rpc.call(&st.shard, cmd, &code).await)
|
||||
}
|
||||
|
||||
/// Body: {"id":"n123","lines":["..."],"durationSec":3600}. Correlated on `id`.
|
||||
async fn towncrier_add(State(st): State<AppState>, Json(body): Json<Value>) -> impl IntoResponse {
|
||||
let id = body.get("id").and_then(|i| i.as_str()).unwrap_or_default();
|
||||
if id.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "id is required"})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut cmd = body.clone();
|
||||
cmd["kind"] = json!("towncrier.add");
|
||||
let id = id.to_string();
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
|
||||
async fn towncrier_remove(
|
||||
State(st): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
let cmd = json!({"kind":"towncrier.remove","id":id});
|
||||
respond(st.rpc.call(&st.shard, cmd, &id).await)
|
||||
}
|
||||
|
||||
// ---- websocket ----
|
||||
|
||||
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
|
||||
@@ -61,37 +187,27 @@ async fn ws_client(mut socket: WebSocket, state: AppState) {
|
||||
|
||||
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
|
||||
break;
|
||||
}
|
||||
}
|
||||
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(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;
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user