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
147 lines
6.2 KiB
JavaScript
147 lines
6.2 KiB
JavaScript
// ── The lifecycle hooks ───────────────────────────────────────────────────
|
|
//
|
|
// `register()` may not touch the database (MODULE_API.md §2.2). This file is
|
|
// where everything it could not do goes.
|
|
//
|
|
// core schema → this module's schema fragment → onBoot(ctx) → the listener binds
|
|
//
|
|
// So by the time `onBoot` runs the tables exist, core's settings are seeded, and
|
|
// nothing is serving traffic yet.
|
|
//
|
|
// **`onBoot` has no timeout.** Shutdown races the process being killed; boot does
|
|
// not. A slow `onBoot` delays the listener, which is the promise above rather
|
|
// than a problem to be timed out.
|
|
//
|
|
// **If `onBoot` throws, the module is `startup_failed` and the site still comes
|
|
// up.** Its routes stay mounted but answer 503, because a module that failed to
|
|
// warm up serving half-initialised data is worse than one that says it is down.
|
|
// There is then NO `onShutdown` — being handed a half-built world to tear down is
|
|
// worse than not closing cleanly. Which is why the poll below catches everything:
|
|
// a sidecar that is not there yet is the ordinary state of a fresh install, and
|
|
// letting that fail the boot would make installing the module before installing
|
|
// the bridge impossible.
|
|
//
|
|
// ── Polling, in phase 1 ───────────────────────────────────────────────────
|
|
//
|
|
// This is a poll, and the live feed it will become is a later phase's work. The
|
|
// poll is not a placeholder for it: a sidecar's store-backed reads are exactly
|
|
// what answers while a game server is off, and the module will keep reading them
|
|
// on an interval to notice a server that went away without saying anything.
|
|
// What the feed adds is latency, not coverage.
|
|
|
|
const core = require('./core')
|
|
|
|
const db = require('./model/servers/servers.db')
|
|
const servers = require('./model/servers/servers.model')
|
|
const sidecar = require('./sidecarClient')
|
|
|
|
const log = core.logger('boot')
|
|
|
|
let refreshTimer = null
|
|
|
|
const REFRESH_MS = 30 * 1000
|
|
|
|
/**
|
|
* Ask every configured sidecar how its server is doing, and store what it said.
|
|
*
|
|
* **Every server is polled independently and one failure never stops the
|
|
* others.** `Promise.allSettled`, not `Promise.all`: six servers behind one
|
|
* unreachable host would otherwise mean the whole fleet stops updating because
|
|
* one of them does, and the site would report five healthy servers offline.
|
|
*/
|
|
async function refresh() {
|
|
let rows
|
|
try {
|
|
rows = await servers.listForPolling()
|
|
} catch (err) {
|
|
log.warn('could not read the server list', { error: err.message })
|
|
return
|
|
}
|
|
|
|
await Promise.allSettled(rows.map(refreshOne))
|
|
}
|
|
|
|
async function refreshOne(server) {
|
|
try {
|
|
const board = await sidecar.serverBoard(server)
|
|
|
|
// Three outcomes, and collapsing any two of them loses something an operator
|
|
// needs:
|
|
//
|
|
// • the sidecar answered with a frame → the server has connected at least once
|
|
// • the sidecar answered 204 (`empty`) → the sidecar is up and the game never connected
|
|
// • the sidecar did not answer → the bridge is unreachable
|
|
//
|
|
// The middle case is the one that is easy to lose. It is a fresh install
|
|
// whose plugin is not loaded yet, and reporting it as unreachable sends the
|
|
// operator to look at the network instead of at the game server.
|
|
if (!board.ok) {
|
|
await db.putState({ serverId: server.id, reachable: false, online: false })
|
|
return
|
|
}
|
|
|
|
const frame = board.data
|
|
if (!frame) {
|
|
await db.putState({ serverId: server.id, reachable: true, online: false })
|
|
return
|
|
}
|
|
|
|
await db.putState({
|
|
serverId: server.id,
|
|
reachable: true,
|
|
// A stored `server.hello` means the game connected; whether it is connected
|
|
// NOW is a different question, and `/health` is what answers it. The board
|
|
// alone cannot say, which is why `online` is not simply `true` here — it is
|
|
// decided by freshness in the model, from `updated_at`.
|
|
online: true,
|
|
players: Number(frame.players) || 0,
|
|
maxPlayers: Number(frame.maxPlayers) || 0,
|
|
hostname: frame.hostname || null,
|
|
level: frame.level || null,
|
|
seed: frame.seed === undefined ? null : Number(frame.seed),
|
|
worldSize: frame.worldSize === undefined ? null : Number(frame.worldSize),
|
|
bootId: frame.bootId || null,
|
|
saveCreatedAt: frame.saveCreatedAt || null,
|
|
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
|
|
raw: frame,
|
|
})
|
|
} catch (err) {
|
|
// A failure here is one server's, and it must not reach `Promise.allSettled`
|
|
// as a rejection that hides which one. Log with the id and carry on.
|
|
log.warn('could not refresh a server', { server: server.id, error: err.message })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs once, after the schema and before the listener binds.
|
|
*
|
|
* Receives the same frozen `ctx` `register()` was given — not a second object
|
|
* built to look like it — so a module that only needs core at boot time can skip
|
|
* `core.init` entirely and use this argument.
|
|
*/
|
|
async function onBoot() {
|
|
await refresh()
|
|
refreshTimer = setInterval(refresh, REFRESH_MS)
|
|
// Node keeps the process alive for a pending timer. Core's own intervals are
|
|
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
|
|
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
|
|
if (typeof refreshTimer.unref === 'function') refreshTimer.unref()
|
|
log.info('booted', { refreshMs: REFRESH_MS })
|
|
}
|
|
|
|
/**
|
|
* Runs on SIGINT/SIGTERM, before core closes anything of its own.
|
|
*
|
|
* The database pool, the push dispatcher and the SSE fan-out are all still open,
|
|
* because flushing through them is the only thing this hook is for. There is a
|
|
* five-second budget per module, after which the hook is abandoned — abandoned
|
|
* rather than cancelled, since nothing can stop a promise that is still running.
|
|
*/
|
|
async function onShutdown() {
|
|
if (refreshTimer) clearInterval(refreshTimer)
|
|
refreshTimer = null
|
|
log.info('shut down')
|
|
}
|
|
|
|
module.exports = { onBoot, onShutdown, refresh, refreshOne, REFRESH_MS }
|