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>
270 lines
13 KiB
Markdown
270 lines
13 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
|
|
```
|
|
|
|
**Run every check on the untouched copy before you change a line.** Jump ahead to
|
|
*Build it* and run all of it — the tests, the build, the three guards — on the
|
|
template exactly as it arrived:
|
|
|
|
```bash
|
|
npm ci --prefix server && npm test --prefix server
|
|
npm run check:imports --prefix server
|
|
npm run check:swagger --prefix server
|
|
npm ci --prefix client && npm run build --prefix client
|
|
npm run check:externals --prefix client && npm test --prefix client
|
|
```
|
|
|
|
It takes two minutes and it buys you a **baseline**. Every one of those commands
|
|
is green on a pristine template, so from here on a red one is something you did —
|
|
and you will know which edit did it, because you were green a moment ago. Without
|
|
that, the first failure is ambiguous forever: is this my mistake, or was the
|
|
template already like this?
|
|
|
|
That is not a hypothetical. The kit's own acceptance run
|
|
([`kit-acceptance.md`][acceptance]) found `check:swagger` failing on an untouched
|
|
copy on Windows, with a message that blamed the reader's routes. It is fixed, and
|
|
the reason the run *found* it rather than being derailed by it is that it had a
|
|
baseline.
|
|
|
|
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
|
|
[acceptance]: https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/modules/kit-acceptance.md
|