Files
Integration-kit/book/02-website-module.md
wtclaude a8fa524263
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 31s
feat(kit): the engagement contract, taught and built (cutover 5 of 7)
The kit was pinned to website 963d734 -- MODULE_API 1.6.0, the Teams cutover --
and the platform is on 1.9.0. Three registrations and two calls arrived in
between, and a reader building against this book would have found no mention of
any of them: a module can now declare what its game can announce, and never who
is told.

Moving `ci/core-ref.json` is the mechanism for exactly this. The pin is now
66bb3b9a (website `main`, the engagement cutover) and `template/module.json`
declares `^1.9.0`.

What chapter 2 gained, under "Telling core something happened":

  * a TRIGGER is a payload contract, not a notification stream -- the two share
    one id namespace and are constantly confused;
  * `ceiling` is required, has no default, and is a CONTAINMENT tree rather than
    a size ladder (a `staff` ceiling does not permit `owner`);
  * an AUDIENCE resolver returns user ids and nothing else, resolves to NOBODY
    on failure, and takes CONSTANT params -- the constraint worth knowing before
    you design around it;
  * templates re-ensure per seedVersion, rule groups are offered ONCE per group
    key, so a rule appended to an existing group reaches fresh installs only;
  * `ctx.events.emit` binds the owner and is fire-and-forget; `ctx.inbox.push`
    is the direct write, for when there is nothing for an operator to decide.

The template builds all of it: one trigger, one audience over the clan roster it
already had, one seeded body and one seeded rule group, and an emitter in
`boot.js` that fires on the TRANSITION rather than on the poll. Seven new tests,
including the audience that resolves to nobody when its query throws.

Three claims were wrong and are corrected here rather than shipped:

  * core validates `subjectKey` against the declared variables and refuses the
    module; the draft taught a cooldown keyed on `undefined`, which the check
    exists to prevent and a reader will never see.
  * `emit` throws OUTSIDE production and only drops-and-logs inside it. Teaching
    the second half alone leaves a developer meeting a throw the book says
    cannot happen.
  * the seeded body itself was malformed -- heading `level: 2` where the block
    registry takes 'h2', and no block ids at all.

The third is the one worth keeping: `registerEngagementSeeds` checks that
`blocks` is a non-empty array and stops, so that body would have registered,
seeded, and failed the first time an operator opened it. Found by running the
template's `register()` through core's real registry at the pinned ref -- which
CI does not do, and cannot: the template job checks the version and runs the
template against fakes. A fake accepts what core refuses. The gap is now named
in the chapter, beside the code, and in the pin's own comment, and the rule that
bit has a test that fails on it.

Also: `checkLinks` skipped `.core/`. Bumping this pin means cloning core into
that directory first, and the walk then reported nine broken links in someone
else's README. CI never saw it -- the clone happens in the `template` job and
the check runs in `prose` -- so it was a failure only a person could meet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 12:57:55 -05:00

822 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 2. The website module
The module you built in [chapter 1](01-first-module.md), explained. This is the
longest chapter in the book because the website module is most of the work, and
because almost every part of it is shaped by a constraint that is invisible until
you hit it.
Nothing here is normative. [`MODULE_API.md`][api] is the contract; where this
chapter and the contract disagree, the contract is right and this chapter has a
bug. What is here is the reasoning — which is exactly what a contract cannot carry
without becoming unreadable.
---
## The shape of the whole thing
A module is a directory core reads at boot. Core loads it, hands it two objects,
and takes back whatever it registers.
```
core boots (its own schema and seed have already run)
└─ scans modules/*/module.json
└─ validates yours ← nothing mounted yet: a failure here leaves
│ nothing of yours on the URL surface at all
└─ require(server entry)
└─ register(ctx, api) ← your one synchronous handshake
└─ second pass: mounts everything that survived
└─ replays your schema fragment
└─ onBoot(ctx) ← the first moment a database exists
└─ HTTP listener binds
```
Two properties of that sequence explain most of the rules that follow.
**It is synchronous and it happens during `require`.** Core's route-manifest
generator and its OpenAPI generator both require the app with the database pool
pointed at a dead port — that is how they introspect a real Express app without a
database. So `register()` may not `await` and may not query. A module that did
would hang both build tools, and the symptom would be a CI job that never
finishes rather than an error anyone can read.
**Mounting is a second pass.** Every module is validated before any module is
mounted. If mounting happened inside the scan loop, the first module's layers
would be sitting on the tier router while the second was validated —
indistinguishable from core's own — and the second would be told it collided with
*core*, naming the wrong culprit. You will never see this; it is why the failure
messages you do see are trustworthy.
## `module.json`
Every field is documented in [§2.1][api]. Three of them decide whether your module
loads at all.
**`id`** is the directory core loads you from, the key of your database row, the
URL segment your pages hang under, and the required prefix of every table you
create. It must equal its own directory name — a module renamed by copying it to a
different directory is rejected rather than quietly mounted under a name nothing
else agrees with.
**`coreApi`** is a semver range against core's `MODULE_API_VERSION`. Set it to the
version you developed against and let it drift upward deliberately. This is the
one number that decides whether a module written today loads against a core
shipped next year, and a range that is too loose does not fail — it half-works.
**`mounts`** declares every prefix you will register, per tier. **The loader
compares it with what you actually register and rejects a mismatch in both
directions.** A prefix you declared and never registered fails just as loudly as a
route you registered without declaring. That is the point: the file is a statement
of your URL surface that cannot rot, because it is checked against reality at
every boot.
**Choosing prefixes is the part to slow down on.** They share one namespace with
core's own, so `/status` is not available to you — and the loader's collision
probe cannot see all of core's, because several of core's endpoints are mounted at
the tier root rather than under a prefix of their own. `template/server/index.js`
carries the current list of what core answers on the public tier in a comment
beside the registration. Read it before you choose, and choose a noun from your
own domain rather than a generic one.
`capabilities` is the opposite kind of field: opaque strings core never
interprets, published by `GET /api/v1/public/modules` while you are `started`, so
that a client — the SPA, the Android app — can feature-detect you. Two modules may
declare the same one. A client must treat an unknown capability as absent, and must
never infer a URL from one.
## The server entry point
One exported function, called once: `register(ctx, api)`. `ctx` is what core hands
you; `api` is what you hand back. Read `template/server/index.js` — it is short,
and every comment in it is load-bearing.
### Why `ctx` is handed over rather than imported
Your module lives at `<website>/modules/<id>/`, outside core's `server/`. Node's
resolver walks *up* from a file looking for `node_modules`, so it never reaches
core's — and `require('express')` from inside a module simply fails.
That is the mechanical reason, and it is the shallow one. The real reason is that
there is exactly one of certain things in the process and core owns them: one
express, so there is one `Router` prototype; one database pool; one logger; one
session reader. A second express resolved from your own dependencies would work
for about a week and then produce a routing bug nobody can reproduce.
So the rule generalises past the two obvious cases: **anything shared between core
and a module is owned by core and handed over, never resolved by the module.** On
the server that is `express` and `express-validator`; on the client it is React,
`react-dom`, `react-router-dom` and the JSX runtime. Both halves of the system
have one mechanism for it, and it is the same rule twice.
[§2.3][api] lists every member of `ctx`. It is a curated list, not core's
internals: `ctx.auth` is one function rather than core's whole auth facade,
because minting sessions is core's job and a module that needs an identity needs
to *read* one. `ctx.settings` is three functions rather than a settings model with
two dozen. Expect the narrowing, and expect to occasionally want something that is
not there — that is a conversation about a minor version bump, not a reason to
reach around it.
### The lazy-accessor pattern, and the require order it forces
`ctx` exists only from the moment `register()` is called. But the code underneath
— models, controllers, routers — is ordinary Node that requires its dependencies
at file scope, and *that* runs before `register()` does.
`template/server/core.js` is what makes both true at once: every member is an
accessor that resolves `ctx` **when it is called**, so a model can write
`const { query } = require('../../core')` at the top of the file, exactly as
ordinary code does.
Two consequences, and both have cost this project time:
**Require order is load-bearing.** A router writes `const express = core.express`
at *its* file scope, and that runs the moment the router is required. So
`core.init(ctx)` has to happen before the first `require` of anything under
`router/`. This is why `template/server/index.js` requires its routers *inside*
the register function instead of at the top of the file. Hoist them and the module
breaks with an error about a missing `ctx`, thrown from a file that never mentions
one.
**Never destructure a getter at init time.** Core is free to hand over an accessor
rather than a value — `ctx.site.baseUrl` is one — and a value captured once at
startup is a value that cannot change afterwards.
`template/server/core.js` is also deliberately a *narrowing*: it re-exports only
what the module actually uses. Copy that discipline. It makes the file an honest
statement of your dependencies, and it makes a test double for it — see
`template/server/test/_fakes.js` — a complete one rather than a guess.
## What you register
Twelve calls — ten registrations and the two lifecycle hooks — all synchronous,
all listed in [§2.4][api]. What is worth knowing is not their signatures but the
model behind them.
**Every call stages; nothing is committed until your whole module is known good.**
The shape of a claim is checked at the call, so a malformed one throws with your
own stack trace. Whether a *name* is taken can only be answered once your batch is
complete, and is checked when the loader commits it. So a module that registers two
notification streams and then throws leaves nothing behind. That matters more than
it sounds: a half-registered catalog is a stream a user can subscribe to and
nothing will ever publish to, which is worse than a missing one because it looks
like it works.
### Routes
`api.registerRoutes({ public, admin, player })` — one router per prefix per tier.
**The tier gate is already applied.** A router registered under `admin` sits
behind core's own `noindex, isLoggedIn, requireRole(...)`; under `player`, behind
`noindex, requireAuth`; under `public`, behind nothing, by design. You add
per-route gates on top of that and you never re-implement the tier gate. A module
cannot supply its own auth wrapper, and that restriction is one of the few places
the boundary is genuinely load-bearing rather than organisational: the server's
route table and the client's sidebar have to agree about who may see what, and
they only do if one thing decides.
Your router is mounted *inside* the tier router, so it structurally cannot reach
above its own prefix. This is not enforcement by review; there is no path
expressible from inside your router that escapes it.
### Extension slots
Sometimes what you have to add is not a page of your own but a section of core's.
An operator looking at a user in the admin panel wants that user's characters
right there, not on a separate screen.
`api.registerExtension(slot, router)` mounts your routes under a core resource,
and its client twin renders your component inside a core page. Core declares the
slot, you fill it, and one module per slot.
The naming rule is worth internalising, because it is what keeps a game-agnostic
core game-agnostic: **a slot is named for a PLACE, never for a meaning.**
`site.footer.status` is "the status-ish spot in the footer" — not a declaration
that core knows what a game server's status is. Core supplies the position and the
styling; the module owns the label, the target, the data, and whether it renders
anything at all. The moment core types a slot by its content, it has re-acquired
the semantics the module system exists to remove.
### Slots go the other way too
The direction above assumes core owns the page. Since `MODULE_API_VERSION` 1.6.0
there is the mirror of it, and **you will need it the moment your game has
anything like a guild**: a module declares a place on its own page and core fills
it.
```jsx
// client/src/entry.jsx — WHERE, in your words, and WHICH of core's contributions
registry.declareModuleSlot(ID, 'examplegame.clan.detail', { core: 'team.activity' })
// client/src/routes/public/Clan.jsx — from the UI kit
<Slot name="examplegame.clan.detail" externalId={externalId} moduleId="examplegame" />
```
**Why it has to invert.** A Team is a core entity — core owns the tables, the
membership sync, the access rules, the forum, the activity feed. What core does
not own is the *word*. A UO shard says guild, yours will say clan or company or
crew, and a core-rendered `/teams` page would publish a noun core invented, beside
your own page for the same thing. So the page is yours, and the parts core cannot
hand over are contributed into it. What core cannot hand over is the test for
whether something belongs in a slot: the activity feed's public/members split can
only be resolved by whatever owns membership, and that is core. You could render a
feed; you could not decide who sees which half of it.
Four rules, and the first two are the ones the shape depends on:
- **Your slot name is namespaced under your module id**, enforced rather than
conventional. It is what keeps two modules from claiming one name, and it makes
the owner readable where the slot is rendered.
- **Core names a CONTRIBUTION, never your slot.** `team.activity`, `team.forum`
and `team.notify` are core's three; the place they land in is yours to name and
yours to position. This is the half a second game depends on, and the first cut
of 1.6.0 had it the other way round — core filled three literal slot names
belonging to the first module, so everyone else's page came up empty with
nothing logged. This kit is what found that.
- **One slot per PLACE, not one per page.** A slot holds one component, so three
contributions want three declarations — and then you decide where each sits. The
template puts the notification control above its roster because muting is an
action *on* the page, and the feed and forum below it because they are content
*in* it. That decision is the reason to declare three.
- **Asking for a contribution core does not offer throws**, which is unusual here
— the client registry otherwise fails open. Core's catalogue is fixed at build
time and your `coreApi` range has already been checked, so an unknown one is
always a typo or a version skew, and the failure it would otherwise produce is a
page that renders empty forever.
`{ core }` is optional. A slot that asks for nothing stays empty, which is what
you want for a place you intend to fill yourself — and **first fill wins**, so a
module that fills its own declared slot keeps it and core's contribution is
skipped. The page is yours.
An empty slot renders nothing and is never an error: a core with no Teams, a
deployment with the forum switched off, a viewer with no membership. Design the
page to read correctly with every slot empty, because on some deployment it will
be.
### Notification streams, announce legs, post hooks
Three registries for three genuinely different things, and the distinctions are
easy to get wrong:
- **A notification stream** is a subscribable channel. You register the catalog
entry — id, label, whether it is personal, whether it needs a linked game
account — and core uses it for the subscribe endpoint and its gates. You publish
to it yourself with `ctx.push.publish`. Core never maps your events to your
streams; you have already resolved the id, and it follows that the safety rule
about which of your events may reach a *public* stream lives in your module too
— which is right, because the event kinds, the stream list and the filter are
then one file that moves together.
- **An announce leg** is one-shot delivery with retry. Core's CMS publishes a post,
every registered leg tries to deliver it somewhere, and your `classify` maps your
own result to `done` / `retry` / `terminal`. A leg that throws is caught,
classified as a retry, and never blocks another leg.
- **A post hook** maintains idempotent state, runs on delete as well as save, and
refreshes silently on an edit.
The last two fire on the same transition and are deliberately not one call. A leg
that must not be retried and a `classify` that means nothing would be the cost of
merging them.
Every hook is awaited and none may throw past core: a subscriber's failure costs
neither another subscriber nor the save itself. A hiccup in your sidecar breaking
somebody's blog post edit would be a worse bug than a stale mirror.
### Telling core something happened
Three registrations and one call, and together they are the seam where a module
is most tempted to reach past the boundary. The rule that keeps them safe is one
sentence: **you declare what CAN happen; core decides who is told.**
A **trigger** is not a notification stream, and the two are easy to confuse
because both are catalogs of things that happen in your game. A stream is a
subscribe toggle, and you publish to it yourself. A trigger is a **payload
contract**: it names the variables an event carries and how wide an audience it
may ever be given, an operator writes rules against it, and *core* does the
sending. Their ids share one namespace, so declaring both for the same id is
legal — that is one event with a toggle and a contract — while taking an id
another module owns is not.
Two fields on a trigger are worth more than their size.
**`ceiling` is required and has no default, and the values are ordered by
containment rather than by size.** It is the widest audience a rule on this
trigger may ever be given. There is no safe value to guess: `owner` silently
breaks a broadcast, `authenticated` silently widens something meant for staff.
And the ladder reading of the seven values is the trap — a `staff` ceiling does
**not** permit `owner`, because "one person" for a cheat-detection event is *the
player it was detected on*. Fewer people is not less exposure.
**`subjectKey` must name one of your declared variables**, because it is what the
cooldown is keyed on — "once per house", not "once per user". Core checks it at
registration and refuses the module, so this is one you meet at your first boot
rather than in production. The check is there because the failure it prevents is
the silent kind: a subjectKey naming nothing keys every subject on `undefined`,
which looks exactly like the feature working right up until two houses share it.
Every variable needs an `example`, and it is not decoration: it is what lets an
operator preview and test-send a body without waiting for a real game event,
which is the reason template systems ship untested. The type set is closed and
has no `object` or `array` — a message that has to walk a structure has outgrown
interpolation.
An **audience** is a named set of *people* you can resolve over your own data,
for an operator to point a rule at. "This clan's members" is one. "Everyone who
opened the last mail" is not, and nothing here builds it.
**Your resolver returns user ids and nothing else.** It is not handed a template,
a channel or an address, and it cannot enumerate them; core maps ids to addresses
on its own side, after preferences, suppression and the verification gate. That
narrowness is deliberate — a module still cannot send mail, and this is the
obvious place a back door would go. Two consequences follow from it:
- **A resolver that fails resolves to NOBODY**, never to everybody and never to
its last good answer. Core enforces that, and your resolver should choose it
too, so the log can say which clan.
- **Its params are CONSTANT.** An operator fills them in when they save the rule.
There is no way to say "the clan this event was about" — if a rule needs that,
the *event* carries its own recipients instead. This is the constraint most
worth knowing before you design around it rather than after.
The third registration ships the **content**: the bodies your messages use and
the rules that decide when one is sent. Both arrive **switched off**, and
`enabled` is not a parameter. An operator turns a module's mail on; installing a
module never does.
The two halves have different lifetimes, and the asymmetry is the contract.
**Templates re-ensure on every boot** under a seed version, so a better default
reaches deployments that never edited it while one an operator *has* edited is
left alone. **Rule groups are offered once, per named group key**, because
re-offering would resurrect a rule somebody deleted and reset one they enabled.
The consequence is easy to trip over: a rule appended to an existing group
reaches **fresh installs only**. That is the guarantee rather than a limitation
to route around, and a rule that must reach existing deployments takes a new
group key. You name the groups, so the choice is yours to make knowingly.
Core's generic bodies are a first-class answer rather than a fallback. Point a
channel at `notify.event` or `inapp.event` and author nothing; ship a body of your
own when the message has something to say that a structural projection of the
payload cannot.
**One thing in a seeded body is not checked when you register it.** The call
asserts that `blocks` is a non-empty array and stops there; the body itself is
validated by the block registry, which runs in the editor and in the renderer. So
a malformed block registers cleanly, seeds cleanly, and first shows itself when an
operator opens the body or a rule fires. Build one, open it in Admin → Engagement →
Templates once, and you have checked the half that boot cannot.
Finally the call. `ctx.events.emit(triggerId, envelope)` fires one of your own
triggers — core binds the owner from the calling module and never reads it from
the arguments, so there is no shape of this call that fires somebody else's
event. It returns nothing and, in production, never throws: there is nothing a
module could correctly do about a delivery failure from inside a game-event
handler, so there is nothing to await. **Outside production it does throw**, at
your call site — a payload that does not match the contract you declared is a bug
rather than a condition, and the throw is how you meet it in your own tests
instead of in an operator's log six weeks later.
Beside it is the one call that skips the rules entirely.
`ctx.inbox.push(userId, item)` writes a single item into a single person's on-site
inbox. Reach for it when there is nothing for an operator to decide — a job that
person started has finished — and for anything else use a trigger, so the message
can be turned off, re-targeted, or sent by mail as well without a code change. The
posture is `emit`'s: the owner is bound from the calling module, it returns
nothing, and it will not tell you that the user has that channel switched off,
because a module that could see that could enumerate people's preferences one
write at a time.
**Emit on the transition, not on the poll.** The template's `refresh()` runs every
thirty seconds and emits only when the world's online state actually changed.
Core's cooldown and hourly cap would both hold if it did not — but leaning on
them means emitting "the world is still up" and calling it news, and the operator
who tightens the cooldown to stop it has hidden your bug rather than fixed it.
Two smaller traps sit inside the same function and are worth reading in
`template/server/boot.js`: the previous state has to be read *before* the write,
or every poll looks like no change at all, and the very first boot has no previous
state, which is not a change either.
### Becoming the source of Teams
`api.registerTeamProvider({ getTeams, getTeamMembers, getTeamLeaders })` — and
this one is not like the others.
**Every registration up to here hands core something to hold.** A router to
mount, a nav row to draw, a hook to call when a post is saved. This hands core
something it will *pick up and call*, from its own reconciler, and — for the
optional fourth method — on a request path with a visitor waiting. It is the
first place in this contract where **core calls you and waits**, and every rule
below falls out of that one fact.
The three required methods answer the three questions core has about the Teams
you are authoritative for: what Teams exist, who is in one, and which of those
lead. `template/server/model/clans/clanProvider.model.js` is a working one,
including the guard clauses; the shape is:
```js
getTeams() // () => { ok, complete?, teams: [{ externalId, name, abbr?, meta? }] }
getTeamMembers(externalId) // => { ok, complete?, members: [{ memberKey, displayName?, rankLabel?,
// leader?, online?, userId? }] }
getTeamLeaders(externalId) // => { ok, leaders: [memberKey] }
// the module knows it cannot answer — sidecar down, cache cold, boot unfinished
{ ok: false, reason: 'sidecar unreachable' }
```
**The envelope is the contract, and it is not decoration.** A rejected promise, a
synchronous throw, a timeout past core's ten-second budget, a non-object, a
missing `ok`, a malformed row — core reads every one of them as `{ ok: false }`.
There is no shape a failure can take that core reads as "zero Teams". That is the
whole argument for it: a bare array has exactly one such shape, `[]`, and it is
the one you return while your sidecar is still connecting.
**So refusing is normal.** `{ ok: false }` is an ordinary answer, not an error you
failed to handle. Core keeps the projection it has, records your reason and shows
it to an operator. A refusal costs staleness and nothing else.
**The mistake to not make** is answering `{ ok: true, teams: [] }` because your
game is unreachable. It reads as an authoritative "this deployment has no Teams",
and core acts on authoritative answers — it archives Teams that have stopped
existing and departs members who have left. A cold start would empty every roster
on the site, and your module would have done it by being helpful. The template's
provider therefore refuses whenever its data might be stale, *even though the rows
it holds are perfectly readable*: core cannot tell a snapshot five minutes old
from one five days old, and it makes destructive decisions from a complete answer.
Same reasoning one level down — an empty roster is refused unless the game says
the Team is empty, because the Team and its roster arrive on separate frames in
any real ingest and there is a window where you know one and not the other.
**`projectRoster(externalId, members, viewer)` is optional and fails CLOSED**, and
that asymmetry is the part worth carrying away. It answers *who may look at this
roster*, on the request path, because the audience model is yours — core does not
know what your rungs are called and cannot invent one. For the other three, an
unanswered call must change nothing. For this one, "keep what you have" means
serving the roster unprojected to whoever asked, which is a leak. So core
distinguishes two refusals and you get the right one for free:
- **no provider, or no `projectRoster`** — nothing is being withheld, so core
serves the roster whole at its own public shape. That is what makes the method
genuinely optional.
- **a `projectRoster` that refused, threw, timed out or answered malformed** — core
serves an empty roster and says so. You claimed an opinion and then did not give
it.
Two smaller things the template gets right and are easy to get wrong: it hands
back the member keys **core** supplied (core's rows, core's `member_key` spelling)
rather than its own, and it treats an anonymous viewer — core hands over `null`
as an *answer* rather than as a lookup that failed. The second one refuses on
every anonymous visit, which on a public deployment is most of your traffic.
**One provider per deployment.** Unlike every other registry this holds a single
value: two modules answering "what Teams exist" would produce two disjoint sets
under one table with no rule for merging them.
**`pageUrlTemplate` is data, not a method** — `'/examplegame/clans/{externalId}'`
— and it is the fifth member. Teams have no core page, so core cannot work out
where yours is, and a notification email about a forum reply that cannot link to
the thread is most of the way to useless. A relative path only; core substitutes
`{externalId}` and `{slug}` and does nothing else with it. Data rather than a
callback deliberately: a function here would put a module hook on the mail path,
one more thing that can hang, to produce a string that never varies.
**What core never gets is your tables.** It asks the questions; you own the
storage, the ingest and the game↔site account mapping (`userId` on a member is
resolved by you, because a core that resolved it would be core reading a module's
table by name). The traffic in the other direction is `ctx.teams.*`, and it is
narrow on purpose.
### The lifecycle hooks
`api.onBoot(fn)` runs after core's schema, after your schema fragment, and
**before the HTTP listener binds**. It is the first moment a database exists, so
it is where everything that needs one goes: warming a cache, backfilling,
connecting to your sidecar.
**`onBoot` has no timeout, deliberately.** A slow boot delays the listener, and
that is the guarantee rather than a problem to be timed out — a module that must
not serve traffic before it has warmed up gets exactly that. If your `onBoot`
throws, you are `startup_failed`: your routes stay mounted and answer `503`, and
the site comes up without you.
`api.onShutdown(fn)` runs while core's pool, push dispatcher and event fan-out are
all still open, because flushing through them is the only thing it is for. It has
a five-second budget and is abandoned past it — the process is exiting anyway, and
the alternative is a host where stopping the service waits for a kill.
A module whose `onBoot` threw gets **no** `onShutdown`. It is part-way through a
warm-up it never finished, and handing it a half-built world to tear down is worse
than not closing cleanly. A module with no hooks at all still reaches `started`:
having nothing to warm up is not the same as never having started.
## The schema fragment
`template/server/db/schema.sql` is your tables. It is replayed **in full, at every
boot**, statement by statement, right after core's own schema.
**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. What you get in exchange is that a module's schema is a single readable
statement of what its tables are, with no ordering history to reconstruct and no
migration table to get out of step with the tables themselves.
What it costs you is that **changing a table is an `ALTER`, never an edit to its
`CREATE`.** `CREATE TABLE IF NOT EXISTS` no-ops against an existing table, so an
edited column definition lands on fresh installs only — and your development
database is usually the fresh one, which is what makes this bite six months later
on somebody else's instance. Add the column with
`ALTER TABLE … ADD COLUMN IF NOT EXISTS`, leave the `CREATE` alone, and both paths
converge.
Two rules the loader enforces before your module is mounted at all:
**Leading verbs are an allowlist: `CREATE`, `ALTER`, `INSERT`, `UPDATE`.** Not a
`DROP` denylist — because the file is replayed at every boot, `TRUNCATE` and
`DELETE` would empty a table at every restart and `RENAME` would fail at the
second one. A denylist only ever bans what somebody thought of.
**Table names are namespaced `<id>_` and collision-checked** against core's tables
and every other module's. A `CREATE TABLE` missing `IF NOT EXISTS` is rejected on
the same grounds as the rest: it succeeds exactly once and fails every boot after,
which presents to an operator as a module that broke on restart.
Both are checked by *reading the file*, before anything mounts, and that split is
the design: everything knowable without a database costs you the mount, so a
rule-breaking fragment never half-applies; what only a database can answer — an
unknown column type, a bad foreign key — happens later and answers `503`.
`purge.sql` is the destructive counterpart, and it is required whenever you ship a
schema. It runs **only** when an operator explicitly purges you, never on
uninstall. A module that can create tables and cannot drop them leaves an operator
with orphaned data and no supported way to remove it.
One more thing about a file that replays: **a guard and the statement it guards
must live in the same file.** If you write a one-shot data fix conditioned on a
marker, put both the marker and the fix in your own fragment. Core's schema
replays in full before any module's, so a marker core writes has already been
written by the time your guard reads it — a real defect this project shipped and
did not notice, because it is latent until the day someone installs on an older
version.
## The client half
Your client half is a **prebuilt ES module**. Core serves it from your module's
directory as a same-origin script and injects a `<script type="module" src>` for
it before `</body>`. There is no bundling step on the operator's machine, ever.
### One React, and core owns it
`window.__rg` is core's published set of shared dependencies plus the registry,
the UI kit and a request primitive ([§3.2][api]). Your build does not bundle React
— it aliases every shared specifier to a two-line shim that re-exports from that
global.
The failure this prevents is specific and nasty: a second React in the page is a
second hook dispatcher, so your component throws about an invalid hook call
somewhere unrelated to the mistake, in a page that otherwise loads fine.
`template/client/vite.config.js` is the whole mechanism, and its comments are the
most valuable prose in the template. Three things there were wrong first and are
now contract:
- **The aliases use the array form with anchored regexes.** Vite's object form does
prefix matching, so a `react` key also silently rewrites `react/jsx-runtime` — to
the wrong shim.
- **The aliases replace `external`; they do not accompany it.** Rollup asks
`external` *before* Vite's alias resolver runs, so a specifier in both is never
aliased and the chunk ships bare `import 'react'` specifiers. A browser cannot
resolve those without an import map, and core's `script-src 'self'` forbids the
inline script an import map has to be. The first real module shipped exactly that
chunk, from a clean green build.
- **The build guard hooks `transform`, not `load`**, and its forbidden-package list
is stated rather than derived from the alias list. `load` is first-wins, so
written against it the guard sat in the build doing nothing. Deriving the list
means deleting an alias also deletes the guard against what that alias prevented
— precisely when it is needed.
`template/client/scripts/checkExternals.js` asks the **built chunk** whether any
bare specifier survived. That question cannot be asked of source:
`import { useState } from 'react'` is correct in every file, and which React it
becomes is decided by the build config. Run it in your CI.
### Registration happens at evaluation time
`template/client/src/entry.jsx` registers your routes and nav rows with plain
top-level calls. There is no subscription and no late registration: module chunks
are deferred scripts that execute after core's bundle and before core's first
render, so everything you register is present in that first render.
**So every page is a static import, and lazy-loading your routes is the one thing
this seam cannot have.** A module that registered asynchronously would register
after the route table had been read, and the symptom is a page that redirects home
with nothing logged anywhere — indistinguishable from a module that failed to
load.
That timing is also where this project's most instructive client-side bug lived.
Core's own render used to wait on `document.readyState === 'loading'`; but a
deferred script runs *after* the document is parsed, so `readyState` is already
`'interactive'`, and core mounted immediately — before any module chunk had
evaluated. Every unit test passed. It was found by loading a real chunk in a real
browser, which is the only place it was visible.
### Nav, and what a registered row becomes
`registry.registerNav` interleaves your rows into **core's** navigation groups,
and from that moment your row is an ordinary row: an operator can reorder,
relabel or hide it from the nav editor exactly as they can core's. That works
because the interleave happens *before* the admin override merge — the override
layer is keyed by a row's `to`, and it drops keys its base does not declare, so a
row appended afterwards would be unorderable, unrelabellable and unhideable.
Three details worth knowing before you need them:
- **A row with no `order` appends after core's rows** rather than defaulting to
zero. "I didn't ask for a position" must not mean "put me first".
- **An unknown `group` name appends a new group** rather than dropping your row.
- **`icon` has no core fallback.** Public header rows carry no icons, so a public
row needs none; an admin or player row without one is the only glyph-less row in
its sidebar, which reads as breakage. Match the nav you land in rather than
shipping one glyph for everywhere.
`registerFeatureProvider` is how a row can be conditional: core keeps a generic
flag context and you supply the hook that fills your namespace. **The namespace
comes from the registration, not from parsing the string**, so a typo'd namespace
is not a thing that can exist.
**Everything in this layer fails open.** No provider, an answer still in flight, a
malformed row — all of them show the link. This is presentation and the server is
the gate: a UI mistake that hides a page from someone entitled to it is worse in
every case than one that shows a link which then answers `403`.
### The UI kit
Core publishes a small set of components and hooks on `window.__rg.ui`
([§3.4][api] is the list): the public layout, a page header, the loading, error
and empty states, the async hook every data page uses, read-only access to the
session and site settings, and `Slot`. Enough to build a page that looks like the
site it is installed in, and nothing else.
`Slot` is the odd one — not a widget but the thing that renders a place you
declared for core, from ["Slots go the other way too"](#slots-go-the-other-way-too)
above. It is in the kit rather than left to you for the reason the kit exists at
all: reimplementing it would mean a second error boundary with different
behaviour, and what this one contains is *core's* content failing inside *your*
page.
**`PublicLayout` needs a `shell`, and this is the one that will catch you.** The
layout is the *chrome* — header, footer, the flex column they sit in. The `shell`
prop is the *body*: the centred max-width column, the vertical padding, and the
element whose `flex: 1` is the only thing holding the footer at the bottom of the
viewport.
```jsx
<PublicLayout shell="narrow"> // 'narrow' · 'mid' · 'wide'
```
Omit it and your content starts hard against the left edge of the window with no
padding, and the footer climbs up underneath it. It reads as a stylesheet bug in
your module and it is not one — core's own pages write that wrapper by hand, and
before `MODULE_API_VERSION` 1.5.0 a module had no way to. **Name a width, never a
class:** the class names are core's stylesheet's and it is free to rename them,
which is exactly why they are not in the contract and this prop is.
That paragraph exists because the kit's acceptance run
([`kit-acceptance.md`][acceptance]) built a module by following this chapter to the
letter, and its page rendered outside the site. Everything else it wrote was right.
**And check a component's prop names against [§3.4][api] rather than guessing
them.** `PageHeader` takes `eyebrow`, `title`, `lead` and `center` — a page that
passes `subtitle` renders its heading and nothing under it, because an unknown
prop on a React component is silently dropped. Nothing warns, in the console or
anywhere else; the page simply looks emptier than every core page around it. This
template shipped exactly that mistake until a run of it against a real core was
looked at, which is the only way that class of thing is ever found.
**It is curated and closed, not a re-export of core's component library.** Adding
to it is a minor version bump, and so is adding an optional prop to a member;
changing an existing prop is a major one.
That is a real constraint on core, and it is the price of the boundary being worth
anything.
So: when you want something it does not have, bundle it. Tables, chips, tabs, editors — those
are yours, and your chunk carries them. Reaching into core's tree for a component
is the one thing that is never available, and `template/server/scripts/checkImports.js`
exists to make sure a moment of weakness fails the build instead of shipping.
One thing that surprises everyone once: **core's public pages render the public
layout themselves** — it is a component, not a route wrapper. A public page of
yours that does not use `ui.PublicLayout` renders bare, with no site chrome. That
is the contract working as intended, not a bug to hunt.
## The OpenAPI fragment
Every module that registers routes ships `swagger-fragment.json` in its bundle
root, and core merges the fragments of started modules into its own API document.
The filename is fixed rather than declared, like `module.json` itself.
**Generate it from your own registrations**`template/server/scripts/swaggerFragment.js`
is a working generator. It runs your `register()` against a recording `api` and
resolves each router back to its source file, so a mount prefix exists in exactly
one place rather than being retyped into a generator that then drifts.
Two rules and one trap:
**Namespace what you define; reference core's shared schemas by core's name.** A
schema you invented gets your prefix. `Error` and `ValidationError` are core's:
reference them and do not redefine them. They resolve in the merged document,
which is the only place both halves exist — and shipping your own copy is a
collision core drops, arriving at the same result the expensive way.
**Commit the generated file and check it is current in CI.** Core merges it
verbatim, so a stale fragment documents a URL surface you do not serve, and
nothing at runtime will ever say so.
The trap: **swagger-autogen reports a broken annotation and then prints
`Success`.** It logs a syntax error, drops that annotation, and exits zero. The
template's generator captures those diagnostics and fails on them — keep that.
The usual cause is an object literal one brace short.
**A quote character is worse, because it does not log anything.** These
annotations are evaluated as JavaScript literals, so a `'` or a `"` inside a
single-quoted description ends the string early — and for a `"` in the middle of
a sentence the result is not an error at all. The value is silently **truncated**
at that character:
```js
// #swagger.summary = 'A "quoted" world status'
// → "summary": "A \"" and swagger-autogen still prints Success
```
Nothing throws, so the generator's error capture has nothing to capture. The only
signal is `check:swagger` calling the fragment stale, with a message that blames
your routes. **If that check fires and your routes did not change, look for a
quote in an annotation first.** Backticks are safe — Markdown spans survive
verbatim. And an escaped apostrophe (`\'`) is a third case, visible only in a
rendered page: the annotation is never evaluated as JavaScript by the reader, so
Swagger UI shows the backslash. Use a typographic `` throughout.
## Packaging and release
`template/.gitea/workflows/release.yml` (and its GitHub twin) is a working release
pipeline. Copy it to the root of your module's repository — a workflow file is
only read from a repository root, which is why it does nothing where it sits
inside the kit.
**A release is not source.** It is the directory core's loader expects to find at
`modules/<id>/`, already assembled: the prebuilt chunk, your runtime dependencies
installed, the schema fragment, the OpenAPI fragment. Core downloads the tarball,
verifies it against the `sha256` in the install manifest, and unpacks it. Nothing
runs `npm` on the way.
**The version is computed from your commit subjects, and `module.json`'s is a
floor.** Every push to `main` carrying a `feat:`, `fix:` or `perf:` publishes a
bundle — `feat!:` and `BREAKING CHANGE` make it a major, `feat:` a minor, the
rest a patch — and a `main` that gained none of those cuts no release. The number
that ships is the **tag**, which the workflow writes into the `module.json` inside
the bundle.
The alternative is tempting and it is what this project's own reference module
did first: let `module.json`'s version decide, and release whenever a push leaves
it at a version with no release yet. You already have that number, it is what core
records and what the admin panel shows, and two sources for one number is how they
drift. It was abandoned on 2026-08-19 for a reason worth knowing before you copy
either shape — **its cost is paid on every release, and the drift it prevents is
something review catches anyway.** A week of merged work produced no bundle at
all, because none of it happened to touch that line, and shipping it meant first
merging a pull request whose entire content was a number.
So the declaration is kept, demoted to a floor: name a version in `module.json`
above the newest tag and *that* is what releases. It is still how you say "this
one is a minor" when a `coreApi` bump forces the question. And for a change with
nothing releasable behind it — a widened `coreApi`, a new mount, a new capability
— run the workflow by hand: leave `version` blank to bump the newest tag by
`bump`, or type an exact version.
The workflow tags and publishes and never writes to a branch, so a protected
`main` needs no exception.
The install manifest is the JSON your workflow publishes beside the tarball. Its
URL is what an operator pastes into Admin → Modules, and the host it lives on has
to be on that core's allowlist — an operator-controlled setting, so tell your users
where you publish.
## Boundaries
[§2.7][api] is the list. Each item has a failure behind it:
| Rule | What it prevents |
| --- | --- |
| No `require` outside your own directory (bar built-ins and your own dependencies) | Two copies of a thing there must be one of; and a module that survives a core refactor only by luck. |
| Do not mutate `ctx`, `req.user`, or anything core handed you | A module changing another module's world, invisibly. |
| No app-level middleware, no Express error handler | One module deciding how every other module's errors are rendered. |
| Do not read `process.env` for core configuration | Configuration with two sources and no panel. Your own config is a settings key or your own table. |
| No `process.exit`, no signal handlers, no listeners | A module taking the site down, or racing core's shutdown. |
| Write only inside your module root and the upload directory | A module that cannot be uninstalled cleanly. |
| Never read or write a core table — including the Team tables you populate | A module racing core's own reconciler for rows core owns. You answer questions about Teams; core stores them. |
| **Never open a connection to a game server from the website process** | The whole of [chapter 3](03-sidecar.md). |
That last one is newer than the others and is the reason this kit is three
chapters and not one. It is also the only rule in the list with **no CI behind
it** — an outbound socket is not statically detectable the way an internal
`require` is — so it is enforced in review and by understanding it, which is what
the next chapter is for.
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md