The book, written out of the tree slice 1 proved. Four chapters in the order the
work happens: the first module in twenty minutes, the website module, the sidecar,
and the game-side plugin.
Shape, settled with the org lead:
* template/README.md stays the REFERENCE — it travels with a copied template and
CI holds it against the tree — and chapter 1 is the narration: what you should
see after each step, the state your module lands in, and the four ways it fails.
The chapter links to the checklist rather than restating it.
* chapters 3 and 4 cite link/ and servuo-plugins/ by FILE AND IDENTIFIER, never by
line. Those repositories move for their own reasons and checkLinks already
forbids commit permalinks, so a line number in this book is wrong the moment
they do. The template stays the only code quoted verbatim.
* one PR: the outline's status table and the link check are only coherent when the
whole set lands.
scripts/checkChapterPaths.js is the anti-rot half a machine can answer: every path
a chapter names in backticks must exist. None of those mentions is a markdown link,
so checkLinks never looked at them, and none is code, so nothing else did either —
renaming one template file would have left four chapters quietly pointing at
nothing. Its anchor list is STATED rather than derived from the tree, for the reason
the template's own build guard states it: a list derived from what exists cannot
fail when what exists changes, and an anchor that stops matching is a check that has
silently stopped checking. So each anchor must exist or the check fails. Eleven
tests, every "must not catch" case a span that really appears in the book.
stripFences moved to scripts/lib/markdown.js and both checks use it — shared code,
not a shared description.
CHAPTER 1 WAS RUN, NOT REASONED ABOUT. The template was copied into a real core on
edge, booted against the dev database, and every claim in "what you should see"
checked: the five log lines, /examplegame/status with its injected
<script type="module" src="/modules/examplegame/entry.js">, the chunk served
no-cache while module.json 404s, /api/v1/public/world/status, the capabilities in
/api/v1/public/modules, and the route in the merged /api/docs.json. Then the three
failures the chapter tells a reader to cause on purpose, because a chapter that
predicts the wrong debugging heuristic is worse than one that predicts none:
* an undeclared prefix -> stage `register`, "declared public/extra but never
registered it", routes 404 and absent from /public/modules;
* a table without the id prefix -> stage `schema`, at LOAD time, before mounting;
* a throwing onBoot -> after mounting, so the same route answers 503 "Module
unavailable" rather than vanishing.
All three came out exactly as written, and the messages in the chapter are that
core's own. Two small corrections fell out of the run: the log sample now shows the
real interleaving of core's three lines with the module's two, and the section on
failure adds that a module disappears from /api/v1/public/modules in every failure
case — a check that needs no login.
MODULE_SYSTEM.md 2.11.1 slice 2. Docs half: docs#146.
Co-Authored-By: Claude <noreply@anthropic.com>
488 lines
26 KiB
Markdown
488 lines
26 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.
|
||
|
||
**It is curated and closed, not a re-export of core's component library.** Adding
|
||
to it is a minor version bump; changing a kit component's props 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 an eighth thing, 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.
|
||
Two ways an annotation breaks are an object literal one brace short, and a `"` or
|
||
a backtick inside a single-quoted description. A third is only visible in a
|
||
rendered page: an escaped apostrophe (`\'`) survives literally into the output,
|
||
because the annotation is never evaluated as JavaScript. Use a typographic `’`.
|
||
|
||
## 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
|