Files
Integration-kit/book/01-first-module.md
wtclaude 5b3f5a5d54
Some checks failed
PR Checks / template (pull_request) Failing after 12s
PR Checks / prose (pull_request) Successful in 14s
docs(kit): pin core 1.17.0; teach 1.13.0 to 1.17.0 and one module per site
The pin moves from 0a37a44 (1.12.0) to 6890a74 (1.17.0, website#220),
past 1.13.0 to 1.16.0, which reached main in the same cutover. The
chapters were read again for all five versions:

- Chapter 5: a lease's example, an option row's note, a param's
  duration (1.13.0) and multiple (1.14.0), a retry's retryAfterMs
  (1.15.0), a dry run's warnings (1.16.0), and a new section on
  templates (1.13.0). "What to build, in order" gains a template as
  step 8.
- Chapter 2: a new section on the identity-link hooks and
  ctx.identities.list (1.17.0). The call count is corrected to
  nineteen; it said twelve, which was already stale at 1.10.0.
- Two core changes in that cutover carried no version bump, and both are
  taught. Chapter 1 now says a site runs one game module and the loader
  refuses the others, since a reader copying the template onto a UO or
  Rust core is the one it surprises. Chapter 5 now covers a resource's
  `until`, which reconcile reads.

The template declares none of the new members, so nothing it does
changed meaning. template/module.json's coreApi becomes ^1.17.0 for the
equality check.

Checks run at the new pin:
- checkCoreApi, links, rename sites and chapter paths all pass, and so
  do the script tests (10 and 11).
- The template's imports, build, externals and swagger checks pass.
- Tests pass: server 82, client 20.
- The template was loaded as `examplegame` into a real core of this code
  through core's own loader, and it registered. Loaded again beside
  module-uo, examplegame started and uo was refused with the reason
  chapter 1 quotes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-23 02:01:26 -05:00

14 KiB

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.


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

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:

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) 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 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

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.
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.

Three 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.
  • A site runs one game module. If the modules directory already holds one, an Ultima Online or Rust site's for example, only the directory whose name sorts first starts. Every other is refused before any of its code is read, and its reason on Admin → Modules names both modules. examplegame sorts ahead of rust and uo, so on such a core yours starts and the site's own module does not. Try your module on a core of your own, with nothing else in its modules directory. This is MODULE_SYSTEM.md §2.5; the installer refuses a second module too, so the rule only surprises someone copying directories by hand.

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,/clans"}
INFO  [modules] registered module "examplegame" v0.1.0 {"mounts":{"public":["/world","/clans"]}}
INFO  [modules] schema ensured for module "examplegame" {"statements":4}
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.
  • /examplegame/clans lists the two clans the template seeds at boot, and one of them renders at /examplegame/clans/clan-1 — the page that declares three places for core to fill. On a core with Teams those hold the activity feed, the forum and the notification control; on one without, they render nothing and the page is exactly as complete. Both are correct outcomes and neither logs anything.
  • /api/v1/public/world/status answers JSON, and so does /api/v1/public/clans.
  • /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 §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 503s. 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, 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.