feat(template): a module that builds and loads — Phase 5 slice 1
All checks were successful
PR Checks / prose (pull_request) Successful in 7s
PR Checks / template (pull_request) Successful in 27s

The kit's `template/`: a complete, minimal Runic Gateway module a reader copies,
renames, and runs before reading a chapter. Slice 0 landed the workflow that runs
it; this is the tree that workflow was written against, so the `template` job
arms itself with no edit to the guard.

Installed into a real core it adds one public page at `/examplegame/status`, a nav
row pointing at it, one API route described in an OpenAPI fragment core merges,
one table created by an idempotent schema fragment and dropped by a purge file,
and both lifecycle hooks. That is deliberately less than a real module does; what
it is complete about is the shape — every seam used once, with the reasoning next
to it.

Four decisions, settled with the org lead:

1. **Public tier only, plus the lifecycle hooks.** §2.11.1 d1's "one public route",
   plus enough to show the whole vertical seam once. Admin and player tiers become
   worked examples quoted from module-uo in chapter 2 rather than two thirds of a
   tree the reader deletes on day one.
2. **The release workflow ships as a file, in BOTH flavours** — `.gitea/` and
   `.github/`. Neither runs where it sits (a workflow is only read from a
   repository root) and each arms itself when the reader's copy is its own repo.
   Packaging is the part of a module that cannot be guessed at, and the kit's
   audience is outside this org, so assuming Gitea would have been assuming our
   own deployment. Core installs from a URL and does not care where the release
   lives — only that the host is on the operator's `MODULE_SOURCE_HOSTS`.
3. **A rename checklist that CI verifies**, not a rename script. `template/README.md`
   carries the table; `scripts/checkRenameSites.js` holds it against the tree in
   both directions — an unlisted file that still carries the placeholder fails, and
   so does a listed file that no longer does. The second half is the one usually
   left out and the more valuable: a row that has stopped matching reads as
   instructions to edit something that is not there. Same rule core's identifier
   check follows about its own exemptions. It has its own ten-test suite, run by
   CI as `node --test`, because a check that has never been shown to fail is a
   check nobody knows the state of.
4. **A neutral invented game.** One deviation from the literal answer, forced by
   decision 3: the id is `examplegame`, not `example`. The checklist check is a
   text search, and `example` occurs in ordinary English ("for example") all over
   prose that is not a rename site — a placeholder that cannot occur by accident is
   what makes the check answerable instead of a source of false alarms someone
   learns to ignore.

**The pin moves to the 1.4.0 bump** (website `edge` 1b692bf), which is what
`template/module.json` declares as `coreApi`. Slice 0 pinned its parent, before
1.4.0 existed, so `checkCoreApi.js` arms for the first time here — it asserts
EQUALITY, and its failing on the next contract bump is the system working.

Also in CI: the client tests now run AFTER the build (two of them read the built
chunk and skip without one — run first, the job reports green while asking nothing
about the artifact that ships), and `check:swagger` verifies the committed
fragment is current.

## The finding: an UPDATE that changes nothing does not touch ON UPDATE CURRENT_TIMESTAMP

Every suite passed, both guards passed, the chunk built, the module loaded into a
real core and the page rendered correctly. Two hours later the same page said the
world was offline, and it was wrong.

`updated_at` was declared `ON UPDATE CURRENT_TIMESTAMP`, and MariaDB fires that
only when an UPDATE actually CHANGES a value. The boot refresh writes the same
numbers every thirty seconds — which is exactly what a quiet game looks like — so
the timestamp froze at the first write, the row crossed the freshness window, and
the model correctly reported a stale row as offline. Verified against the live
database: two hours of refreshes, `updated_at` still the boot timestamp.

No test in this repo could see it. The model takes its clock as an argument, and
nothing in a suite runs the same UPDATE twice against a real database. It is only
visible as a page that was right when you looked at it and wrong an hour later.

The writer now sets `updated_at = CURRENT_TIMESTAMP` explicitly and the column
drops the clause that was not doing what it looked like it was doing; both carry
the reasoning. Re-verified end to end: the timestamp advances every interval and
the API reports fresh.

Falling out of the fix, the schema fragment gained the rule the reader hits next:
**changing a table is an ALTER, never an edit to its CREATE** — `CREATE TABLE IF
NOT EXISTS` does nothing when the table exists, so an edited column definition
takes effect on a fresh install and on no existing one, which is the worst
possible split because your development database is usually the fresh one.

## Verified

- 29 server tests, 18 client tests, 10 kit-script tests; `check:imports`,
  `check:externals`, `check:swagger` and `checkCoreApi` all green, run in CI's own
  order from a clean `npm ci`.
- Browser smoke (MODULE_API.md §7.7) against a real core built from the pinned
  ref: module `started`, published on `/api/v1/public/modules`, chunk served
  `no-cache` with the right MIME from the entry's directory while `module.json`
  and the server source 404, script tag injected after core's bundle, the page
  rendering inside core's own chrome, the nav row interleaved into the public
  header between Wiki and About, SPA navigation into it from another page, the
  module's path and schema and tag merged into `/api/docs.json`, and
  `[examplegame] registered against core API 1.4.0` in the console with no CSP
  report and no React error.

Refs: MODULE_SYSTEM.md §2.11.1 (slice 1), MODULE_API.md §2.x, §3.x, §5.1, §7.7.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-12 12:56:32 -05:00
parent 1156a89509
commit 1ed617736e
45 changed files with 7289 additions and 12 deletions

View File

@@ -0,0 +1,34 @@
// ── This module's own API bindings ────────────────────────────────────────
//
// Core hands out the request PRIMITIVE and nothing above it (MODULE_API.md
// §3.5): same-origin `/api/v1`, cookies included, JSON in and out, and an
// `ApiError` thrown on any non-2xx. The paths are yours, because the routes at
// the other end are yours — `server/router/**` in this repo serves them.
//
// **Do not build your own fetch wrapper.** The primitive is what carries the
// session cookie, the CSRF handling and the error shape core's `ErrorState`
// knows how to render. A module that calls `fetch` directly gets none of that
// and finds out one page at a time.
//
// Keeping the bindings in one file, ordered the way the routers are, is
// convention rather than contract — but the two halves of every call live in
// different directories and nothing checks them against each other, so anything
// that makes a mismatch easy to see is worth doing.
import rg from './core.js'
const { request: req, BASE } = rg.api
// ── public ────────────────────────────────────────────────────────────────
// Token-free, same-origin reads. Paths are relative to `/api/v1`, so this hits
// `/api/v1/public/world/status` — the route `server/router/public/world.router.js`
// registers under the `/world` prefix `module.json` declares.
export const world = {
status: () => req('/public/world/status'),
}
// Exported for the rare caller that needs the base itself — an `<img src>`, a
// download link, an EventSource. Reach for `request` first.
export { BASE }
export default { world, BASE }

View File

@@ -0,0 +1,77 @@
// ── What core hands this module, on the client side ────────────────────────
//
// The client twin of `server/core.js`, and deliberately much simpler than it.
// Every page imports its layout, its state components and its hooks from here,
// so the boundary is one file. The normative contract is MODULE_API.md §3.2 and
// §3.4.
//
// **Why this is a plain read and the server's is a lazy accessor.** On the
// server, `ctx` arrives at `register(ctx)` — after every `require` has already
// run — so `server/core.js` has to defer resolution to call time or a router
// would capture `undefined` at file scope. There is no such gap here.
// `window.__rg` is published by core's own bundle (client/src/modules/shared.js),
// and every module chunk is a deferred script the server injects *after* that
// bundle's tag, so by the time the first line of this file executes the global
// is already there. Reading it once, at module scope, is safe — and it means a
// component keeps the ordinary `import { PageHeader } from '…'` shape rather
// than being wrapped in an accessor that would cost it its identity.
//
// The absent-global case is handled by `shim/rg.js`, which every shim beside it
// also goes through — the shims touch the global before this file does, so a
// check here would be unreachable.
import { createElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Link } from 'react-router-dom'
import { rg as shared } from './shim/rg.js'
const rg = shared()
// ── The shared-dependency self-check ───────────────────────────────────────
//
// Keep this. There are two BUILD guards on the same rule — `assertSharedNotBundled`
// in vite.config.js at resolution time, and `scripts/checkExternals.js` on the
// finished artifact — and both reason about the chunk in isolation. Neither can
// see the one failure that only exists once the chunk meets a core: a
// `window.__rg` whose React is not the React that rendered the page.
//
// Identity is the only question worth asking. A second React satisfies every
// type check, renders its first element happily, and then throws about an invalid
// hook call somewhere unrelated — in a component that has nothing to do with it.
if (createElement !== rg.react.createElement || createRoot !== rg.reactDom.createRoot || Link !== rg.router.Link) {
console.error(
'[examplegame] the bindings this chunk imported are not the ones core published — it has bundled ' +
'its own copy of a shared dependency. Check the aliases in vite.config.js (MODULE_API.md §3.6).',
)
}
// The curated kit (§3.4). Seven members, and it is CLOSED: layout, headings, the
// three data-page states, the fetch hook, and read-only access to the session and
// the site's settings. Anything else your pages need — tables, tabs, an editor —
// you bundle yourself, in a `components/` directory of your own.
//
// Closed is a real constraint and it is the price of the boundary being worth
// anything: adding a member is a minor `MODULE_API_VERSION` bump, and changing a
// kit component's props is a major one. Use them, though. A module page that
// ships its own layout is a page that stops looking like the site it is installed
// in, and drifts further every time core changes.
export const {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
} = rg.ui
// The registry, for entry.jsx. Everything else here is read by pages.
export const registry = rg.registry
// The core API version this module was loaded against. Logged by entry.jsx —
// `module.json`'s `coreApi` range is checked by the loader before this file is
// ever served, so there is nothing to re-check, only something to report.
export const coreApiVersion = rg.version
export default rg

View File

@@ -0,0 +1,79 @@
// ── The client entry point ────────────────────────────────────────────────
//
// Core serves `dist/entry.js` from your module's directory and injects it into
// its own HTML as a same-origin `<script type="module" src>` before `</body>`.
// This file registers what the module has; core renders it. Normative:
// MODULE_API.md §3.3.
//
// **Registration is synchronous and happens at evaluation time.** Module scripts
// are deferred, so this runs after core's bundle — which is where `window.__rg`
// is published — and before core's first render. There is no subscription and no
// late registration: 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.
//
// So everything below is a plain top-level call and every page is a STATIC
// import. Lazy-loading the routes is the natural instinct for a chunk that grows,
// and it is the one thing this seam cannot have.
import { registry, coreApiVersion } from './core.js'
import WorldStatus from './routes/public/WorldStatus.jsx'
// Your module id, exactly as `module.json` spells it. Core keys the registry by
// it and prefixes every route path with it.
const ID = 'examplegame'
// ── Routes ────────────────────────────────────────────────────────────────
//
// Paths are relative to your module's namespace and core prefixes them. Whatever
// you write here, a public route lands at `/<id>/<path>`, an admin route at
// `/admin/<id>/<path>` and a player route at `/player/<id>/<path>`. You cannot
// write the segment your routes hang under, which is the point: two modules
// installed side by side cannot collide, and an operator can see from a URL which
// module served it.
//
// So this one page is at `/examplegame/status`.
//
// **Note what is NOT here: an auth wrapper.** `gate: { roles: [...] }` is
// available and core applies it as its own `RoleGate`; supplying your own is not
// possible, because the sidebar and the route table have to agree about who may
// see what, and they only do if one thing decides.
registry.registerRoutes(ID, {
public: [
{ path: 'status', element: <WorldStatus /> },
],
})
// ── Nav ───────────────────────────────────────────────────────────────────
//
// A registered row is an ORDINARY row from here on. It interleaves into core's
// own navigation, and an operator can reorder it, relabel it or hide it from the
// admin nav editor exactly as they can core's — because the interleave happens
// before the override merge, and the override layer is keyed by `to`.
//
// Three fields worth knowing before you need them:
//
// • `order` places the row among core's, which are keyed by their index. A row
// with NO order appends after them, rather than defaulting to 0 — otherwise
// "I didn't ask for a position" would mean "put me first".
// • `group` (admin sidebar) names an existing core group; an unknown name
// appends a new group at the end rather than dropping the row.
// • `icon` is a component, and core supplies no fallback. Public header rows
// carry no icons, so there is none here — but an admin or player row without
// one is the only row in its sidebar with no glyph, which reads as breakage.
// Match the nav you are landing in: the admin sidebar draws at 18px with a
// 1.6 stroke, the player portal at 16px with a 2.
registry.registerNav(ID, {
area: 'public',
items: [
{ label: 'World', to: '/examplegame/status' },
],
})
// `module.json`'s `coreApi` range was checked by the loader before this file was
// ever served, so there is nothing to re-check here. Log it anyway: a mismatch
// between the core that validated your manifest and the core that published this
// global is otherwise invisible from the browser, which is where the client half
// actually fails.
console.info(`[${ID}] registered against core API ${coreApiVersion}`)

View File

@@ -0,0 +1,65 @@
// ── The one page ──────────────────────────────────────────────────────────
//
// An ordinary React component. Nothing about being inside a module changes how
// you write one — the only differences are where React comes from (core, via the
// aliases in vite.config.js, so the import below looks completely normal and is
// not) and where the chrome comes from (`../../core.js`, the seven-member kit).
//
// **Render `PublicLayout` yourself.** Core wraps your public routes in its
// maintenance gate and nothing else, so a page that omits the layout renders
// bare — no header, no footer, no site chrome — which looks like a bug and is
// the contract (§3.3). Admin and player routes are the other way round: core
// wraps those in their layouts for you.
import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.js'
import api from '../../api.js'
// A relative time that does not need a date library. `Intl.RelativeTimeFormat`
// is in every browser core supports, and one fewer dependency in the chunk is
// one fewer thing an operator ships.
const RELATIVE = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function ago(iso) {
if (!iso) return 'never'
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000)
const [unit, size] = Math.abs(seconds) < 3600 ? ['minute', 60] : ['hour', 3600]
return RELATIVE.format(Math.round(seconds / size), unit)
}
export default function WorldStatus() {
// `useAsync` is core's fetch/loading/error hook, and the three components
// below are its three states. Using them rather than rolling your own is what
// makes a module page indistinguishable from a core one while it loads and
// while it fails.
const { data, loading, error } = useAsync(() => api.world.status(), [])
return (
<PublicLayout>
<PageHeader
title="World status"
subtitle="What the game server last told us about itself"
/>
{loading && <Loading />}
{error && <ErrorState error={error} />}
{data && (
<div style={{ display: 'grid', gap: '0.75rem', maxWidth: '32rem' }}>
<p>
<strong>{data.worldName || 'The world'}</strong> is{' '}
{data.online ? 'online' : 'offline'}
{data.online && data.players > 0 ? ` with ${data.players} playing` : ''}.
</p>
<p style={{ opacity: 0.7 }}>
Last reported {ago(data.updatedAt)}
{/* `stale` is a first-class part of the answer rather than something
the page infers from a timestamp. The server decides what counts
as stale, because the server is what knows how often the game is
supposed to check in. */}
{data.stale ? ' — this is out of date, so the world is shown as offline.' : '.'}
</p>
</div>
)}
</PublicLayout>
)
}

View File

@@ -0,0 +1,16 @@
// `react/jsx-runtime`, from core.
//
// Every .jsx file this module compiles becomes imports from `react/jsx-runtime`
// under the automatic runtime, which is the default the tooling assumes. Those
// have to resolve to CORE's React like every other import — a second jsx runtime
// bound to a second React is the same one-React violation as bundling `react`
// itself, only harder to see, because it shows up as a hook dispatcher error in
// a component that looks fine.
import { rg } from './rg.js'
const jsxRuntime = rg().jsxRuntime
export const { jsx, jsxs, jsxDEV, Fragment } = jsxRuntime
export default jsxRuntime.default ?? jsxRuntime

14
template/client/src/shim/react-dom.js vendored Normal file
View File

@@ -0,0 +1,14 @@
// `react-dom/client`, from core.
//
// A module never calls `createRoot` — core owns the root and the module renders
// inside it. This exists because a transitive import can still reach for
// react-dom, and one that resolved to a bundled copy would put a second
// renderer in the page.
import { rg } from './rg.js'
const reactDom = rg().reactDom
export default reactDom.default ?? reactDom
export const { createRoot, hydrateRoot, flushSync, createPortal } = reactDom

View File

@@ -0,0 +1,32 @@
// `react-router-dom`, from core.
//
// The sharpest of the four, because router state is not just a library — it is
// one live navigation context. A module with its own copy would get a router
// whose `useParams` returns nothing and whose `<Link>` navigates the browser
// instead of the SPA, on a page that otherwise renders perfectly.
import { rg } from './rg.js'
const router = rg().router
export default router.default ?? router
export const {
BrowserRouter,
Link,
NavLink,
Navigate,
Outlet,
Route,
Routes,
createSearchParams,
generatePath,
matchPath,
useLocation,
useMatch,
useNavigate,
useOutletContext,
useParams,
useResolvedPath,
useSearchParams,
} = router

50
template/client/src/shim/react.js vendored Normal file
View File

@@ -0,0 +1,50 @@
// The shared React, taken from core rather than bundled.
//
// Why a shim file exists at all (MODULE_API.md §3.6, and the spike proved it the
// hard way): Rollup's `external` alone emits a bare `import 'react'` into the
// chunk, which the browser cannot resolve without an import map — and an import
// map has to be an inline `<script type="importmap">`, which core's
// `script-src 'self'` forbids. `output.globals` does not help either; it is
// iife/umd only, and this is an ES module. So each shared dependency is aliased
// to a two-line module that re-exports from the global core published before any
// module chunk evaluated.
//
// The named re-exports are not decoration: `import { useState } from 'react'`
// compiles to a named import, and a module with only a default export would fail
// at link time in the browser with a message about the binding, not about this.
import { rg } from './rg.js'
const react = rg().react
export default react.default ?? react
export const {
Children,
Component,
Fragment,
StrictMode,
Suspense,
cloneElement,
createContext,
createElement,
forwardRef,
isValidElement,
lazy,
memo,
useCallback,
useContext,
useDebugValue,
useDeferredValue,
useEffect,
useId,
useImperativeHandle,
useInsertionEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
useSyncExternalStore,
useTransition,
} = react

View File

@@ -0,0 +1,29 @@
// The one place this module reads `window.__rg`, and the one place that says
// something useful when it is not there.
//
// Every shim beside this file, and `src/core.js`, go through here. That is not
// tidiness — it removes an ordering dependency that was genuinely fragile. ES
// modules evaluate dependencies in the source order of their import statements,
// so "put the friendly check in the file that is imported first" is a guarantee
// that survives exactly until someone sorts the imports. Whichever module the
// bundler happens to reach first, it reaches `window.__rg` through this.
//
// A missing global means core did not publish its shared dependencies before
// this chunk evaluated: an injection or ordering fault in CORE (MODULE_API.md
// §3.1), not a fault in this module. Without this, the first symptom is
// "Cannot read properties of undefined (reading 'react')" thrown from a file
// called react.js, which reads like the module bundled React wrong — the
// opposite of what happened.
export function rg() {
const shared = window.__rg
if (!shared) {
throw new Error(
'[examplegame] window.__rg is missing — core did not publish its shared dependencies before this ' +
'chunk evaluated. That is an injection or ordering fault in core (MODULE_API.md §3.1), not a ' +
'fault in this module.',
)
}
return shared
}
export default rg