Files
link/sidecar/README.md
colby 946ba7027b Sidecar: SQLite persistence
store.rs (sqlx/sqlite) makes the data durable and queryable over time. Three
tables: events (the full live stream, append-only), links (account <-> website
user, mirrored from link.ok), profiles (last-known character sheet, cached from
char.profile). The event loop persists every live event before broadcasting it;
pong is dropped as ephemeral chatter.

New read endpoints served from the DB rather than the shard: GET /history
(optionally ?kind=), GET /economy (the money-supply series), GET /link/{account}.
GET /char/serial/{serial} now falls back to the cached profile when the shard is
unreachable, so an already-viewed character still renders during an outage;
link.confirm mirrors a successful link into the store.

Verified end to end: 10 economy.supply snapshots and the rest of the live stream
persisted and served via /history and /economy; the data survived a sidecar
restart (14 events still present, and the shard reconnected to the new sidecar);
and with the shard killed, a cached profile returned at HTTP 200 while an uncached
query failed cleanly at 503.

The sidecar is feature-complete: shard link, WebSocket feed, REST queries, and
persistence all work end-to-end against the live shard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:20:33 -05:00

5.6 KiB

uo-link sidecar

The Rust half of the bridge. It terminates the loopback link to the ServUO shard and (as it grows) exposes WebSocket + REST to the website.

website ──WS (live feed) / REST (queries)──►  sidecar  ──loopback TCP 127.0.0.1:7788──►  shard
                                              (this)      newline-JSON, bidirectional

The sidecar is the TCP listener; the shard dials out to it. That is what keeps the game unreachable from the website — the game exposes no port of its own. See ../docs/PLAN.md §2.

Run

cargo run                 # info logging
RUST_LOG=debug cargo run  # see every event, incl. pong heartbeats

Binds 127.0.0.1:7788 and waits for the shard to connect. Boot the shard (or it will reconnect on its own) and watch server.hello arrive.

Status

Piece State
Shard link (shard.rs) done — accepts the shard, reads events, sends commands, re-accepts on disconnect. Verified against the live shard: received server.hello, round-tripped a pingpong, and reconnected after a sidecar restart.
WebSocket feed (web.rs) done/ws fans every shard event out to connected clients via a broadcast. Verified: a WS client received ws.hello then live pong events relayed from the shard. Live-only, no replay.
REST queries (rpc.rs + web.rs) done — synchronous queries and commands, correlated to shard replies by id. Verified end-to-end against the live shard, success and error paths.
SQLite persistence (store.rs) done — every live event persisted; history/economy served from the DB; profiles cached with shard-down fallback; link map. Verified: data survived a sidecar restart, and a cached profile served at 200 with the shard killed.

The sidecar is feature-complete. All four pieces work end-to-end against the live shard.

The web server binds 127.0.0.1:8080 by default (WEB_ADDR in main.rs). Widen the bind and add auth before exposing it off-host.

Routes

Method Path Shard command Reply
GET /health ok
GET /ws live event feed (WebSocket)
GET /char/{account}/{slot} char.request char.profile
GET /char/serial/{serial} char.request char.profile
GET /roster/{account} account.roster account.roster
GET /vendors/{account} vendor.snapshot vendor.snapshot
POST /link/confirm {code, websiteUserId} link.confirm link.ok / link.error
POST /towncrier {id, lines, durationSec} towncrier.add towncrier.ok / towncrier.error
DELETE /towncrier/{id} towncrier.remove towncrier.ok / towncrier.error
GET /link/{account} — (reads store) {account, websiteUserId} or 404
GET /history?kind=&limit= — (reads store) {events: [...]} newest first
GET /economy?limit= — (reads store) {series: [...]} supply snapshots

A shard *.error reply maps to HTTP 404 (unknown/not-found) or 400 (bad request). No shard connected → 503; no reply within 10 s → 504. GET /char/serial/{serial} falls back to the cached profile when the shard is unreachable, so an already-viewed character still renders during an outage.

Design

  • shard.rsserve() binds the listener and accepts shard connections in a loop. Each connection splits into read/write halves: the read half parses newline-JSON into ShardEvent { kind, value } and forwards them; the write half drains an mpsc of command lines. ShardHandle::send posts a command to whichever shard is currently connected, and drops with a warning if none is — a website query during a shard outage should fail fast and retry, not queue behind a reconnect. Live events that must survive an outage are buffered by the shard, not here.
  • web.rs — the website-facing HTTP surface (axum). AppState holds the broadcast::Sender<String>; each /ws client subscribes and forwards every event as a text frame. A client that lags past the broadcast buffer is warned and kept live (it just misses events) rather than stalling the others. This side may be exposed beyond loopback — it is the gatekeeper, so add auth when you do.
  • rpc.rs — request/reply correlation over the one shard socket. A REST call registers a pending entry under a correlation id, sends the command, and awaits the reply (10 s timeout). The event loop routes any incoming line whose id is pending back to the waiter; everything else flows on as a live event. Recognizes three correlation fields, matching what the plugin echoes: reqId (queries), code (link), id (town-crier).
  • store.rs — SQLite (sqlx). Three tables: events (the full live stream, append-only), links (account ↔ website user, mirrored from link.ok), profiles (last-known character sheet, cached from char.profile). History and economy read here instead of the shard; pong is dropped as ephemeral chatter. DB file defaults to uo-link.db (DB_PATH in main.rs), gitignored.
  • main.rs — wires it together: the shard event loop first tries to route each line as an RPC reply; if it isn't one, the line is a live event — logged, persisted, and broadcast to WS.

Wire protocol

Every line is one JSON object with t (epoch ms) and kind. The shard→sidecar events and sidecar→shard commands are catalogued in ../docs/PLAN.md (§5 data catalog, §7 protocol) and were all validated end-to-end while building the plugin. Notable inbound commands the sidecar will issue: char.request, account.roster, vendor.snapshot, link.confirm, towncrier.add/remove, ping.