The sidecar now files a frame by its `type` and never by its `kind`. That is the dumb-forwarder property made structural: `event` is appended to history, `snapshot` replaces the board of its kind, `reply` is routed by `reqId`, `control` is broadcast and kept nowhere. Ten new event kinds are no change here at all, which is the whole point when the thing that grows fastest is the catalogue. A frame whose `type` this build does not know is dropped and counted, never guessed at. Defaulting an absent one to `event` would file a BOARD as history — the presence board appended a few thousand times, which nothing reports. The count is on `/health` as `untyped_frames`, because the failure it diagnoses (a plugin and a sidecar on different protocol versions, which the game link has no handshake to catch) otherwise presents as a website showing nothing while the game is plainly up. It caught exactly that within three seconds of first running, against a protocol 1 plugin still live on a retired rig. `boards` generalises protocol 1's single `server_state` row, and a database made by protocol 1 is migrated in place: the two indexed columns are added by a guarded `ALTER`, and the old board is carried across. Without that carry-over an upgraded sidecar answers `204` until the game next connects, and the website reads that as "never heard from" — losing a server it has rendered for weeks at the exact moment somebody upgraded the bridge. `GET /feed` is the ingest cursor: oldest first, strictly after an id, with `lastId` and `more`. It is a separate route rather than a flag on `/events` because one route with two orderings serves the other one to every caller that forgets the parameter — and for the ingesting caller that means advancing its cursor past rows it never read. Omitting `since` asks where the END is; `since=0` is the other question entirely, and the two must not be separated by whether somebody typed a parameter. `[store].retain_days` (default 14) prunes events hourly. Boards are never pruned: history grows and the present does not, and a pruned board is a server that has never connected. The repository also had no CI. `pr-checks.yml` runs the fmt, clippy and test gates phases 1 and 3 have both been running by hand — a guard nothing invokes is a guard whose state nobody knows. 44 tests pass, clippy clean at `-D warnings`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
rust-link-sidecar
Configuration reference and endpoint list. For what this component is, see the repo README.
Configuration
sidecar.toml, resolved in this order: --config <PATH>, else $RUSTLINK_CONFIG, else
./sidecar.toml. Environment variables override the file; the file overrides the defaults.
| Key | Env | Default | What it is |
|---|---|---|---|
[game].bind |
RUSTLINK_GAME_BIND |
127.0.0.1:7799 |
Where the Oxide plugin dials in |
[game].server_id |
RUSTLINK_SERVER_ID |
(empty) | Optional cross-check against the plugin's own serverId |
[web].bind |
RUSTLINK_WEB_BIND |
127.0.0.1:8090 |
Where the website reaches this sidecar |
[web].auth_token |
RUSTLINK_WEB_TOKEN |
(generated) | The shared secret the website presents |
[store].path |
RUSTLINK_DB_PATH |
rust-link.db |
SQLite file |
[store].retain_days |
RUSTLINK_RETAIN_DAYS |
14 |
Days of event history to keep. 0 keeps everything |
Two things about those defaults are load-bearing:
[game].bindis loopback, and there is no token on that link. The plugin and the sidecar share a host;127.0.0.1is the authentication. Binding it to a routable address puts an unauthenticated command channel on the network.- A relative
[store].pathresolves against the directory holdingsidecar.toml, not the working directory. A service manager's working directory must not decide where the database lands — on Windows that can be%SystemRoot%\System32, or a silently redirected VirtualStore copy.
[game].server_id is a cross-check, not a second source of truth. The plugin announces its own
serverId and that is the authority; when both are set and they disagree, the sidecar logs the
disagreement loudly and keeps the plugin's. Two game servers pointed at one sidecar by a copied
config is the mistake this catches, and it is silent in every other design.
Reading the token back
rust-link-sidecar --print-config
Resolves the configuration exactly as a normal start would — writing the file and generating the token if they are missing — and prints it as JSON on stdout, including the token in clear text. That is the supported way for an installer to obtain it; the alternative is scraping a log.
Endpoints
Everything except /health requires the token, as Authorization: Bearer <t>, X-Api-Key: <t>,
or ?token=<t> (the last so browser WebSocket clients, which cannot set handshake headers, can
still authenticate). Every response carries X-RustLink-Version.
| Route | Backed by | Notes |
|---|---|---|
GET /health |
— | Unauthenticated, so monitoring can reach it |
GET /server |
store | The last server.hello. 204 when the game has never connected |
GET /boards |
store | Every board, keyed by kind. 200 with an empty object when there are none |
GET /events?kind=&wipe=&limit= |
store | Newest first; limit clamped to 1–1000. For a human |
GET /feed?since=&limit= |
store | Oldest first, from a cursor. For a consumer that must not miss a row. Omitting since asks where the end is |
GET /status |
plugin (RPC) | A live round trip. 503 with no plugin, 504 on no reply |
GET /ws |
broadcast | The live feed. Sends ws.hello on connect |
The split is the point: the store-backed reads answer while the game is off, which is what lets the
website render a server list during a wipe or a restart. /status is the one route that fails when
the game is down, because "what is it doing right now" has no stale answer worth giving.
/server answers 204, not 200 with a null, when the game has never connected. "We have never
heard from this server" and "this server reports nothing" are different answers, and a client that
cannot tell them apart renders a server that does not exist.
The protocol
Newline-delimited JSON over TCP, both directions. Outbound frames (plugin → sidecar) carry kind;
inbound frames (sidecar → plugin) carry cmd. Lines are capped at 1 MiB; an over-long line is
discarded and the connection stays up.
This process files a frame by its type, and never by its kind — which is what keeps it a
dumb forwarder while the catalogue grows. Ten new event kinds are no change here at all.
type |
Kept | Broadcast | Example |
|---|---|---|---|
event |
appended to the history | yes | player.death |
snapshot |
replaces the board of that kind | yes | players.online |
reply |
no | no | server.status, routed by reqId |
control |
no | yes | pong, link.down |
A frame with no type this build knows is dropped and counted, never guessed at, and the count
is on /health as untyped_frames. The game link has no version handshake, so a plugin and a
sidecar on different protocol versions show up there and nowhere else.
| Frame | Direction | Purpose |
|---|---|---|
server.hello |
plugin → sidecar | A board. Sent on every connect, not once at server start — this process restarts independently of the game. Carries serverId, bootId and wipeId |
players.online |
plugin → sidecar | The other board: who is connected, re-sent on connect and every 60s |
ping / pong |
sidecar → plugin → sidecar | The heartbeat, every 30s. A pong is never persisted; it only moves last_event |
server.status |
sidecar → plugin → sidecar | The one request/reply verb, correlated by reqId |
| the read path | plugin → sidecar | Presence, deaths, chat, tallies, moderation, the wipe — the catalogue is PROTOCOL.md §8.4 |
bootId is how a game restart is told apart from a sidecar reconnect — the distinction the event
system's reconcile hangs off later. wipeId is how a wipe splits the history instead of ending
it; the plugin derives it, and every frame carries it.
History is bounded, boards are not. [store].retain_days (default 14) prunes events hourly; a
board is one row per kind holding what is true now, and pruning it would make a server the site has
rendered for weeks look like one that has never connected. The permanent record is the website's.
The RPC reply timeout (rpc::REPLY_TIMEOUT, 10s) is a ceiling every later command budget sits
under. Core classifies a budget overrun as retryable unconditionally, because it cannot ask the
game while the action is still awaiting a socket. An action whose budgetMs exceeds this can never
report retry: false.
Checks
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test