// ── The near end of a call whose far end is a Rust server ───────────────── // // Every other file in this module reads its own tables. This one is different in // kind: it is the only place that leaves the process. // // **The website process never opens a connection to a game server** // (MODULE_API.md §2.7). It opens one to a `rust-link` sidecar, which owns the // socket to the game, persists what the game says before forwarding it, and // answers reads from that store. `test/noGameConnection.test.js` enforces the // decidable half of that rule and names this file as the one that may reach the // network: // // const MAY_OPEN_SOCKETS = new Set(['sidecarClient.js']) // // ── One client per configured server ────────────────────────────────────── // // R8: the bridge is one game server to one sidecar. So this file takes the // server row as an argument rather than holding a single configured endpoint — // six servers is six base URLs and six tokens, and core never learns there is // more than one. // // ── TIMEOUT_MS is not a tuning knob. It is half of a rule. ──────────────── // // An event action declares `budgetMs`, and core's dispatcher enforces it: when // the budget expires it stops waiting and classifies the failure as **retry**, // unconditionally, without asking the action — it cannot ask, the action is still // awaiting a socket. So if core's deadline is shorter than this one, an action // never gets to classify its own failure and `{ ok: false, retry: false }` is // unreachable code. `budgetMs` must EXCEED this. // // It is also bounded from the other side: the sidecar's own RPC reply timeout is // ten seconds, so a value below that would give up while the sidecar is still // legitimately waiting for the game. The ordering is // `sidecar RPC timeout < TIMEOUT_MS < budgetMs`, and every one of the three // is written down somewhere the other two can be checked against. // // ── This file never throws ──────────────────────────────────────────────── // // Every call answers `{ ok, status, data }`. A module that let a socket failure // escape into a controller would hand an exception to a page whose whole job is // to render while the game is off. The public site degrades; it does not 500. const core = require('./core') const log = core.logger('sidecar') /** How long this client waits before giving up on a sidecar. See the header. */ const TIMEOUT_MS = 12000 /** * The wire version this module speaks. Declared in FOUR places that must agree: * here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge * plugin, and `protocol` in its `overlay.toml`. * * **5 — configuration from the site.** Protocol 2 was the read path, 3 the * first message the WEBSITE originates (`link.confirm`), 4 the first that * writes to the game's permission store; 5 is the first that writes to the game * HOST'S FILESYSTEM — a plugin's settings, and a reload watched closely enough * to be undone. The bump lands here in the same change as the emitters, * because the sidecar refuses a client declaring a different version with a * `409`: a module left on 2 would stop being able to read the server board it * has been reading all along. A constant that lags the deployment is not a safe * default; it is an outage with a version number on it. * * It is sent on every request as `X-RustLink-Version`, which turns a mismatched * deployment into a `409` naming both numbers instead of a parse failure three * layers further in. */ const PROTOCOL_VERSION = 5 /** What a caller gets back. Shaped once so every call site reads the same. */ function reply(ok, status, data = null) { return { ok, status, data } } /** * Normalises a configured base URL into something `new URL(path, base)` will not * surprise anybody with. * * A trailing slash on the base and a leading slash on the path is the classic * way to lose a path segment, and an operator pasting a URL out of a terminal * supplies the trailing slash about half the time. */ function joinUrl(baseUrl, path) { return `${String(baseUrl).replace(/\/+$/, '')}${path}` } /** * One request to one sidecar. * * @param {object} server a `rust_servers` row, token already decrypted * @param {string} server.baseUrl * @param {string|null} server.token * @param {string} path e.g. `/server` * @param {object} [options] * @param {string} [options.method] * @param {object} [options.body] */ async function request(server, path, { method = 'GET', body = null } = {}) { if (!server || !server.baseUrl) return reply(false, 'not-configured') // A sidecar with auth off does not exist — it generates and persists a token on // first start — so a missing token here is a half-finished admin form, not a // sidecar to try unauthenticated. Saying so beats a 401 the operator has to // interpret. if (!server.token) return reply(false, 'no-token') const controller = new AbortController() const timer = setTimeout(() => controller.abort(), TIMEOUT_MS) try { const res = await fetch(joinUrl(server.baseUrl, path), { method, signal: controller.signal, headers: { Authorization: `Bearer ${server.token}`, 'X-RustLink-Version': String(PROTOCOL_VERSION), ...(body ? { 'Content-Type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), }) // A protocol mismatch is a deployment fault and deserves its own status, not // to be folded into "the sidecar said no". The operator's fix is an upgrade // of one component, and the message has to be able to say which. if (res.status === 409) { const detail = await safeJson(res) log.warn('protocol mismatch', { server: server.id, module: PROTOCOL_VERSION, sidecar: detail && detail.sidecar_protocol, }) return reply(false, 'protocol-mismatch', detail) } if (res.status === 401) return reply(false, 'unauthorized') // 204 is an ANSWER, not an absence of one: the sidecar is up and reports that // the game has never connected. Collapsing it into a failure would make a // freshly installed server indistinguishable from an unreachable one. if (res.status === 204) return reply(true, 'empty', null) if (!res.ok) return reply(false, `http-${res.status}`) return reply(true, 'ok', await safeJson(res)) } catch (err) { // `AbortError` is this client's own deadline firing, and it is worth telling // apart from a refused connection: one means the sidecar is slow or the game // is not answering, the other means nothing is listening. const status = err && err.name === 'AbortError' ? 'timeout' : 'transport-error' log.warn('sidecar request failed', { server: server.id, path, status, error: err.message }) return reply(false, status) } finally { clearTimeout(timer) } } async function safeJson(res) { try { return await res.json() } catch { // A sidecar that answered 200 with something that is not JSON is a sidecar // this module cannot use, but it is not a reason to throw at a page. return null } } /** Liveness, the protocol version, and whether the plugin is connected. Unauthenticated at the far end, but sent authenticated anyway so one code path covers every call. */ const health = (server) => request(server, '/health') /** The last `server.hello` the sidecar stored. Answers while the game is off. */ const serverBoard = (server) => request(server, '/server') /** A live round trip through the sidecar to the game. Fails when the game is down, by design. */ const liveStatus = (server) => request(server, '/status') /** Every board at once: what is true now, before following what happens next. */ const boards = (server) => request(server, '/boards') /** * The ingest cursor: events after `since`, oldest first. * * **`since` is required here, unlike on the wire.** The sidecar treats an omitted * cursor as "tell me where the end is", which is a genuinely useful question and * a catastrophic default for an ingest loop that would silently store nothing * and advance past everything. So the question is asked explicitly, by name, and * a caller cannot get it by forgetting an argument. */ const feed = (server, since, limit = 200) => request(server, `/feed?since=${encodeURIComponent(since)}&limit=${encodeURIComponent(limit)}`) /** Where the sidecar's history currently ends. What a new server's cursor starts at. */ const feedTail = (server) => request(server, '/feed') /** * Redeem a one-time link code against one server (protocol 3). * * **The only call in this file that is not a GET**, and the only one that asks * the game a question rather than reading what it already said. The sidecar * forwards the code to the plugin, which holds the pending codes in memory, and * hands back what it answers. * * **A refused code comes back `{ ok: true }`.** `link.ok` and `link.error` are * both answers — the sidecar reserves its own failures for the transport (503 * when the game is down, 504 when it is up and silent) — and the caller has to * tell "that code is wrong" from "the game never replied" to say the right thing * to a player. So the discrimination happens on `data.kind`, not on `ok`. * * A code is spent on the plugin's FIRST lookup whether or not it turns out to be * expired, so this must never be called speculatively for its answer alone. */ const confirmLink = (server, code) => request(server, '/link/confirm', { method: 'POST', body: { code } }) /** * What one server's loaded plugins have registered, and the groups its store * holds (protocol 4). * * The option source behind the authoring form (D33). It is a live read through * to the game rather than anything cached at the sidecar, because the answer * changes when an operator loads a plugin — and the whole reason to ask is to * offer names that will actually resolve. It therefore fails when the game is * down, like `/status` and unlike every store-backed read. */ const permCatalogue = (server) => request(server, '/permissions/catalogue') /** * Push the whole permission set this site authors for one server (protocol 4). * * **The second call in this file that is not a GET, and the first that changes * the game.** The body is the desired set plus what the site has withdrawn; the * plugin diffs it against the live store, applies the difference and answers * with a report — counts, the names it could not resolve, the memberships that * are waiting on a first connection, and every holder the site did not author. * * **A refusal comes back `{ ok: true }`**, like a refused link code: `perm.error` * and `perm.report` are both answers, and the sidecar keeps its own status codes * for the transport. The caller discriminates on `data.kind`. */ const permSync = (server, set) => request(server, '/permissions/sync', { method: 'POST', body: set }) /** * Every settings file on one game host, and every plugin loaded to reload one * (protocol 5, R18). * * A description of the tree, never its contents: paths, sizes, which files are * too large to edit, and the plugin each one probably belongs to. **Probably** * is the operative word and it survives all the way to the form — a folder name * is convention, not contract, and reloading the wrong plugin would report * success while the edited one never re-read anything. * * Live, like `/status`: what is on a host's disk has no stale answer worth * giving, and a cached one would be an edit an operator made over SSH that the * website then overwrote. */ const configFiles = (server) => request(server, '/config/files') /** One settings file as text, with the version a write has to present back. */ const configFile = (server, path) => request(server, `/config/file?path=${encodeURIComponent(path)}`) /** * Replace a set of settings files and reload what owns them (protocol 5). * * **The only call in this module that writes to a filesystem**, and the only one * whose reply routinely takes seconds: the plugin holds it open across the * reload it is watching, and across the rollback if that reload never arrives. * * Like every other write on this bridge, a refusal comes back `{ ok: true }` * with the answer in `data.kind` — `config.report` or `config.error`. The * transport keeps its own codes, and a `504` here is the one case worth reading * carefully: the plugin writes a whole set or restores a whole set, never half * of either, so the state is knowable by re-reading rather than by guessing. */ const configWrite = (server, body) => request(server, '/config/write', { method: 'POST', body }) module.exports = { TIMEOUT_MS, PROTOCOL_VERSION, request, health, serverBoard, liveStatus, boards, feed, feedTail, confirmLink, permCatalogue, permSync, configFiles, configFile, configWrite, joinUrl, }