// ── 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 → your schema fragment → onBoot(ctx) → the listener binds // // So by the time `onBoot` runs your tables exist, core's settings are seeded, and // nothing is serving traffic yet. That last part is a guarantee you can rely on: // a module that must warm a cache before its first request gets to. // // **`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.** Your 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. // You then get NO `onShutdown` — you are part-way through a warm-up you never // finished, and being handed a half-built world to tear down is worse than not // closing cleanly. // // This is where a real module opens its sidecar connection. **The website process // never opens a connection to a game server** — that is §2.7, contract as of // MODULE_API 1.4.0, not advice. What you connect to here is your sidecar: a // service you write, which owns the socket to the game, persists what the game // says before forwarding it, and answers reads from that store. See the kit's // chapter 3 for why that shape and not a shorter one. const core = require('./core') const worldStatusDb = require('./model/worldStatus/worldStatus.db') const log = core.logger('boot') // Whatever a real module would keep open — a sidecar WebSocket, a poll timer — // is held here so `onShutdown` can close it. This template has one timer, purely // so that there is something for the shutdown hook to actually do. let refreshTimer = null const REFRESH_MS = 30 * 1000 /** * Ask the game (in a real module: your sidecar) how it is doing, and store it. * * Isolated from the hooks so it is the one place a failure is handled: an * unreachable game is expected, is not this module's fault, and must not become * an unhandled rejection in core's process. */ async function refresh() { try { // A real module calls its sidecar's REST API here. Two hardcoded values // stand in, so that the page renders and the seam is visible. await worldStatusDb.setStatus({ online: true, players: 0, worldName: 'Example World' }) } catch (err) { log.warn('could not refresh world status', { 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. * Close what you opened, flush what is buffered, and return. */ async function onShutdown() { if (refreshTimer) clearInterval(refreshTimer) refreshTimer = null log.info('shut down') } module.exports = { onBoot, onShutdown, refresh, REFRESH_MS }