A cold agent was given this repo and the documents it links to, and nothing else — no core source, no module-uo — and asked to build a module for a second game. It did, in one pass. The record is docs/modules/kit-acceptance.md; this is the repair list, plus the two things it recommended that were not defects. The one it could not find, because it had no core to render against: a module page built exactly as this kit teaches renders OUTSIDE the site. PublicLayout is the chrome, not the body. Core grew an opt-in `shell` prop for it (MODULE_API_VERSION 1.5.0, website#148); the template passes shell="narrow" and chapter 2 explains why you name a width and never a class. Fixed: - **F1, and the worst of them, because it lands in the first twenty minutes.** `npm run check:swagger` failed on a PRISTINE template on Windows: the check compared the committed fragment byte-for-byte and a default Windows clone is CRLF while the generator writes LF. The message blamed "the routes or their annotations". Now `template/.gitattributes` pins `eol=lf` and the comparison normalises line endings anyway — a check may only fail for the reason it names, and this one names a diagnosis. - **F3** — `.gitea/workflows/release.yml` carries `gitea.example.com` and `your-org/your-module` under a literal `# CHANGE THESE`, was not in the rename checklist, and `checkRenameSites.js` could not match it, so CI was silent by construction. Row added, pattern widened. (The agent reported both workflow flavours; only the Gitea one is affected — GitHub supplies its own variables. Corrected in the record.) The near-miss is kept in the check's comments and its suite: the obvious widening is `example\.com`, which fires on a fixture URL in checkImports.test.js. Every alternative has to be a string that cannot occur by accident, which is the same rule that made the id `examplegame`. - **F4** — the release bundle's include list was hardcoded, so adding `server/utils/` would have silently dropped it from every release while the bundle check stayed green. Inverted to an exclusion list, in both flavours, and run by hand because a release workflow never executes in CI. - **F5** — the annotation-quoting warning was wrong in both directions, and the correction is measured rather than reasoned. A backtick is harmless (the template's own description has two spans and they survive). A `"` is not, and it does not throw: `'A "quoted" status'` is silently TRUNCATED to `A "` while swagger-autogen prints Success and the error capture sees nothing. The only signal is check:swagger blaming your routes. - **F6** — `template/.gitignore`, so a copied template that is `git init`ed inherits ignore rules instead of nothing. - **F7** — the UI kit is eight exports across five rows, not seven. The contract said seven and this kit had faithfully carried the miscount out of it. Adopted, not defects: - Chapter 1 now says to run every check on the untouched copy first. That is what found F1; without a baseline the first failure is ambiguous forever. - The template ships the §2.7 self-check the agent wrote for itself. The rule has no CI in general — an outbound socket is not statically detectable — but a module can make a decidable claim about its own tree. Ported from its code with a header explaining how to NARROW it when a sidecar client arrives, since talking to your sidecar is the expected shape and is not what §2.7 forbids. The pin moves to website edge 4ad8b2b, the 1.5.0 bump, and template/module.json declares ^1.5.0 — so checkCoreApi's equality assertion still holds and the template uses a member that exists only at that ref and later. 32 server + 18 client template tests, 21 kit-script tests, all four checks green. Co-Authored-By: Claude <noreply@anthropic.com>
527 lines
28 KiB
Markdown
527 lines
28 KiB
Markdown
# 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
|
||
|
||
Seven calls, all synchronous, all documented 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. **Only core may
|
||
declare a slot; a module may only fill one**, 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.
|
||
|
||
### 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.
|
||
|
||
### 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, and read-only access to the
|
||
session and site settings. Enough to build a page that looks like the site it is
|
||
installed in, and nothing else.
|
||
|
||
**`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.
|
||
|
||
**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 declared in `module.json`, not computed from commit subjects.**
|
||
You already have one authoritative version — it is what core records and what the
|
||
admin panel shows — and two sources for one number is how they drift. A release
|
||
happens when a push to `main` leaves a version that has no release yet, so
|
||
bumping is an ordinary reviewed change and publishing is the workflow's business.
|
||
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 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
|