Files
Integration-kit/book/01-first-module.md
wtclaude f8f7014d53
All checks were successful
PR Checks / prose (pull_request) Successful in -38s
PR Checks / template (pull_request) Successful in 29s
fix(kit): everything the acceptance run found — Phase 5 slice 3
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>
2026-08-12 14:40:02 -05:00

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