module-rust, id 'rust', built from the Integration Kit's template. Phase 1's job
is the kit's own argument: get every seam working at once with almost nothing in
them, so that afterwards you break exactly one at a time.
What is here:
* /rust on all three tiers, because the loader holds module.json's mounts against
what is registered in BOTH directions -- so the declaration and the
registration land together or not at all. The player tier is honestly thin: it
answers the server list on the authenticated tier, delegating to the same model
the public tier uses so the two cannot drift while they are meant to be the
same. It is the address the app will call, registered now rather than moved
later.
* Two tables. rust_servers is configuration an operator writes; rust_server_state
is what a sidecar reported. Separate tables because they have different
writers, lifetimes and audiences -- and because purging observed state while
keeping the configuration is a thing an operator will want.
* Per-server sidecar tokens through ctx.secretBox, write-only in the API. The
admin list reports hasToken and never the credential, and an empty token on a
save leaves the stored one alone -- a form that posts its own blank field would
otherwise erase a credential every time somebody renamed a server.
* A real sidecar client. It never throws: every call answers {ok, status, data},
and the status is what tells a wrong URL from a wrong token from a mismatched
protocol -- all three present as 'the site says my server is offline' and each
has a different fix.
* The five guards, green: check:imports, check:swagger, check:externals, and both
suites.
What is deliberately NOT registered: the Team provider, triggers, audiences,
engagement seeds, notification streams, the four event catalogues, and the two
extension slots. Each arrives with the phase that has something real to put in
it, and a test asserts their absence so that removing it is deliberate. A
declared trigger nothing emits and a declared slot nothing fills are both
surfaces an operator can configure and then wait on, which is worse than an
absent one because the absence is visible.
Two corrections to the kit's template, both feedback for a later phase:
* registration.test.js read one page BY NAME to check declared slots are
rendered, so a module declaring none dies on ENOENT before reaching the loop
that would have been empty. It now scans every file under src/routes.
* test/_fakes.js supplied validator: {}. An admin router that builds validation
chains at file scope cannot be required with that, so the fake holds the real
express-validator -- for the same reason it holds a real express Router.
The kit was right about noGameConnection.test.js: its header predicts that a
module adding a sidecar client will see the check go red, names sidecarClient.js
as the file to allow, and says narrow it rather than delete it. That is exactly
what happened on the first run, and the fix was the one line the header names.
Installed into a real core and verified: the module reaches 'started', publishes
its capability, serves its chunk, and renders a server whose server.hello
originated in a live Rust server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
175 lines
7.2 KiB
JavaScript
175 lines
7.2 KiB
JavaScript
// ── 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 three places that must agree:
|
|
* here, `PROTOCOL_VERSION` in the sidecar, and `overlay.toml` in Rust-Plugins.
|
|
*
|
|
* 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 = 1
|
|
|
|
/** 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')
|
|
|
|
module.exports = {
|
|
TIMEOUT_MS,
|
|
PROTOCOL_VERSION,
|
|
request,
|
|
health,
|
|
serverBoard,
|
|
liveStatus,
|
|
joinUrl,
|
|
}
|