Building a module
Start from the Integration Kit’s template rather than an empty directory. This page explains what the template is doing and why, so that when you change something you know what you are changing.
The layout
Section titled “The layout”One repository, both halves, versioned together:
module.json id, version, coreApi, mounts, extensionsserver/index.js the entry point — exports register(ctx, api)server/db/schema.sql idempotent fragment, replayed every bootserver/db/purge.sql destructive; only ever run by an explicit purgeserver/router/ routers and controllersserver/model/ *.model.js (logic) + *.db.js (SQL) pairsclient/src/entry.jsx registers routes, nav, providersclient/src/shim/ the shared-dependency shims — see belowclient/dist/entry.js PREBUILT chunk, published by your CIclient/dist/ is committed by your release, not by hand — the operator never builds,
so the built chunk has to be in the bundle.
The server half
Section titled “The server half”server/index.js exports one function, called once during core’s require phase:
module.exports = function register(ctx, api) { const log = ctx.log('examplegame')
api.registerRoutes({ public: { '/world': worldRouter(ctx) }, })
api.onBoot(async (ctx) => { // anything that needs a live database goes HERE, not above })}Follow core’s own layering — router → controller → model → db, with .model.js (logic)
and .db.js (SQL) pairs, and raw parameterised queries. There is no ORM anywhere in this
project, and a module that introduces one is a module nobody else can read.
The rule CI enforces
Section titled “The rule CI enforces”Zero require/import may reach outside your own directory. Not “few”. Zero.
npm run check:imports --prefix serverIf you need something from core that ctx does not offer, that is a gap in the contract —
raise it, so the surface grows deliberately. Reaching into core’s internals is how a module
breaks on a refactor it had no part in.
The client half
Section titled “The client half”Your chunk is built with Vite in library mode, emitting one unhashed dist/entry.js.
Unhashed deliberately: module.json names that file, and a hashed name would have to be
discovered at runtime. Core answers the caching question instead, serving it no-cache.
Then three things about the bundling, each of which has already cost somebody a day.
1. Aliases replace external — they do not accompany it
Section titled “1. Aliases replace external — they do not accompany it”This is the one that looks most like it should work.
Rollup asks external before Vite’s alias resolver runs, so a specifier listed there is
marked external and never aliased. The chunk then ships bare import 'react'
specifiers, which a browser cannot resolve without an import map — and an import map has to
be inline, which script-src 'self' forbids.
The first real module shipped with both, built cleanly, and emitted exactly that chunk.
rollupOptions: { external: [] }, // deliberately emptyAlias only. Nothing in external. (output.globals does not rescue this either — it covers
iife/umd and does nothing for an ES module.)
2. Use the array form of resolve.alias, with anchored regexes
Section titled “2. Use the array form of resolve.alias, with anchored regexes”Vite’s object form does prefix matching, so a react key also rewrites
react/jsx-runtime — silently, to the wrong shim. The chunk then fails at its first element
with a message about jsx not being a function, which points nowhere near the cause.
alias: SHARED.map(({ specifier, shim }) => ({ find: new RegExp(`^${escape(specifier)}$`), replacement: shim,}))^react$ and ^react/jsx-runtime$ cannot collide.
3. Assert at resolution time, not by grepping the output
Section titled “3. Assert at resolution time, not by grepping the output”The risk is a missed alias welding a second React into your chunk. That loads fine and then throws about an invalid hook call somewhere unrelated.
The template fails the build if any shared package resolves into node_modules. Two details
of how it does that are not interchangeable:
- It hooks
transform, notload.loadis first-wins, so an earlier plugin returning the module’s contents means the guard is never called. Written againstload, it sat in the build doing nothing while a deliberately-broken alias produced a green build with react-router welded in. - The list of packages that may not be bundled is stated independently of the alias list. Deriving one from the other means deleting an alias also deletes the guard against what that alias prevented.
Verify with:
npm run build --prefix client # build BEFORE the tests — two of them read the chunknpm run check:externals --prefix clientRegistering the client half
Section titled “Registering the client half”const { registry } = window.__rg
registry.registerRoutes(ID, { public: [{ path: 'world', element: <WorldStatus /> }], admin: [{ path: 'link', element: <Admin /> }],})registry.registerNav(ID, { … })Paths are relative to your module’s segment — path: 'link' under admin becomes
/admin/<id>/link. Check window.__rg.version against your coreApi range and refuse to
register on a mismatch.
Testing and release covers CI, the checks, and publishing the bundle and its manifest.