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>
245 lines
12 KiB
Markdown
245 lines
12 KiB
Markdown
# 1. Your first module in twenty minutes
|
|
|
|
No theory in this chapter. You will copy a module that already works, rename it,
|
|
build it, install it into a running core, and load a page it serves. Everything
|
|
after this chapter is a change to something that runs, rather than a step toward
|
|
something that might.
|
|
|
|
That order is deliberate. The module system has a lot of seams — a server entry
|
|
point, a client chunk, a schema fragment, a nav registration, an OpenAPI fragment
|
|
— and each one is easy to understand and unpleasant to debug in the abstract. Get
|
|
all of them working at once with almost no content in them, and you can then break
|
|
exactly one at a time on purpose.
|
|
|
|
**What you need:** a Runic Gateway core you can restart, Node 20 or newer, and
|
|
about twenty minutes. You do not need core's source, and you should not read it —
|
|
if this chapter cannot be followed without it, that is a bug in this chapter and
|
|
[worth telling us about][issues].
|
|
|
|
---
|
|
|
|
## The pieces you are about to copy
|
|
|
|
`template/` is a whole module, in the shape a real one has. Nine things matter and
|
|
the rest is filling:
|
|
|
|
| Piece | What it is |
|
|
| --- | --- |
|
|
| `template/module.json` | The first thing core reads. Your id, your version, the core API range you need, and a declaration of every prefix you will mount. |
|
|
| `template/server/index.js` | The server-side handshake: one exported function, called once with `(ctx, api)`. |
|
|
| `template/server/core.js` | Lazy accessors over `ctx`, so the rest of your server code can reach core the way ordinary code reaches a library. |
|
|
| `template/server/boot.js` | `onBoot` and `onShutdown` — where anything needing a live database goes. |
|
|
| `template/server/db/schema.sql` | Your tables. Idempotent, replayed at every boot. |
|
|
| `template/server/db/purge.sql` | The same tables, dropped. Run only when an operator explicitly purges you. |
|
|
| `template/client/src/entry.jsx` | The client-side handshake: registers your routes and your nav rows into core's SPA. |
|
|
| `template/client/vite.config.js` | The library build that produces the chunk core serves — and the aliases that make your React core's React. |
|
|
| `template/swagger-fragment.json` | Generated. Core merges it into its own API documentation. |
|
|
|
|
Two of those have a reputation. `vite.config.js` is the highest-risk mechanical
|
|
detail in the whole system and chapter 2 spends real time on why; `module.json`'s
|
|
`mounts` is the one field people fill in wrong and discover at boot. Neither
|
|
matters yet — the template has both right.
|
|
|
|
## Copy it, and make it yours
|
|
|
|
```bash
|
|
cp -r template/ ~/my-module
|
|
cd ~/my-module
|
|
```
|
|
|
|
Your module id is the single most load-bearing string in it: it is the directory
|
|
core loads you from, the key in core's database, the URL segment every one of your
|
|
pages hangs under, and the prefix every one of your tables must carry. It must
|
|
match `^[a-z][a-z0-9-]{1,31}$`, and you want no hyphen in it unless you enjoy
|
|
backticking table names.
|
|
|
|
Change `id` in `module.json` first, then work down the checklist in
|
|
`template/README.md` — it names every file that still carries the placeholder,
|
|
and it is [verified by CI][renamecheck] in both directions, so it is not the kind
|
|
of checklist that is wrong by the second edit.
|
|
|
|
**The placeholder is `examplegame`, not `example`, and that is not an aesthetic
|
|
choice.** A check for a leftover `example` fires on the phrase "for example" in
|
|
ordinary prose, and a check that cries wolf is a check people learn to ignore. If
|
|
you build your own checks later, pick placeholder names that cannot occur by
|
|
accident.
|
|
|
|
## Build it
|
|
|
|
```bash
|
|
npm ci --prefix server
|
|
npm test --prefix server
|
|
|
|
npm ci --prefix client
|
|
npm run build --prefix client # → client/dist/entry.js
|
|
npm test --prefix client
|
|
```
|
|
|
|
Build **before** you run the client tests. Two of them read the built chunk and
|
|
skip when there is none, so a run in the other order passes while asking nothing
|
|
about the artifact that actually ships. That ordering has bitten this project
|
|
twice in two different repositories, which is why it is called out here rather
|
|
than left to a CI file.
|
|
|
|
What you have now is `client/dist/entry.js` — a prebuilt ES module — and a server
|
|
tree that has never been compiled at all, because it does not need to be.
|
|
|
|
**An operator never builds anything.** That is the constraint the whole delivery
|
|
path is designed around: a module arrives as a tarball with the chunk already in
|
|
it, and core serves that file untouched. Your build machine is the only place a
|
|
bundler ever runs.
|
|
|
|
## Install it
|
|
|
|
Three supported ways, and for the next twenty minutes you want the third:
|
|
|
|
1. **Admin → Modules**, pasting the URL of an install manifest — the JSON your
|
|
release workflow publishes beside your tarball. This is how a real operator
|
|
installs you.
|
|
2. **The `MODULES` environment variable**, `<id>@<version>=<manifest URL>`, for a
|
|
deployment that declares its module set instead of clicking it.
|
|
3. **A directory on the volume.** Copy your whole module tree to
|
|
`<website>/modules/<your-id>/` and restart core.
|
|
|
|
```bash
|
|
cp -r ~/my-module <website>/modules/my-id
|
|
# restart core
|
|
```
|
|
|
|
**Copy it. Do not symlink it.** The loader lists directory entries and asks each
|
|
whether it is a directory; a symlink answers no, and your module is skipped in
|
|
complete silence. This is the single most common way a first install appears to do
|
|
nothing at all.
|
|
|
|
Two more things that look like your module failing and are not:
|
|
|
|
- If core is running in a container, your files have to be on the volume core sees
|
|
— `MODULES_DIR` (`/app/modules` under the shipped Compose file), not the
|
|
repository directory next to it.
|
|
- A core with a fresh database boots in **maintenance mode**, and public module
|
|
pages sit behind the same maintenance gate core's own do. Your page will look
|
|
broken while the site is not live yet.
|
|
|
|
## What you should see
|
|
|
|
Restart core and read the log. A module that loaded says so:
|
|
|
|
```
|
|
INFO [examplegame] registered {"version":"0.1.0","routes":"public:/world"}
|
|
INFO [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world"]}}
|
|
INFO [modules] schema ensured for module "examplegame" {"statements":2}
|
|
INFO [examplegame:boot] booted {"refreshMs":30000}
|
|
INFO [modules] module "examplegame" started
|
|
```
|
|
|
|
Your two lines and core's three, interleaved: core narrates each step of your load
|
|
in its own `[modules]` namespace, and your logger is namespaced with your id. That
|
|
alternation is the quickest way to see how far a load got.
|
|
|
|
Then, in the browser:
|
|
|
|
- **`/examplegame/status`** renders your page, with a **World** row in the public
|
|
header pointing at it. That row is now an ordinary nav row: an operator can
|
|
reorder it, relabel it or hide it from the nav editor exactly as they can core's.
|
|
- **`/api/v1/public/world/status`** answers JSON.
|
|
- **`/api/v1/public/modules`** lists you, with the `capabilities` array from your
|
|
`module.json`. This is how a client — core's SPA, the Android app, anything —
|
|
feature-detects you.
|
|
- **`/api/docs`** shows your route under its own tag, merged out of the OpenAPI
|
|
fragment you committed. (`/api/docs.json` is the raw merged document, if you
|
|
would rather grep it.)
|
|
- **Admin → Modules** shows you as `started`.
|
|
|
|
Open the browser console while you are there. Your entry logs the core API version
|
|
it registered against, and any complaint the client half has to make will be sitting
|
|
next to it.
|
|
|
|
## The state your module is in
|
|
|
|
Core keeps one row per module and its `state` column has five values. Four are
|
|
outcomes and one is an operator's decision:
|
|
|
|
| State | Means |
|
|
| --- | --- |
|
|
| `installed` | Files are on the volume; the row was just created. |
|
|
| `enabled` | Cleared for this boot to try. Every non-disabled row is reset to this at each boot. |
|
|
| `started` | Loaded, registered, schema replayed, `onBoot` returned. This is the one you want. |
|
|
| `startup_failed` | Something went wrong; the panel shows the stage and the reason. The site came up anyway. |
|
|
| `disabled` | An operator switched you off. Nothing else — not a failure, not a reinstall — moves this. |
|
|
|
|
The important half of that table is what it implies: **a module that fails to
|
|
load never takes the site down.** Core try/catches your entire lifecycle, records
|
|
where you broke, and serves everything else. You are debugging from an admin
|
|
screen, not from a stack trace in a crash loop.
|
|
|
|
**A retry is a restart.** Every boot resets non-disabled rows to `enabled` and
|
|
writes that boot's outcome, so the panel always describes the run you are looking
|
|
at rather than a run from last week.
|
|
|
|
## The four ways it fails
|
|
|
|
When something is wrong, the shape of the failure tells you where to look before
|
|
you read a single message.
|
|
|
|
**1. Your module is not in the panel at all.** The loader never saw a directory
|
|
worth scanning. It is a symlink; or it is in the wrong place; or it has no
|
|
`module.json` at the top of it. Note the bundle shape here — a release tarball's
|
|
top-level directory is `<name>-<version>`, so an unpacked bundle copied wholesale
|
|
leaves core looking at a directory with nothing in it but another directory.
|
|
|
|
**2. It is `startup_failed`, and your routes and nav are simply absent.** The
|
|
failure happened before anything was mounted: a malformed `module.json`, an
|
|
unsatisfiable `coreApi`, a prefix that collides with core's, a schema fragment
|
|
breaking a rule. Nothing of yours is on the URL surface, so nothing of yours can
|
|
half-work.
|
|
|
|
**3. It is `startup_failed`, and your routes answer `503`.** The failure happened
|
|
after mounting — the database rejected a statement in your fragment, or your
|
|
`onBoot` threw. Your routes stay mounted deliberately: the URL surface is a
|
|
property of what is installed, not of whether a boot hook succeeded on this
|
|
machine. A module that failed to warm up says it is down; it does not serve half
|
|
its data.
|
|
|
|
**4. It answers `404` everywhere.** Someone disabled you. Same mechanism — mounted
|
|
and guarded, never unmounted.
|
|
|
|
The panel names the stage each failure happened in, and the stages are the
|
|
loader's own validation steps, listed in [`MODULE_API.md`][api] §4.3 and §4.4. Read
|
|
the stage first; it is usually enough. Core logs the same thing at boot —
|
|
`module "…" failed to load — continuing without it {"stage":…,"reason":…}` — so you
|
|
do not need the panel to debug this.
|
|
|
|
**In all four cases you disappear from `/api/v1/public/modules`.** That endpoint
|
|
answers what this backend is *serving*, so a client feature-detecting your
|
|
capability renders a site without it rather than one advertising something that
|
|
`503`s. It is also a quick check with no login: if you are not in that list, you
|
|
are not running, whatever the page looks like.
|
|
|
|
## What to do next
|
|
|
|
You have a module. Now break it on purpose, once each, and watch what the panel
|
|
says:
|
|
|
|
- Add a prefix to `module.json`'s `mounts` and do not register it. → stage
|
|
`register`, *"declared public/extra but never registered it"*. What you declared
|
|
and what you registered must match, in both directions.
|
|
- Rename one of your tables so it no longer starts with your id. → stage `schema`,
|
|
at **load** time, before anything is mounted: your routes answer `404`.
|
|
- Throw inside `onBoot`. → after mounting, so the same route answers `503` with
|
|
*"Module unavailable"* instead of vanishing.
|
|
|
|
Those are the three outcomes above, and the messages are what this core actually
|
|
prints for them — they were run to write this paragraph rather than predicted.
|
|
|
|
Twenty minutes of that is worth more than any chapter, because every one of those
|
|
failures is one you will cause accidentally later, and you will recognise it.
|
|
|
|
Then read [chapter 2](02-website-module.md), which is the same module explained —
|
|
what `ctx` hands you and why it is handed rather than imported, what each
|
|
`register*` call is for, why the client half is built the way it is, and what a
|
|
module must never do.
|
|
|
|
[api]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/website/MODULE_API.md
|
|
[issues]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit/issues
|
|
[renamecheck]: ../scripts/checkRenameSites.js
|