feat(template): a module that builds and loads — Phase 5 slice 1
The kit's `template/`: a complete, minimal Runic Gateway module a reader copies,
renames, and runs before reading a chapter. Slice 0 landed the workflow that runs
it; this is the tree that workflow was written against, so the `template` job
arms itself with no edit to the guard.
Installed into a real core it adds one public page at `/examplegame/status`, a nav
row pointing at it, one API route described in an OpenAPI fragment core merges,
one table created by an idempotent schema fragment and dropped by a purge file,
and both lifecycle hooks. That is deliberately less than a real module does; what
it is complete about is the shape — every seam used once, with the reasoning next
to it.
Four decisions, settled with the org lead:
1. **Public tier only, plus the lifecycle hooks.** §2.11.1 d1's "one public route",
plus enough to show the whole vertical seam once. Admin and player tiers become
worked examples quoted from module-uo in chapter 2 rather than two thirds of a
tree the reader deletes on day one.
2. **The release workflow ships as a file, in BOTH flavours** — `.gitea/` and
`.github/`. Neither runs where it sits (a workflow is only read from a
repository root) and each arms itself when the reader's copy is its own repo.
Packaging is the part of a module that cannot be guessed at, and the kit's
audience is outside this org, so assuming Gitea would have been assuming our
own deployment. Core installs from a URL and does not care where the release
lives — only that the host is on the operator's `MODULE_SOURCE_HOSTS`.
3. **A rename checklist that CI verifies**, not a rename script. `template/README.md`
carries the table; `scripts/checkRenameSites.js` holds it against the tree in
both directions — an unlisted file that still carries the placeholder fails, and
so does a listed file that no longer does. The second half is the one usually
left out and the more valuable: a row that has stopped matching reads as
instructions to edit something that is not there. Same rule core's identifier
check follows about its own exemptions. It has its own ten-test suite, run by
CI as `node --test`, because a check that has never been shown to fail is a
check nobody knows the state of.
4. **A neutral invented game.** One deviation from the literal answer, forced by
decision 3: the id is `examplegame`, not `example`. The checklist check is a
text search, and `example` occurs in ordinary English ("for example") all over
prose that is not a rename site — a placeholder that cannot occur by accident is
what makes the check answerable instead of a source of false alarms someone
learns to ignore.
**The pin moves to the 1.4.0 bump** (website `edge` 1b692bf), which is what
`template/module.json` declares as `coreApi`. Slice 0 pinned its parent, before
1.4.0 existed, so `checkCoreApi.js` arms for the first time here — it asserts
EQUALITY, and its failing on the next contract bump is the system working.
Also in CI: the client tests now run AFTER the build (two of them read the built
chunk and skip without one — run first, the job reports green while asking nothing
about the artifact that ships), and `check:swagger` verifies the committed
fragment is current.
## The finding: an UPDATE that changes nothing does not touch ON UPDATE CURRENT_TIMESTAMP
Every suite passed, both guards passed, the chunk built, the module loaded into a
real core and the page rendered correctly. Two hours later the same page said the
world was offline, and it was wrong.
`updated_at` was declared `ON UPDATE CURRENT_TIMESTAMP`, and MariaDB fires that
only when an UPDATE actually CHANGES a value. The boot refresh writes the same
numbers every thirty seconds — which is exactly what a quiet game looks like — so
the timestamp froze at the first write, the row crossed the freshness window, and
the model correctly reported a stale row as offline. Verified against the live
database: two hours of refreshes, `updated_at` still the boot timestamp.
No test in this repo could see it. The model takes its clock as an argument, and
nothing in a suite runs the same UPDATE twice against a real database. It is only
visible as a page that was right when you looked at it and wrong an hour later.
The writer now sets `updated_at = CURRENT_TIMESTAMP` explicitly and the column
drops the clause that was not doing what it looked like it was doing; both carry
the reasoning. Re-verified end to end: the timestamp advances every interval and
the API reports fresh.
Falling out of the fix, the schema fragment gained the rule the reader hits next:
**changing a table is an ALTER, never an edit to its CREATE** — `CREATE TABLE IF
NOT EXISTS` does nothing when the table exists, so an edited column definition
takes effect on a fresh install and on no existing one, which is the worst
possible split because your development database is usually the fresh one.
## Verified
- 29 server tests, 18 client tests, 10 kit-script tests; `check:imports`,
`check:externals`, `check:swagger` and `checkCoreApi` all green, run in CI's own
order from a clean `npm ci`.
- Browser smoke (MODULE_API.md §7.7) against a real core built from the pinned
ref: module `started`, published on `/api/v1/public/modules`, chunk served
`no-cache` with the right MIME from the entry's directory while `module.json`
and the server source 404, script tag injected after core's bundle, the page
rendering inside core's own chrome, the nav row interleaved into the public
header between Wiki and About, SPA navigation into it from another page, the
module's path and schema and tag merged into `/api/docs.json`, and
`[examplegame] registered against core API 1.4.0` in the console with no CSP
report and no React error.
Refs: MODULE_SYSTEM.md §2.11.1 (slice 1), MODULE_API.md §2.x, §3.x, §5.1, §7.7.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
92
template/server/boot.js
Normal file
92
template/server/boot.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// ── 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 }
|
||||
99
template/server/core.js
Normal file
99
template/server/core.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// ── Everything this module reaches in core ─────────────────────────────────
|
||||
//
|
||||
// `ctx` arrives once, as an argument to `register()` (MODULE_API.md §2.3). The
|
||||
// code beneath it — models, controllers, utilities — is ordinary Node that
|
||||
// requires its dependencies at file scope, the way any Node file does. This file
|
||||
// is what lets both of those be true at the same time.
|
||||
//
|
||||
// **Every export is a lazy accessor, not a stored reference, and that is the
|
||||
// whole point.** A model writes
|
||||
//
|
||||
// const { query } = require('../../core')
|
||||
//
|
||||
// at require time, which is before `register()` has been called and therefore
|
||||
// before any `ctx` exists. Handing out `ctx.db.query` at that moment would hand
|
||||
// out `undefined`, permanently, and the failure would surface much later as a
|
||||
// TypeError inside a model with no clue pointing here. So each member resolves
|
||||
// `ctx` when it is CALLED. Require order stops mattering for everything except
|
||||
// `core.init()` itself, which `index.js` runs first.
|
||||
//
|
||||
// The same rule in the other direction: **never destructure off `ctx` at init
|
||||
// time.** Core is free to hand over a getter — `ctx.site.baseUrl` is one — and a
|
||||
// value captured once is a value that cannot change.
|
||||
//
|
||||
// If `ctx` is missing every accessor throws the same message. The only ways to
|
||||
// reach one before `register()` are a require cycle or a test that forgot to call
|
||||
// `init`, and both want naming rather than `undefined`.
|
||||
//
|
||||
// ── This file is a NARROWING, on purpose ───────────────────────────────────
|
||||
//
|
||||
// §2.3 lists everything core hands over. What is re-exported below is only what
|
||||
// this module actually uses, which is the discipline worth copying: the file is
|
||||
// then an honest statement of what your module depends on, and a test double for
|
||||
// it (see `test/_fakes.js`) is a complete one. Add a member here when you reach
|
||||
// for it — not in advance.
|
||||
|
||||
let ctx = null
|
||||
|
||||
function need() {
|
||||
if (!ctx) {
|
||||
throw new Error('examplegame: core accessed before register() — see server/core.js')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Called once, first thing in `register()`. */
|
||||
function init(value) {
|
||||
ctx = value
|
||||
}
|
||||
|
||||
/** Test seam. Nothing in the module calls this; there is no de-registration. */
|
||||
function _reset() {
|
||||
ctx = null
|
||||
}
|
||||
|
||||
// A logger that can be taken at require time and used after `register()`.
|
||||
//
|
||||
// A file writes `const log = require('../core').logger('world')` at file scope,
|
||||
// so the object returned has to exist before `ctx` does. It is a façade whose
|
||||
// four methods each resolve the real logger when called. Core namespaces the
|
||||
// output with your module id, so these come out as `[examplegame:world]`.
|
||||
function logger(namespace) {
|
||||
const call = (level) => (message, meta) => need().log(namespace)[level](message, meta)
|
||||
return { error: call('error'), warn: call('warn'), info: call('info'), debug: call('debug') }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
_reset,
|
||||
logger,
|
||||
|
||||
// Shared server dependencies. Core owns exactly one express, as it owns
|
||||
// exactly one React on the client, and for the same reason: a second copy in
|
||||
// the process is a second Router prototype and a second set of `instanceof`
|
||||
// checks. A module could not resolve these for itself even if it were allowed
|
||||
// to — it lives outside core's `server/` (§7.2).
|
||||
get express() { return need().express },
|
||||
get validator() { return need().validator },
|
||||
|
||||
// The database. `query(sql, params)` is what every `*.db.js` file uses; raw
|
||||
// parameterised SQL, no ORM, the same as core. `pool` is there for the rare
|
||||
// case that needs a connection it can hold (a streamed import, say).
|
||||
query: (...args) => need().db.query(...args),
|
||||
get pool() { return need().db.pool },
|
||||
|
||||
// Read-only access to who is asking. Minting a session is core's job; a module
|
||||
// that needs an identity needs to *read* one.
|
||||
auth: { getUserFromRequest: (...args) => need().auth.getUserFromRequest(...args) },
|
||||
|
||||
// Core's middleware, taken as values rather than wrapped: express stores the
|
||||
// function reference at mount time, so a wrapper is what would end up in the
|
||||
// stack. Routers are built inside `register()`, so `ctx` is set by then.
|
||||
get middleware() { return need().middleware },
|
||||
|
||||
// Deployment facts. `moduleRoot` is the absolute path to `modules/<id>/` — the
|
||||
// only correct way to find a file you shipped, because the working directory is
|
||||
// core's and the module's location is the loader's business.
|
||||
get moduleRoot() { return need().paths.moduleRoot },
|
||||
get moduleId() { return need().moduleId },
|
||||
}
|
||||
24
template/server/db/purge.sql
Normal file
24
template/server/db/purge.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- ── The teardown ──────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Destructive, and run ONLY by an explicit admin purge (MODULE_API.md §2.6).
|
||||
-- Nothing on the boot path executes this file, and uninstalling your module does
|
||||
-- not either: removing an operator's data is a second decision they have to make
|
||||
-- on purpose, offered inside the uninstall flow and confirmed separately.
|
||||
--
|
||||
-- It exists because `schema.sql` does. A module that can create tables and
|
||||
-- cannot drop them leaves an operator with orphaned data and no supported way to
|
||||
-- remove it — so core refuses to load a module that declares one without the
|
||||
-- other.
|
||||
--
|
||||
-- **Drop in the reverse of creation order.** With one table it does not matter;
|
||||
-- with a parent and its children it does, because dropping a parent first fails
|
||||
-- on the constraint and a purge that fails halfway is worse than one that never
|
||||
-- ran — it leaves exactly the orphaned data this file exists to remove.
|
||||
-- `IF EXISTS` on every line, so a partially-installed module still tears down.
|
||||
--
|
||||
-- **What does NOT belong here: rows you wrote into core's tables.** Notification
|
||||
-- subscriptions, announce-job legs and settings rows live in core's schema, and
|
||||
-- a module does not DELETE from core's tables. Core prunes what it knows you
|
||||
-- registered, because it is the one that knows which registrant owned what.
|
||||
|
||||
DROP TABLE IF EXISTS examplegame_world_status;
|
||||
70
template/server/db/schema.sql
Normal file
70
template/server/db/schema.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- ── The schema fragment ───────────────────────────────────────────────────
|
||||
--
|
||||
-- Core replays this file on EVERY boot, statement by statement, immediately
|
||||
-- after its own schema.sql and before it seeds defaults (MODULE_API.md §2.6).
|
||||
--
|
||||
-- **There is no migration runner anywhere in this project, and that is a
|
||||
-- decision rather than an omission.** Core's own schema is one idempotent file
|
||||
-- replayed the same way. So a module's schema is not a sequence of changes to
|
||||
-- apply once — it is a statement of what the tables should look like, written so
|
||||
-- that running it against a database that already matches does nothing.
|
||||
--
|
||||
-- Which means: every CREATE TABLE carries IF NOT EXISTS and every ALTER carries
|
||||
-- IF NOT EXISTS. A statement that succeeds once and fails afterwards presents as
|
||||
-- a module that worked until the first restart.
|
||||
--
|
||||
-- **And it means CHANGING a table is an ALTER, never an edit to its CREATE.**
|
||||
-- `CREATE TABLE IF NOT EXISTS` does nothing at all when the table is already
|
||||
-- there, so an edited column definition takes effect on a fresh install and on no
|
||||
-- existing one — the worst possible split, because your development database is
|
||||
-- usually the fresh one. Add the column with
|
||||
-- `ALTER TABLE … ADD COLUMN IF NOT EXISTS`, below the CREATE, and leave the
|
||||
-- CREATE describing what a new install gets.
|
||||
--
|
||||
-- ── What core checks, and when ────────────────────────────────────────────
|
||||
--
|
||||
-- Core validates this file at LOAD time, before your module mounts anything —
|
||||
-- so a rule broken here costs you the mount entirely rather than leaving you
|
||||
-- with half-created tables and routes that 503. What is left for replay time is
|
||||
-- the class only the database can answer: an unknown column type, a bad foreign
|
||||
-- key. Those are post-mount and do answer 503.
|
||||
--
|
||||
-- • **Leading verbs are an allowlist: CREATE, ALTER, INSERT, UPDATE.** Not a
|
||||
-- DROP denylist. This file replays every boot, so a TRUNCATE or a DELETE
|
||||
-- would empty a table on every restart.
|
||||
-- • **Every table you create must be prefixed with your module id** —
|
||||
-- `examplegame_` here. Nothing else in the database is yours to create.
|
||||
-- • **No table core declares, and none another module has claimed.**
|
||||
--
|
||||
-- A foreign key INTO a core table is allowed, and works because core's schema is
|
||||
-- already in place when this runs. The reverse is not, and could not be: it
|
||||
-- would make core's schema depend on your module being installed.
|
||||
--
|
||||
-- Teardown is `purge.sql`, which no boot ever runs. See it.
|
||||
|
||||
|
||||
-- ── World status ──────────────────────────────────────────────────────────
|
||||
-- One row, id 1, holding the last thing the game server said about itself.
|
||||
--
|
||||
-- A singleton row rather than a settings key because it is *observed state* and
|
||||
-- not configuration: it is written by whatever ingests from your sidecar, and an
|
||||
-- operator never edits it. In a real module the writer is the sidecar ingest;
|
||||
-- here `boot.js` writes it once so the page has something to render.
|
||||
-- `updated_at` carries no `ON UPDATE CURRENT_TIMESTAMP`, deliberately. That
|
||||
-- clause fires only when an UPDATE actually CHANGES a value, so a writer sending
|
||||
-- the same numbers back — which is what a quiet game looks like — leaves the
|
||||
-- timestamp frozen at the first write, and the row then goes stale while nothing
|
||||
-- is wrong. The writer sets the column explicitly instead; see
|
||||
-- `model/worldStatus/worldStatus.db.js`.
|
||||
CREATE TABLE IF NOT EXISTS examplegame_world_status (
|
||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||
players INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
world_name VARCHAR(120) NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Seed the singleton. `INSERT IGNORE` rather than a plain INSERT: this runs
|
||||
-- again on every boot, and the second run must be a no-op rather than a
|
||||
-- duplicate-key error that fails the whole replay.
|
||||
INSERT IGNORE INTO examplegame_world_status (id, online, players) VALUES (1, 0, 0);
|
||||
98
template/server/index.js
Normal file
98
template/server/index.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// ── The server entry point ─────────────────────────────────────────────────
|
||||
//
|
||||
// Core requires this file once, synchronously, while its own `app.js` is still
|
||||
// being required, and calls the exported function with `(ctx, api)`. That is the
|
||||
// entire server-side handshake: everything this module can reach arrives on
|
||||
// `ctx`, and everything it can offer is registered through `api`.
|
||||
//
|
||||
// Normative: MODULE_API.md §2.2 (the entry point) and §2.4 (what you register).
|
||||
//
|
||||
// ── Three rules, and each one has a failure behind it ──────────────────────
|
||||
//
|
||||
// 1. **No `await`, and no database.** Core requires `app.js` in two build tools
|
||||
// with the connection pool pointed at a dead port — the route-manifest
|
||||
// generator and the OpenAPI generator both do it — so a module that queried
|
||||
// at registration time would hang both. Anything that needs a live database
|
||||
// goes in `onBoot`, which runs after the schema is up.
|
||||
//
|
||||
// 2. **Never resolve what core owns.** Your module lives at
|
||||
// `<website>/modules/<id>/`, which is outside core's `server/`, so Node's
|
||||
// resolver never reaches core's `node_modules` and `require('express')` from
|
||||
// here simply fails. express, express-validator, the database, the logger and
|
||||
// the middleware all arrive on `ctx` (§2.3) and are re-exported by `./core`.
|
||||
// This is not a style rule: a second express in the process would be a second
|
||||
// `Router` prototype, exactly as a second React would be a second renderer.
|
||||
//
|
||||
// 3. **Never reach into core's tree.** No relative path may escape this module's
|
||||
// root. `scripts/checkImports.js` enforces it (§5.1) and CI runs it.
|
||||
//
|
||||
// ── Why the requires are INSIDE the function ───────────────────────────────
|
||||
//
|
||||
// Every file below reaches core through `./core`, whose members resolve `ctx`
|
||||
// when they are CALLED. But a router writes `const express = core.express` at its
|
||||
// own file scope, and that runs the moment the file is required. So
|
||||
// `core.init(ctx)` has to happen before the first `require` of anything under
|
||||
// `router/`. Hoisting these to the top of the file breaks the module with an
|
||||
// error about a missing `ctx`, thrown from a file that never mentions one.
|
||||
//
|
||||
// Node caches modules, so requiring here costs nothing after the first call.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
/**
|
||||
* @param {object} ctx what core hands the module (MODULE_API.md §2.3), frozen
|
||||
* @param {object} api what the module registers (§2.4)
|
||||
*/
|
||||
module.exports = function register(ctx, api) {
|
||||
core.init(ctx)
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const worldRouter = require('./router/public/world.router')
|
||||
const boot = require('./boot')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = core.logger()
|
||||
|
||||
// One prefix, on one tier. The keys here must match `module.json`'s `mounts`
|
||||
// exactly — the loader compares the two and rejects a mismatch in either
|
||||
// direction, so a route you forgot to declare and a prefix you declared and
|
||||
// never registered both fail loudly at boot instead of quietly at runtime.
|
||||
//
|
||||
// This mounts at `/api/v1/public/world`. The router sits INSIDE the tier
|
||||
// router, so it structurally cannot reach above its prefix, and the tier's own
|
||||
// gate is already applied: `public` is behind nothing by design, `admin` sits
|
||||
// behind `noindex, isLoggedIn, requireRole(...)` and `player` behind
|
||||
// `noindex, requireAuth`. You add per-route gates on top; you never
|
||||
// re-implement the tier gate.
|
||||
//
|
||||
// **Prefixes share one namespace with core's own, and `/world` was chosen to
|
||||
// stay out of it.** Core answers `/api/v1/public/` + contact, modules, pages,
|
||||
// posts, settings, status, version and wiki. The loader rejects a collision at
|
||||
// registration time — but four of those eight are mounted at the tier root
|
||||
// rather than under a prefix of their own, and the loader's probe cannot see
|
||||
// them. `/status` would have been the obvious name for this module's route and
|
||||
// is exactly the one that would have gone wrong. Check the list before you
|
||||
// choose (§2.4, and MODULE_SYSTEM.md §2.7's own note about the probe).
|
||||
api.registerRoutes({
|
||||
public: { '/world': worldRouter },
|
||||
})
|
||||
|
||||
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
|
||||
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
|
||||
// that must not serve traffic until it has warmed a cache gets that for free.
|
||||
// It has no timeout, deliberately: a slow boot delays the listener, which is
|
||||
// the guarantee rather than a problem to be timed out.
|
||||
//
|
||||
// `onShutdown` runs while core's database pool and push dispatcher are still
|
||||
// open, because flushing through them is the only thing it is for. It gets a
|
||||
// five-second budget and is abandoned past it.
|
||||
//
|
||||
// Both are optional. A module with neither still reaches `started`.
|
||||
api.onBoot(boot.onBoot)
|
||||
api.onShutdown(boot.onShutdown)
|
||||
|
||||
log.info('registered', {
|
||||
version: require('../module.json').version,
|
||||
routes: 'public:/world',
|
||||
})
|
||||
}
|
||||
53
template/server/model/worldStatus/worldStatus.db.js
Normal file
53
template/server/model/worldStatus/worldStatus.db.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// ── SQL, and nothing else ─────────────────────────────────────────────────
|
||||
//
|
||||
// Core's own backend is layered `router → controller → model → db`, with models
|
||||
// arriving in pairs: a `.db.js` holding the SQL and a `.model.js` holding the
|
||||
// logic that calls it. Your module is under no obligation to copy that — the
|
||||
// contract says nothing about how you organise yourself — but the split earns
|
||||
// its keep here for the same reason it does in core: the file with the queries
|
||||
// in it has no branching to test, and the file with the branching in it has no
|
||||
// database to stand up.
|
||||
//
|
||||
// Raw parameterised SQL through `core.query`, no ORM. Placeholders always; a
|
||||
// value interpolated into a query string is the one mistake in this file that
|
||||
// nothing downstream can catch.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const TABLE = 'examplegame_world_status'
|
||||
|
||||
/** The singleton status row, or `null` if the schema replay has not run yet. */
|
||||
async function getStatus() {
|
||||
const rows = await core.query(
|
||||
`SELECT online, players, world_name AS worldName, updated_at AS updatedAt
|
||||
FROM ${TABLE}
|
||||
WHERE id = 1`,
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the singleton. Called by whatever ingests from your sidecar.
|
||||
*
|
||||
* **`updated_at` is set explicitly, and it has to be.** MariaDB's
|
||||
* `ON UPDATE CURRENT_TIMESTAMP` fires only when an UPDATE actually CHANGES a
|
||||
* value — an update that writes the same numbers back is a no-op and leaves the
|
||||
* timestamp where it was. A game sitting quietly at the same player count writes
|
||||
* exactly that update, so the column would freeze at the first write, the row
|
||||
* would cross the freshness window, and the page would report the world offline
|
||||
* while the game was up and reporting normally.
|
||||
*
|
||||
* That is invisible to every test — the model takes its timestamps as arguments,
|
||||
* and nothing in a suite runs an UPDATE twice against a real database. It shows
|
||||
* up as a page that was right when you looked at it and wrong an hour later.
|
||||
*/
|
||||
async function setStatus({ online, players, worldName }) {
|
||||
await core.query(
|
||||
`UPDATE ${TABLE}
|
||||
SET online = ?, players = ?, world_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1`,
|
||||
[online ? 1 : 0, players, worldName],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { getStatus, setStatus, TABLE }
|
||||
50
template/server/model/worldStatus/worldStatus.model.js
Normal file
50
template/server/model/worldStatus/worldStatus.model.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// ── The logic half ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Shapes what the database returned into what a client should see. It is a
|
||||
// separate file from the SQL so that it is testable without a database, and the
|
||||
// suite next door tests it that way.
|
||||
//
|
||||
// The one decision worth pointing at: **a module answers when the game is
|
||||
// unreachable rather than failing.** The website is the internet-facing process
|
||||
// and your game is not; the game being down, or the sidecar being mid-restart,
|
||||
// is an ordinary Tuesday and not an error condition for the site. A page that
|
||||
// renders "offline, last seen 20 minutes ago" is right; a page that 500s because
|
||||
// a socket is closed is a module that has made the site's availability depend on
|
||||
// the game's.
|
||||
|
||||
const db = require('./worldStatus.db')
|
||||
|
||||
// Past this, the last thing the game said stops being news and starts being
|
||||
// history. Presentation, so the number lives with the code that shapes the
|
||||
// response rather than in the client.
|
||||
const STALE_AFTER_MS = 5 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The public view of the world's status.
|
||||
*
|
||||
* Never throws for an absent or stale row: both are answers, not failures.
|
||||
*/
|
||||
async function getPublicStatus(now = Date.now()) {
|
||||
const row = await db.getStatus()
|
||||
if (!row) {
|
||||
// No row at all means the schema fragment has not been replayed — a fresh
|
||||
// install whose first boot has not finished. Report it as offline rather
|
||||
// than as an error; the next boot fixes it.
|
||||
return { online: false, players: 0, worldName: null, updatedAt: null, stale: true }
|
||||
}
|
||||
|
||||
const updatedAt = row.updatedAt ? new Date(row.updatedAt) : null
|
||||
const stale = !updatedAt || now - updatedAt.getTime() > STALE_AFTER_MS
|
||||
|
||||
return {
|
||||
// A stale row cannot claim the world is up. The row says what was true when
|
||||
// it was written, and nothing has written it since.
|
||||
online: Boolean(row.online) && !stale,
|
||||
players: stale ? 0 : Number(row.players) || 0,
|
||||
worldName: row.worldName || null,
|
||||
updatedAt: updatedAt ? updatedAt.toISOString() : null,
|
||||
stale,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getPublicStatus, STALE_AFTER_MS }
|
||||
1056
template/server/package-lock.json
generated
Normal file
1056
template/server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
template/server/package.json
Normal file
23
template/server/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "examplegame-module-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Server half of the Example Game module — routers, models and the schema fragment core loads at boot",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"check:imports": "node scripts/checkImports.js",
|
||||
"swagger": "node scripts/swaggerFragment.js",
|
||||
"check:swagger": "node scripts/swaggerFragment.js --check"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"//dependencies": "There are none, and that is the shape to aim for: everything the shipped half needs arrives on ctx (MODULE_API.md 2.3) - express, express-validator, the database, the logger and the middleware are all core-owned and handed over. If you do add one, remember an operator never builds: your release CI runs npm ci --omit=dev and packs server/node_modules into the tarball, so every dependency is weight in the artifact and a package the operator now runs. scripts/checkImports.js reads this file to decide what the shipped half may resolve.",
|
||||
"devDependencies": {
|
||||
"express": "^4.19.2",
|
||||
"swagger-autogen": "^2.23.7"
|
||||
},
|
||||
"//devDependencies": "Test-only and build-only, never shipped. test/_fakes.js builds a REAL express Router, because a fake Router would only ever test the fake. swagger-autogen generates the OpenAPI fragment; pin it to the same major core uses, so the fragment and the spec it merges into come out of one tool."
|
||||
}
|
||||
27
template/server/router/public/world.controller.js
Normal file
27
template/server/router/public/world.controller.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// ── Public · World — the handlers ─────────────────────────────────────────
|
||||
//
|
||||
// Thin on purpose: read the request, call a model, answer. Everything worth
|
||||
// testing is in the model, which needs no express and no database to test.
|
||||
//
|
||||
// **A handler must not throw past express.** Core mounts your router inside its
|
||||
// own tier router, so an unhandled rejection here reaches core's error handler
|
||||
// and answers 500 — which is survivable, but it means an operator sees core
|
||||
// blamed for a fault in your module. Catch, log through `core.logger` (so the
|
||||
// line carries your module id), and answer something honest.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const worldStatus = require('../../model/worldStatus/worldStatus.model')
|
||||
|
||||
const log = core.logger('world')
|
||||
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
res.json(await worldStatus.getPublicStatus())
|
||||
} catch (err) {
|
||||
log.error('failed to read world status', { error: err.message })
|
||||
res.status(500).json({ error: 'Failed to read world status' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getStatus }
|
||||
50
template/server/router/public/world.router.js
Normal file
50
template/server/router/public/world.router.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// ── Public · World ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Mounted at `/api/v1/public/world` by `index.js`. One express Router, built
|
||||
// from CORE's express (`core.express`) — never from a `require('express')` of
|
||||
// your own, which would not resolve from here anyway (MODULE_API.md §7.2).
|
||||
//
|
||||
// **The tier's gate is already on.** This router sits inside core's public tier,
|
||||
// which is behind nothing by design — the public API is public. Per-route
|
||||
// middleware goes on top, and `siteMode` below is the one worth understanding:
|
||||
// it is what makes a page respect the operator's maintenance switch. Core applies
|
||||
// it to its own content routes (`/posts`, `/wiki`) and deliberately does not
|
||||
// apply it to its status endpoints, because status is exactly what an operator
|
||||
// wants visible *during* maintenance. Which of those two your route is depends on
|
||||
// what it serves, and it is your decision to make.
|
||||
//
|
||||
// ── About the `#swagger` comments ─────────────────────────────────────────
|
||||
//
|
||||
// They are not documentation *of* the code, they are the source the OpenAPI
|
||||
// fragment is generated from — `npm run swagger` parses this file (§2.8). Two
|
||||
// rules that cost this project real time:
|
||||
//
|
||||
// • swagger-autogen reads these as JavaScript literals it evaluates. It
|
||||
// re-quotes `"` and a backtick to `'` first, so either one inside a
|
||||
// single-quoted description ends the string early — and when it cannot parse
|
||||
// an annotation it drops that annotation, prints an error, and then reports
|
||||
// success. Use a typographic apostrophe (’) in prose. `swaggerFragment.js`
|
||||
// captures those errors and makes them fatal, which is the only reason you
|
||||
// will find out.
|
||||
// • A `\'` escape is valid JavaScript and wrong here: the annotation is never
|
||||
// evaluated as JS by the reader, so Swagger UI renders the backslash.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const express = core.express
|
||||
const world = require('./world.controller')
|
||||
const { siteMode } = core.middleware
|
||||
|
||||
const worldRouter = express.Router()
|
||||
|
||||
worldRouter.get(
|
||||
'/status',
|
||||
// #swagger.tags = ['Public · Example Game']
|
||||
// #swagger.summary = 'The game world’s current status'
|
||||
// #swagger.description = 'What the game server last reported: whether it is up, how many players are on, and when that was. Answers with `online: false` and `stale: true` rather than failing when the game or its sidecar is unreachable — the site’s availability does not depend on the game’s.'
|
||||
/* #swagger.responses[200] = { description: 'The world’s status', content: { "application/json": { schema: { $ref: "#/components/schemas/ExamplegameWorldStatus" } } } } */
|
||||
siteMode,
|
||||
world.getStatus,
|
||||
)
|
||||
|
||||
module.exports = worldRouter
|
||||
190
template/server/scripts/checkImports.js
Normal file
190
template/server/scripts/checkImports.js
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §5.1 — zero internal imports ───────────────────────────────────────────
|
||||
//
|
||||
// The acceptance test for the whole module contract. A module that reaches into
|
||||
// core's tree still works — right up until core moves a file — and the boundary
|
||||
// this workstream exists to build is worth exactly as much as this check is.
|
||||
//
|
||||
// MODULE_API.md §5.1 sketches it as a grep for `../../`. That is the shape of
|
||||
// the violation but not the rule, and the difference matters in both directions:
|
||||
// a grep says nothing about `require('../../../../etc/passwd')` from a deeply
|
||||
// nested file (which it catches by accident) and false-alarms on a legitimate
|
||||
// `require('../module.json')` from `server/` (which it catches wrongly). So this
|
||||
// RESOLVES each specifier against the file that wrote it and asks whether the
|
||||
// result is still inside the module root — the actual rule, stated once.
|
||||
//
|
||||
// Bare specifiers are checked too, and against a stricter list than "is it
|
||||
// installed": core hands the module express, express-validator, the database and
|
||||
// the logger on `ctx` precisely so the module never resolves them, and Node's
|
||||
// resolver cannot reach core's `node_modules` from here anyway. A bare
|
||||
// `require` that is not a Node builtin is therefore a module that will fail to
|
||||
// load on a real install, with a message about a missing package rather than
|
||||
// about the rule it broke.
|
||||
//
|
||||
// **That second check applies to SHIPPED code only.** `test/` and `scripts/`
|
||||
// never run inside core's process — the fakes in `test/_fakes.js` build a real
|
||||
// `express` router precisely so the module's routers are exercised for real —
|
||||
// so they may use devDependencies. The containment check applies everywhere,
|
||||
// because a test that reaches into core's tree is a test that passes on this
|
||||
// machine and nowhere else.
|
||||
//
|
||||
// Run over the SERVER half. The client half's equivalents are its Vite build,
|
||||
// which fails if a shared dependency resolves into node_modules, and
|
||||
// client/scripts/checkExternals.js, which asks the built chunk whether any bare
|
||||
// specifier survived.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
// Node's own answer, not a list reconstructed from `builtinModules`. That list
|
||||
// omits `test` on Node 20 and includes it on Node 24, so a suite that requires
|
||||
// `node:test` passed locally and failed in CI on the very first run — reported
|
||||
// as the module boundary being broken, which it was not. `isBuiltin` is the
|
||||
// authoritative check and handles the `node:` prefix itself.
|
||||
const { isBuiltin } = require('module')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
|
||||
// Packages the SHIPPED half may resolve for itself: this package's declared
|
||||
// `dependencies`, and nothing else. Read from package.json rather than listed
|
||||
// here, so adding one is a visible, reviewable edit to the manifest that also
|
||||
// changes what CI installs and what the release tarball carries.
|
||||
//
|
||||
// Adding a dependency is a real decision. §2.7 permits a module its own, and the
|
||||
// release tarball carries `server/node_modules` because an operator never builds
|
||||
// — so every entry is weight in the artifact and a package the operator's
|
||||
// deployment now runs. Anything core already owns must come from `ctx` instead:
|
||||
// a second express is a second Router prototype, a second express-rate-limit is
|
||||
// a second store, and a limit enforced by two independent counters is not the
|
||||
// limit either of them states.
|
||||
|
||||
const SKIP_DIRS = new Set(['node_modules', 'coverage', '.git'])
|
||||
|
||||
// Directories whose contents never run inside core's process, and may therefore
|
||||
// resolve this package's devDependencies.
|
||||
const NOT_SHIPPED = [path.join(SERVER_ROOT, 'test'), path.join(SERVER_ROOT, 'scripts')]
|
||||
const isShipped = (file) => !NOT_SHIPPED.some((d) => file.startsWith(d + path.sep))
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(SERVER_ROOT, 'package.json'), 'utf8'))
|
||||
const dependencies = new Set(Object.keys(manifest.dependencies || {}))
|
||||
const devDependencies = new Set(Object.keys(manifest.devDependencies || {}))
|
||||
|
||||
// `require('x')`, `from 'x'`, `import('x')`. Deliberately textual: parsing would
|
||||
// need a dependency, and a specifier this pattern misses is a specifier written
|
||||
// to be missed, which review catches and a stricter regexp would not.
|
||||
const SPECIFIER = /(?:require\(|from\s+|import\()\s*['"]([^'"]+)['"]/g
|
||||
|
||||
/**
|
||||
* Blank out comments and template literals before scanning.
|
||||
*
|
||||
* Not a nicety — without it this file fails on ITSELF, because the comments
|
||||
* above name `require('../../../../etc/passwd')` as an example of what to
|
||||
* catch, and index.js explains in prose why it must never `require('express')`.
|
||||
* A boundary check that cannot survive being described is a check people stop
|
||||
* writing comments around.
|
||||
*
|
||||
* A character walk rather than a regexp, because the two get in each other's
|
||||
* way: `'https://x'` contains a line-comment opener inside a string, and
|
||||
* `// don't` contains a quote inside a comment. Tracking the state is shorter
|
||||
* than the regexp that would almost handle it. Content is replaced with spaces
|
||||
* rather than removed so nothing else has to care.
|
||||
*/
|
||||
function stripCommentsAndTemplates(src) {
|
||||
let out = ''
|
||||
let i = 0
|
||||
const keep = (n) => { out += src.slice(i, i + n); i += n }
|
||||
const blank = (end) => { out += src.slice(i, end).replace(/[^\n]/g, ' '); i = end }
|
||||
while (i < src.length) {
|
||||
const two = src.slice(i, i + 2)
|
||||
if (two === '//') {
|
||||
const nl = src.indexOf('\n', i)
|
||||
blank(nl === -1 ? src.length : nl)
|
||||
} else if (two === '/*') {
|
||||
const end = src.indexOf('*/', i + 2)
|
||||
blank(end === -1 ? src.length : end + 2)
|
||||
} else if (src[i] === '"' || src[i] === "'") {
|
||||
// Strings are KEPT — they are where the specifiers live.
|
||||
const quote = src[i]
|
||||
keep(1)
|
||||
while (i < src.length && src[i] !== quote) keep(src[i] === '\\' ? 2 : 1)
|
||||
keep(1)
|
||||
} else if (src[i] === '`') {
|
||||
// Template literals are blanked: nothing may `require` a template, and a
|
||||
// template holding SQL or HTML is a rich source of false positives.
|
||||
i += 1
|
||||
out += ' '
|
||||
while (i < src.length && src[i] !== '`') {
|
||||
if (src[i] === '\\') { out += ' '; i += 2 } else { out += src[i] === '\n' ? '\n' : ' '; i += 1 }
|
||||
}
|
||||
i += 1
|
||||
out += ' '
|
||||
} else {
|
||||
keep(1)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function* walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!SKIP_DIRS.has(entry.name)) yield* walk(path.join(dir, entry.name))
|
||||
} else if (/\.(js|mjs|cjs)$/.test(entry.name)) {
|
||||
yield path.join(dir, entry.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every boundary violation under `root`, resolved against `moduleRoot`.
|
||||
*
|
||||
* Exported so `test/checkImports.test.js` can point it at fixtures. A check that
|
||||
* has never been shown to fail is a check nobody knows the state of — and this
|
||||
* one guards the acceptance criterion for the whole contract.
|
||||
*/
|
||||
function scan(root, moduleRoot = MODULE_ROOT, { shipped = isShipped, deps = dependencies, dev = devDependencies } = {}) {
|
||||
const violations = []
|
||||
for (const file of walk(root)) {
|
||||
const source = stripCommentsAndTemplates(fs.readFileSync(file, 'utf8'))
|
||||
for (const [, specifier] of source.matchAll(SPECIFIER)) {
|
||||
if (specifier.startsWith('.')) {
|
||||
const resolved = path.resolve(path.dirname(file), specifier)
|
||||
if (resolved !== moduleRoot && !resolved.startsWith(moduleRoot + path.sep)) {
|
||||
violations.push({ file, specifier, why: 'escapes the module root' })
|
||||
}
|
||||
} else if (path.isAbsolute(specifier)) {
|
||||
violations.push({ file, specifier, why: 'absolute path' })
|
||||
} else {
|
||||
const pkg = specifier.startsWith('@')
|
||||
? specifier.split('/').slice(0, 2).join('/')
|
||||
: specifier.split('/')[0]
|
||||
const allowed = deps.has(pkg) || (!shipped(file) && dev.has(pkg))
|
||||
// The `node:` prefix can only ever name a builtin, so it never reaches
|
||||
// node_modules and is safe whatever this Node version enumerates.
|
||||
const builtin = isBuiltin(specifier) || specifier.startsWith('node:')
|
||||
if (!builtin && !allowed) {
|
||||
violations.push({ file, specifier, why: 'undeclared bare specifier — should this come from ctx?' })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
module.exports = { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT }
|
||||
|
||||
// Required by a test, or run as the check? Only the second one exits.
|
||||
if (require.main !== module) return
|
||||
|
||||
const violations = scan(SERVER_ROOT)
|
||||
|
||||
if (violations.length) {
|
||||
console.error(`\n${violations.length} import(s) break the module boundary (MODULE_API.md §5.1):\n`)
|
||||
for (const v of violations) {
|
||||
console.error(` ${path.relative(MODULE_ROOT, v.file)}\n "${v.specifier}" — ${v.why}`)
|
||||
}
|
||||
console.error('')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`OK — no import escapes the module root (${SERVER_ROOT}).`)
|
||||
255
template/server/scripts/swaggerFragment.js
Normal file
255
template/server/scripts/swaggerFragment.js
Normal file
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
// ── §2.8 — the OpenAPI fragment ───────────────────────────────────────────
|
||||
//
|
||||
// Generates (or checks) `swagger-fragment.json` in the bundle root: the paths,
|
||||
// tags and schemas describing every route this module registers. Core merges the
|
||||
// fragments of *started* modules over its own committed spec at request time and
|
||||
// serves the result at `/api/docs.json` (MODULE_API.md §6.1a).
|
||||
//
|
||||
// ── Why a module has to ship this at all ──────────────────────────────────
|
||||
//
|
||||
// Core's own spec generation is STATIC analysis — swagger-autogen parses core's
|
||||
// `app.js` as text and follows the literal `app.use(...)` chain. Your module
|
||||
// arrives on a volume after core was built, is required by a filesystem loop, and
|
||||
// mounts through `api.registerRoutes()`. There is no literal mount for a parser to
|
||||
// follow, and core does not have your sources anyway. So nothing core can run
|
||||
// will ever describe your routes.
|
||||
//
|
||||
// The failure mode is the dangerous one: swagger-autogen reports success and
|
||||
// emits a spec with the routes simply absent. It happened twice inside core
|
||||
// before anyone noticed, and once to the first module — 417 annotations that
|
||||
// generated nothing at all, for two phases, because nobody had built the
|
||||
// fragment. If you take one thing from this file, take that a green build is not
|
||||
// evidence that anything was described.
|
||||
//
|
||||
// ── Where the prefixes come from ──────────────────────────────────────────
|
||||
//
|
||||
// swagger-autogen is pointed at one router file at a time, so its paths come out
|
||||
// relative to that router (`/status`, not `/api/v1/public/world/status`) —
|
||||
// nothing in the file says where it hangs. §6.1a requires fully-qualified paths,
|
||||
// because core merges the fragment verbatim and never re-derives a prefix.
|
||||
//
|
||||
// So this script **runs your own `register()`** against a recording `api` and
|
||||
// reads the mounts back out. Every prefix is therefore the prefix that router is
|
||||
// actually registered under — the same call an operator's core will make, rather
|
||||
// than a table beside it that drifts the first time a mount moves. Which file a
|
||||
// recorded router object came from is answered by `require.cache`: the module
|
||||
// whose `exports` IS that router.
|
||||
//
|
||||
// The tier base paths are the one thing that cannot be derived here, because they
|
||||
// are core's and not yours. They are §2.4's normative table, quoted below.
|
||||
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const swaggerAutogen = require('swagger-autogen')({ openapi: '3.0.0' })
|
||||
|
||||
const { fakeCtx, fakeApi } = require('../test/_fakes')
|
||||
const doc = require('../swagger/doc')
|
||||
|
||||
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
|
||||
const SERVER_ROOT = path.join(MODULE_ROOT, 'server')
|
||||
const FRAGMENT = path.join(MODULE_ROOT, 'swagger-fragment.json')
|
||||
|
||||
// MODULE_API.md §2.4. A router registered under a tier sits inside that tier's
|
||||
// router in core, behind its gate; the base path is core's and fixed.
|
||||
const TIER_BASE = {
|
||||
public: '/api/v1/public',
|
||||
admin: '/api/v1/admin',
|
||||
player: '/api/v1/player',
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `register()` with a recording api and return `[{ file, prefix, what }]`.
|
||||
*
|
||||
* The ctx is the test fakes' — the same one the suite proves the module runs
|
||||
* against — because registration must not touch a database (§2.2), and this
|
||||
* script is exactly the kind of no-database caller that rule exists for.
|
||||
*/
|
||||
function mountedRouters() {
|
||||
const register = require('../index')
|
||||
const api = fakeApi()
|
||||
register(fakeCtx(), api)
|
||||
|
||||
const fileOf = (router) => {
|
||||
for (const mod of Object.values(require.cache)) {
|
||||
if (mod && mod.exports === router) return mod.filename
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mounts = []
|
||||
for (const [tier, byPrefix] of Object.entries(api.record.routes || {})) {
|
||||
const base = TIER_BASE[tier]
|
||||
if (!base) throw new Error(`swagger: registered under unknown tier "${tier}" — §2.4 has three`)
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
mounts.push({ router, prefix: base + prefix, what: `${tier}${prefix}` })
|
||||
}
|
||||
}
|
||||
|
||||
return mounts.map(({ router, prefix, what }) => {
|
||||
const file = fileOf(router)
|
||||
if (!file) {
|
||||
// A router built inline in index.js rather than required from its own
|
||||
// file. swagger-autogen needs a file to read, so there is nothing to
|
||||
// generate from — put the router in its own module.
|
||||
throw new Error(`swagger: cannot find the source file of the router for ${what}`)
|
||||
}
|
||||
return { file, prefix, what }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run swagger-autogen over one router file. Paths come out router-relative.
|
||||
*
|
||||
* **swagger-autogen reports a broken annotation and then succeeds anyway** — it
|
||||
* `console.error`s "Syntax error" or "out of structure", drops that one
|
||||
* annotation, and prints `Success` in green. So its diagnostics are captured here
|
||||
* and made fatal. Nothing else will tell you.
|
||||
*/
|
||||
async function fragmentFor(file) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-swagger-'))
|
||||
const out = path.join(dir, 'fragment.json')
|
||||
|
||||
const complaints = []
|
||||
const realError = console.error
|
||||
console.error = (...args) => {
|
||||
const line = args.map(String).join(' ')
|
||||
if (/syntax error|out of structure/i.test(line)) complaints.push(line.trim())
|
||||
else realError(...args)
|
||||
}
|
||||
try {
|
||||
// A DEEP COPY per call, and that is not defensive style. swagger-autogen
|
||||
// writes its result back into the object it was handed, so reusing one `doc`
|
||||
// across several routers re-wraps the previous pass's output every time. The
|
||||
// first module to hit this produced a 484 MB fragment from six routers.
|
||||
await swaggerAutogen(out, [path.relative(SERVER_ROOT, file).split(path.sep).join('/')], {
|
||||
...JSON.parse(JSON.stringify(doc)),
|
||||
info: { title: 'examplegame fragment', version: '0' },
|
||||
})
|
||||
} finally {
|
||||
console.error = realError
|
||||
}
|
||||
if (complaints.length > 0) {
|
||||
throw new Error(
|
||||
`swagger: ${path.relative(MODULE_ROOT, file)} has ${complaints.length} annotation(s) ` +
|
||||
`swagger-autogen could not parse — it drops them and reports success:\n ${complaints.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const fragment = JSON.parse(fs.readFileSync(out, 'utf8'))
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
return fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-root a router-relative fragment under the prefix it is mounted at.
|
||||
*
|
||||
* Express path params (`:id`) become OpenAPI's (`{id}`), and any param belonging
|
||||
* to the PREFIX is moved to the front of each operation's parameter list —
|
||||
* swagger-autogen orders parameters by where they appeared in the path it saw,
|
||||
* which was only the tail.
|
||||
*/
|
||||
function prefixPaths(fragment, prefix) {
|
||||
const oas = prefix.replace(/:([A-Za-z0-9_]+)/g, '{$1}').replace(/\/+$/, '')
|
||||
const outer = [...oas.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((m) => m[1])
|
||||
const paths = {}
|
||||
for (const [p, item] of Object.entries(fragment.paths || {})) {
|
||||
for (const operation of Object.values(item)) {
|
||||
const params = operation && operation.parameters
|
||||
if (!Array.isArray(params)) continue
|
||||
const rank = (q) => {
|
||||
const i = outer.indexOf(q && q.name)
|
||||
return i === -1 ? outer.length : i
|
||||
}
|
||||
operation.parameters = params
|
||||
.map((q, i) => ({ q, i }))
|
||||
.sort((a, b) => rank(a.q) - rank(b.q) || a.i - b.i)
|
||||
.map(({ q }) => q)
|
||||
}
|
||||
// `router.get('/')` under a prefix concatenates to a trailing slash, a URL no
|
||||
// client calls. Core's generator normalises the same way.
|
||||
paths[`${oas}${p}`.replace(/\/$/, '')] = item
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the whole fragment: every mounted router, re-rooted and merged.
|
||||
*
|
||||
* Only `paths`, `tags` and `components.schemas` — the three sections §6.1a lets a
|
||||
* fragment carry. `info`, `servers` and the security schemes belong to the merged
|
||||
* document, which is to say to core.
|
||||
*/
|
||||
async function build() {
|
||||
const spec = { paths: {}, tags: [], components: { schemas: {} } }
|
||||
let shared = false
|
||||
|
||||
for (const { file, prefix, what } of mountedRouters()) {
|
||||
const generated = await fragmentFor(file)
|
||||
// Tags and schemas are the same on every pass — each was handed the same
|
||||
// `doc` — so take them from whichever ran first. What lands in the fragment
|
||||
// has to be what swagger-autogen PRODUCED and not what it was given: those
|
||||
// two differ (see fragmentFor), and core merges this file verbatim into a
|
||||
// spec whose own schemas went through the same mill.
|
||||
if (!shared) {
|
||||
spec.tags = generated.tags || []
|
||||
spec.components.schemas = (generated.components || {}).schemas || {}
|
||||
shared = true
|
||||
}
|
||||
const paths = prefixPaths(generated, prefix)
|
||||
const count = Object.keys(paths).length
|
||||
if (count === 0) {
|
||||
// An empty result is precisely what the silent drop looks like, so it is a
|
||||
// hard failure rather than a router that happens to declare no routes.
|
||||
throw new Error(`swagger: ${what} (${path.relative(MODULE_ROOT, file)}) generated NO paths`)
|
||||
}
|
||||
for (const [p, item] of Object.entries(paths)) {
|
||||
if (spec.paths[p]) throw new Error(`swagger: two of this module's routers both document ${p}`)
|
||||
spec.paths[p] = item
|
||||
}
|
||||
process.stdout.write(` ${String(count).padStart(3)} path(s) ${prefix} ← ${what}\n`)
|
||||
}
|
||||
|
||||
// Sorted, because swagger-autogen emits router-traversal order: without this,
|
||||
// moving a route between files rewrites most of a committed artifact even when
|
||||
// the API is provably unchanged.
|
||||
spec.paths = Object.fromEntries(Object.entries(spec.paths).sort(([a], [b]) => (a < b ? -1 : 1)))
|
||||
return spec
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const check = process.argv.includes('--check')
|
||||
const spec = await build()
|
||||
const json = `${JSON.stringify(spec, null, 2)}\n`
|
||||
|
||||
if (!check) {
|
||||
fs.writeFileSync(FRAGMENT, json)
|
||||
process.stdout.write(`\nwrote ${path.relative(MODULE_ROOT, FRAGMENT)} — ${Object.keys(spec.paths).length} paths\n`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(FRAGMENT)) {
|
||||
process.stderr.write('\nswagger-fragment.json is missing. Run `npm run swagger`.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
if (fs.readFileSync(FRAGMENT, 'utf8') !== json) {
|
||||
process.stderr.write(
|
||||
'\nswagger-fragment.json is STALE — the routes or their annotations changed and it was not\n' +
|
||||
'regenerated. Run `npm run swagger` and commit the result. Core merges this file verbatim,\n' +
|
||||
'so a stale one documents a URL surface this module does not serve.\n',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(`\nswagger-fragment.json is current — ${Object.keys(spec.paths).length} paths\n`)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`${err.stack}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { mountedRouters, prefixPaths, build, TIER_BASE, FRAGMENT }
|
||||
56
template/server/swagger/doc.js
Normal file
56
template/server/swagger/doc.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// ── The OpenAPI fragment: the shared half ─────────────────────────────────
|
||||
//
|
||||
// The tags and component schemas your `#swagger.*` annotations refer to.
|
||||
// `scripts/swaggerFragment.js` feeds this to swagger-autogen; the per-endpoint
|
||||
// detail lives beside each route, exactly as it does in core.
|
||||
//
|
||||
// **Two rules about names, and both belong to the MERGED document rather than to
|
||||
// this file** (MODULE_API.md §6.1a). Core merges every started module's fragment
|
||||
// over its own spec and serves the result at `/api/docs.json`, and core wins any
|
||||
// key collision:
|
||||
//
|
||||
// • **Namespace what you DEFINE.** `ExamplegameWorldStatus`, not `WorldStatus`.
|
||||
// A second game's module describing the same idea under the same bare name
|
||||
// would silently clobber yours or be clobbered by it. The prefix is what
|
||||
// makes two modules able to coexist.
|
||||
// • **Reference what CORE defines by core's name.** `#/components/schemas/Error`
|
||||
// and `ValidationError` are core's; point at them and do not redefine them.
|
||||
// They resolve in the merged document, where core's definitions are. Shipping
|
||||
// your own copy is a collision core drops — which is the right outcome, and
|
||||
// an expensive way to learn it.
|
||||
//
|
||||
// A tag is how the docs UI groups operations. Name yours after your module so an
|
||||
// operator reading `/api/docs` can see which operations arrived with it.
|
||||
//
|
||||
// **swagger-autogen renders `components.schemas` from an EXAMPLE object, not from
|
||||
// raw OpenAPI.** `{ type: 'object' }` comes back as a meta-description of itself.
|
||||
// That is uniform across core's committed spec and is the house shape — match it,
|
||||
// do not fight it.
|
||||
|
||||
module.exports = {
|
||||
tags: [
|
||||
{
|
||||
name: 'Public · Example Game',
|
||||
description: 'Live world data, as last reported by the game server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
schemas: {
|
||||
ExamplegameWorldStatus: {
|
||||
type: 'object',
|
||||
description: 'The game world’s status (GET /public/world/status).',
|
||||
properties: {
|
||||
online: { type: 'boolean', example: true },
|
||||
players: { type: 'integer', example: 12 },
|
||||
worldName: { type: 'string', nullable: true, example: 'Example World' },
|
||||
updatedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
stale: {
|
||||
type: 'boolean',
|
||||
description: 'Has nothing reported in longer than the freshness window? A stale row is reported offline.',
|
||||
example: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
107
template/server/test/_fakes.js
Normal file
107
template/server/test/_fakes.js
Normal file
@@ -0,0 +1,107 @@
|
||||
// ── Test doubles for what core hands the module ───────────────────────────
|
||||
//
|
||||
// Your server half is testable WITHOUT core, and that is not a convenience — it
|
||||
// is the contract holding. Everything a module may touch arrives on `ctx`
|
||||
// (MODULE_API.md §2.3), so a `ctx` this file can build is a complete statement of
|
||||
// what your module depends on. **If a test ever needs something that is not here,
|
||||
// either your module reached past the boundary or §2.3 needs a new member.** Both
|
||||
// are worth stopping for.
|
||||
//
|
||||
// The fake mirrors §2.3 member for member — including the freezing, so a module
|
||||
// that assigns to `ctx.something` fails here the way it would in core.
|
||||
//
|
||||
// This file lives under `test/`, which `checkImports.js` treats as not-shipped —
|
||||
// which is why it may `require('express')` when the module's own routers may not.
|
||||
// It builds a REAL express Router on purpose: a fake Router would only ever test
|
||||
// the fake.
|
||||
|
||||
const express = require('express')
|
||||
|
||||
/** Records every call, so a test can assert what the module asked for. */
|
||||
function spy(returns) {
|
||||
const fn = (...args) => {
|
||||
fn.calls.push(args)
|
||||
return typeof returns === 'function' ? returns(...args) : returns
|
||||
}
|
||||
fn.calls = []
|
||||
return fn
|
||||
}
|
||||
|
||||
function fakeLog() {
|
||||
return { error: spy(), warn: spy(), info: spy(), debug: spy() }
|
||||
}
|
||||
|
||||
function fakeCtx(overrides = {}) {
|
||||
// `freeze: false` is a test seam for a suite that wants to adjust the ctx it
|
||||
// installed. Core always freezes; the unfrozen variant is never a claim about
|
||||
// what a module is handed in production.
|
||||
const { freeze = true, ...rest } = overrides
|
||||
const logs = []
|
||||
const ctx = {
|
||||
moduleId: 'examplegame',
|
||||
paths: { moduleRoot: require('path').resolve(__dirname, '..', '..') },
|
||||
express,
|
||||
validator: {},
|
||||
db: { query: spy(Promise.resolve([])), pool: {} },
|
||||
log: (namespace) => {
|
||||
const log = fakeLog()
|
||||
logs.push({ namespace, log })
|
||||
return log
|
||||
},
|
||||
auth: { getUserFromRequest: spy(null) },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
// The factory returns a pass-through rather than a real limiter: a test
|
||||
// that tripped a rate limit would be a test whose result depended on how
|
||||
// many times the suite had run.
|
||||
rateLimit: (options) => Object.assign((req, res, next) => next(), { options }),
|
||||
accountChangeLimiter: (req, res, next) => next(),
|
||||
},
|
||||
site: { baseUrl: 'http://localhost:5173' },
|
||||
...rest,
|
||||
}
|
||||
// Non-enumerable, and that is not tidiness. Core freezes every object value on
|
||||
// `ctx` one level deep, so an enumerable recorder hung off it would be frozen
|
||||
// by the loop below and every `log.info` would throw on push. Keeping it out of
|
||||
// the enumeration also makes the fake more faithful: a module iterating `ctx`
|
||||
// sees §2.3's members and nothing a test put there.
|
||||
Object.defineProperty(ctx, 'logs', { value: logs, enumerable: false })
|
||||
if (!freeze) return ctx
|
||||
for (const value of Object.values(ctx)) {
|
||||
if (value && typeof value === 'object') Object.freeze(value)
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration api, recording rather than mounting.
|
||||
*
|
||||
* Copies core's `once()` rule (§2.4: "calling twice is an error"), so a module
|
||||
* that registers the same thing twice fails in its own suite rather than first on
|
||||
* an operator's install.
|
||||
*/
|
||||
function fakeApi() {
|
||||
const record = { routes: null, extensions: [], streams: null, legs: [], hooks: {} }
|
||||
const called = new Set()
|
||||
const once = (name) => {
|
||||
if (called.has(name)) throw new Error(`${name}() called twice`)
|
||||
called.add(name)
|
||||
}
|
||||
const api = {
|
||||
registerRoutes(mounts) { once('registerRoutes'); record.routes = mounts },
|
||||
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
||||
registerAnnounceLeg(leg) { record.legs.push(leg) },
|
||||
registerPostHook(hook) { once('registerPostHook'); record.hooks.post = hook },
|
||||
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
|
||||
}
|
||||
api.record = record
|
||||
return api
|
||||
}
|
||||
|
||||
module.exports = { fakeCtx, fakeApi, spy }
|
||||
149
template/server/test/checkImports.test.js
Normal file
149
template/server/test/checkImports.test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// The boundary check, checked.
|
||||
//
|
||||
// `scripts/checkImports.js` is the acceptance test for the whole module contract
|
||||
// (MODULE_API.md §5.1), and a check that has never been shown to fail is a check
|
||||
// nobody knows the state of. These point it at fixtures that break each rule and
|
||||
// assert it says so — and at prose that merely *describes* breaking them, which
|
||||
// is what it got wrong the first time it was run.
|
||||
//
|
||||
// **Every fixture is a template literal, and that is load-bearing.** The scanner
|
||||
// reads the files in this directory too, so an ordinary quoted string holding
|
||||
// `require('../../x')` would make this file fail the very check it is testing.
|
||||
// Templates are blanked by the stripper for exactly this class of text: source
|
||||
// being composed as data is not source being imported.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { scan, stripCommentsAndTemplates, SERVER_ROOT, MODULE_ROOT } = require('../scripts/checkImports')
|
||||
|
||||
/** Write `files` into a throwaway module tree and scan it. */
|
||||
function scanFixture(files, { dev = new Set() } = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-tpl-'))
|
||||
const src = path.join(root, 'server')
|
||||
for (const [name, source] of Object.entries(files)) {
|
||||
const file = path.join(src, name)
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, source)
|
||||
}
|
||||
try {
|
||||
return scan(src, root, { shipped: (f) => !f.startsWith(path.join(src, 'test') + path.sep), dev })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('the real server half is clean', () => {
|
||||
assert.deepStrictEqual(scan(SERVER_ROOT, MODULE_ROOT), [])
|
||||
})
|
||||
|
||||
test('catches a relative path that escapes the module root', () => {
|
||||
const found = scanFixture({ 'a.js': `require('../../server/src/utils/db')` })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.strictEqual(found[0].why, 'escapes the module root')
|
||||
})
|
||||
|
||||
test('allows a relative path that stays inside it, however deep', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'deep/nested/a.js': `require('../../../module.json')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches an absolute path', () => {
|
||||
const found = scanFixture({ 'a.js': `require('/etc/passwd')` })
|
||||
assert.strictEqual(found[0].why, 'absolute path')
|
||||
})
|
||||
|
||||
test('catches a bare specifier in shipped code, even a devDependency', () => {
|
||||
// The rule that makes the boundary real: express arrives on ctx. A shipped
|
||||
// file requiring it would fail on a real install, because a module lives
|
||||
// outside core's server/ and never reaches core's node_modules.
|
||||
const found = scanFixture({ 'a.js': `const express = require('express')` }, { dev: new Set(['express']) })
|
||||
assert.strictEqual(found.length, 1)
|
||||
assert.match(found[0].why, /should this come from ctx/)
|
||||
})
|
||||
|
||||
test('allows a devDependency in test code, which never runs inside core', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'test/a.js': `const express = require('express')` }, { dev: new Set(['express']) }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node builtins anywhere, with or without the node: prefix', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('path'); require('node:fs'); import crypto from 'node:crypto'` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('allows node:test, which older Node versions omit from builtinModules', () => {
|
||||
// The first CI run failed on exactly this and on nothing else: `builtinModules`
|
||||
// omits `test` on Node 20 and includes it on Node 24, so every test file in
|
||||
// this suite was reported as breaking the module boundary. The check asks
|
||||
// Node (`isBuiltin`) rather than rebuilding the list, and treats the `node:`
|
||||
// prefix as sufficient on its own — a prefixed specifier can never resolve to
|
||||
// a package, whatever the running version enumerates.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': `require('node:test'); require('node:test/reporters')` }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('catches ESM and dynamic forms, not only require()', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`import db from '../../core/db.js'`, `const x = await import('../../core/other.js')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 2)
|
||||
})
|
||||
|
||||
test('ignores a violation that is only DESCRIBED in a comment', () => {
|
||||
// The first run of this check failed on its own documentation, and on
|
||||
// index.js's comment explaining why the module must never require('express').
|
||||
// Prose about the rule must not trip the rule.
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({
|
||||
'a.js': [
|
||||
`// Never write require("../../server/src/utils/db") - it escapes the module root.`,
|
||||
`/* Nor import express from "express": core hands it over on ctx. */`,
|
||||
`const path = require('path')`,
|
||||
].join('\n'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores a specifier-shaped string inside a template literal', () => {
|
||||
assert.deepStrictEqual(
|
||||
scanFixture({ 'a.js': ['const sql = ', '`SELECT 1 -- require("../../x")`'].join('') }),
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('a comment opener inside a string does not swallow the rest of the file', () => {
|
||||
// The reason this is a character walk and not a regexp: a URL in a string
|
||||
// contains `//`, and treating that as a comment would blank everything after
|
||||
// it — turning the check into one that silently passes.
|
||||
const found = scanFixture({
|
||||
'a.js': [`const url = 'https://example.com/x'`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1, 'the specifier after a URL string was missed')
|
||||
})
|
||||
|
||||
test('a quote inside a comment does not swallow the rest of the file', () => {
|
||||
const found = scanFixture({
|
||||
'a.js': [`// don't do this`, `require('../../escaped')`].join('\n'),
|
||||
})
|
||||
assert.strictEqual(found.length, 1)
|
||||
})
|
||||
|
||||
test('stripping preserves line numbers', () => {
|
||||
// Blanked rather than removed, so anything that later reports a line still
|
||||
// reports the right one.
|
||||
const src = ['/* a', 'b', 'c */', `require("x")`, ''].join('\n')
|
||||
assert.strictEqual(stripCommentsAndTemplates(src).split('\n').length, src.split('\n').length)
|
||||
})
|
||||
84
template/server/test/entry.test.js
Normal file
84
template/server/test/entry.test.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// ── The registration handshake ────────────────────────────────────────────
|
||||
//
|
||||
// The one suite every module should have, whatever else it does. Core validates
|
||||
// all of this at boot and refuses to mount a module that fails — so testing it
|
||||
// here is the difference between finding out in half a second and finding out on
|
||||
// an operator's install.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const { fakeCtx, fakeApi } = require('./_fakes')
|
||||
const manifest = require('../../module.json')
|
||||
|
||||
/** A fresh registration. `core.js` holds a module-level `ctx`, so reset it. */
|
||||
function register(ctx = fakeCtx()) {
|
||||
require('../core')._reset()
|
||||
const api = fakeApi()
|
||||
require('../index')(ctx, api)
|
||||
return { api, ctx }
|
||||
}
|
||||
|
||||
test('registers exactly the mounts module.json declares', () => {
|
||||
const { api } = register()
|
||||
|
||||
// Core compares these two and rejects a mismatch in EITHER direction: a prefix
|
||||
// declared and never registered is as fatal as a route registered and never
|
||||
// declared. Asserting it against the manifest rather than against a literal is
|
||||
// what keeps the test true after you add a prefix.
|
||||
assert.deepStrictEqual(
|
||||
Object.keys(api.record.routes).sort(),
|
||||
Object.keys(manifest.mounts).sort(),
|
||||
)
|
||||
for (const [tier, prefixes] of Object.entries(manifest.mounts)) {
|
||||
assert.deepStrictEqual(Object.keys(api.record.routes[tier]).sort(), [...prefixes].sort())
|
||||
}
|
||||
})
|
||||
|
||||
test('every registered mount is a real express router', () => {
|
||||
const { api } = register()
|
||||
for (const byPrefix of Object.values(api.record.routes)) {
|
||||
for (const [prefix, router] of Object.entries(byPrefix)) {
|
||||
assert.strictEqual(typeof router, 'function', `${prefix} is not a router`)
|
||||
assert.ok(router.stack, `${prefix} has no middleware stack`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('prefixes are one segment, lowercase, no parameters', () => {
|
||||
// §2.4's rule, restated where a typo is cheap to find. Core enforces it, and a
|
||||
// module that fails it does not mount at all.
|
||||
for (const prefixes of Object.values(manifest.mounts)) {
|
||||
for (const prefix of prefixes) {
|
||||
assert.match(prefix, /^\/[a-z0-9][a-z0-9-]*$/, `illegal mount prefix ${prefix}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('registration touches no database and awaits nothing', () => {
|
||||
const ctx = fakeCtx()
|
||||
register(ctx)
|
||||
|
||||
// §2.2's first rule. Core requires `app.js` with the pool pointed at a dead
|
||||
// port in two build tools, so a query here would hang both — and the symptom
|
||||
// is a build that never finishes rather than an error naming this module.
|
||||
assert.deepStrictEqual(ctx.db.query.calls, [])
|
||||
})
|
||||
|
||||
test('registers both lifecycle hooks', () => {
|
||||
const { api } = register()
|
||||
assert.strictEqual(typeof api.record.hooks.onBoot, 'function')
|
||||
assert.strictEqual(typeof api.record.hooks.onShutdown, 'function')
|
||||
})
|
||||
|
||||
test('the manifest declares what the loader requires', () => {
|
||||
assert.match(manifest.id, /^[a-z][a-z0-9-]{1,31}$/)
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+/)
|
||||
assert.ok(manifest.coreApi, 'coreApi is required — it is the version check')
|
||||
// Declaring a schema without a purge is refused: a module that can create
|
||||
// tables and cannot drop them leaves an operator with orphaned data.
|
||||
if (manifest.schema) assert.ok(manifest.purge, 'a schema fragment requires a purge file')
|
||||
// The chunk must be in a SUBDIRECTORY — the directory it sits in is what core
|
||||
// serves, so an entry in the module root would publish the whole module.
|
||||
if (manifest.client) assert.ok(manifest.client.entry.includes('/'), 'client.entry must be in a subdirectory')
|
||||
})
|
||||
116
template/server/test/schema.test.js
Normal file
116
template/server/test/schema.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// ── The schema fragment, checked against §2.6's rules ─────────────────────
|
||||
//
|
||||
// Core validates the fragment at LOAD time and refuses to mount a module that
|
||||
// breaks a rule — with no tables created and no routes served. That is the right
|
||||
// behaviour and a slow way to find a typo, so the same rules are checked here.
|
||||
//
|
||||
// **This is also the suite that catches a half-finished rename.** Change the id
|
||||
// in `module.json` and forget a table name, and the prefix assertion below fails
|
||||
// immediately rather than at an operator's first boot.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const manifest = require('../../module.json')
|
||||
|
||||
const read = (rel) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8')
|
||||
|
||||
/**
|
||||
* Split a SQL file into statements the way core does.
|
||||
*
|
||||
* Core's own splitter is shared code (`utils/sqlStatements.js`) used by both the
|
||||
* loader and the schema replay — this is a small stand-in for a test, and it is
|
||||
* deliberately simple because the fragment it reads is deliberately simple. If
|
||||
* your schema grows a stored procedure or a string containing a semicolon, stop
|
||||
* trusting this and read the fragment a different way.
|
||||
*/
|
||||
function statements(sql) {
|
||||
return sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const schema = statements(read(manifest.schema))
|
||||
const purge = statements(read(manifest.purge))
|
||||
|
||||
// The allowlist core enforces. Note it is an ALLOWLIST and not a `DROP` denylist:
|
||||
// this file replays on every boot, so TRUNCATE or DELETE would empty a table on
|
||||
// every restart — which no denylist naming only DROP would have caught.
|
||||
const ALLOWED_VERBS = ['CREATE', 'ALTER', 'INSERT', 'UPDATE']
|
||||
|
||||
test('every statement starts with an allowed verb', () => {
|
||||
for (const statement of schema) {
|
||||
const verb = statement.split(/\s+/)[0].toUpperCase()
|
||||
assert.ok(ALLOWED_VERBS.includes(verb), `"${verb}" is not one of ${ALLOWED_VERBS.join(', ')}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('every table is prefixed with the module id', () => {
|
||||
for (const statement of schema) {
|
||||
const match = /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(statement)
|
||||
if (!match) continue
|
||||
assert.ok(
|
||||
match[1].startsWith(`${manifest.id}_`),
|
||||
`table "${match[1]}" is not prefixed "${manifest.id}_" — core will refuse to load this module`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('the fragment is idempotent — it replays on every boot', () => {
|
||||
for (const statement of schema) {
|
||||
if (/^CREATE\s+TABLE/i.test(statement)) {
|
||||
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'CREATE TABLE without IF NOT EXISTS')
|
||||
}
|
||||
if (/^ALTER\s+TABLE/i.test(statement) && /ADD\s+COLUMN/i.test(statement)) {
|
||||
assert.match(statement, /IF\s+NOT\s+EXISTS/i, 'ADD COLUMN without IF NOT EXISTS')
|
||||
}
|
||||
if (/^INSERT\s+INTO/i.test(statement)) {
|
||||
// A plain INSERT succeeds once and then fails the whole replay on the next
|
||||
// boot with a duplicate key — the classic "worked until I restarted it".
|
||||
assert.ok(
|
||||
/INSERT\s+IGNORE/i.test(statement) || /ON\s+DUPLICATE\s+KEY/i.test(statement),
|
||||
'INSERT must be IGNORE or carry ON DUPLICATE KEY — it runs again every boot',
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('purge drops every table the schema creates', () => {
|
||||
const created = schema
|
||||
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
const dropped = purge
|
||||
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
|
||||
for (const table of created) {
|
||||
assert.ok(dropped.includes(table), `${table} is created but never dropped — purge would orphan it`)
|
||||
}
|
||||
for (const table of dropped) {
|
||||
assert.ok(created.includes(table), `${table} is dropped but never created`)
|
||||
}
|
||||
})
|
||||
|
||||
test('purge drops in the reverse of creation order', () => {
|
||||
// With one table this proves nothing; with a parent and its children it is the
|
||||
// difference between a clean teardown and a purge that fails halfway, leaving
|
||||
// exactly the orphaned data it exists to remove.
|
||||
const created = schema
|
||||
.map((s) => /^CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
const dropped = purge
|
||||
.map((s) => /^DROP\s+TABLE(?:\s+IF\s+EXISTS)?\s+`?([A-Za-z0-9_]+)`?/i.exec(s))
|
||||
.filter(Boolean)
|
||||
.map((m) => m[1])
|
||||
|
||||
assert.deepStrictEqual(dropped, [...created].reverse())
|
||||
})
|
||||
58
template/server/test/worldStatus.test.js
Normal file
58
template/server/test/worldStatus.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// ── The model, with no database ───────────────────────────────────────────
|
||||
//
|
||||
// The `.db.js` / `.model.js` split pays for itself here: the logic worth testing
|
||||
// is in the model, and the model's only dependency is a function that returns a
|
||||
// row. Stub that and there is nothing to stand up.
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
|
||||
const db = require('../model/worldStatus/worldStatus.db')
|
||||
const { getPublicStatus, STALE_AFTER_MS } = require('../model/worldStatus/worldStatus.model')
|
||||
|
||||
const NOW = Date.parse('2026-08-12T12:00:00Z')
|
||||
|
||||
/** Replace `getStatus` for one test and put it back afterwards. */
|
||||
function withRow(row, fn) {
|
||||
const real = db.getStatus
|
||||
db.getStatus = async () => row
|
||||
return Promise.resolve(fn()).finally(() => { db.getStatus = real })
|
||||
}
|
||||
|
||||
test('a fresh row reports the world online', () =>
|
||||
withRow(
|
||||
{ online: 1, players: 12, worldName: 'Example World', updatedAt: new Date(NOW - 1000) },
|
||||
async () => {
|
||||
const status = await getPublicStatus(NOW)
|
||||
assert.strictEqual(status.online, true)
|
||||
assert.strictEqual(status.players, 12)
|
||||
assert.strictEqual(status.worldName, 'Example World')
|
||||
assert.strictEqual(status.stale, false)
|
||||
},
|
||||
))
|
||||
|
||||
test('a stale row is reported offline, whatever it says', () =>
|
||||
withRow(
|
||||
{ online: 1, players: 12, worldName: 'Example World', updatedAt: new Date(NOW - STALE_AFTER_MS - 1) },
|
||||
async () => {
|
||||
const status = await getPublicStatus(NOW)
|
||||
// The row claims the world is up. Nothing has written it in longer than the
|
||||
// freshness window, so the claim is not evidence of anything.
|
||||
assert.strictEqual(status.online, false)
|
||||
assert.strictEqual(status.players, 0)
|
||||
assert.strictEqual(status.stale, true)
|
||||
// The name is still worth showing — it does not go stale the way a player
|
||||
// count does.
|
||||
assert.strictEqual(status.worldName, 'Example World')
|
||||
},
|
||||
))
|
||||
|
||||
test('no row at all is an answer, not an error', () =>
|
||||
withRow(null, async () => {
|
||||
// A fresh install whose first boot has not finished replaying the schema.
|
||||
// The site must render; the next boot fixes it.
|
||||
const status = await getPublicStatus(NOW)
|
||||
assert.deepStrictEqual(status, {
|
||||
online: false, players: 0, worldName: null, updatedAt: null, stale: true,
|
||||
})
|
||||
}))
|
||||
Reference in New Issue
Block a user