Skip to content

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.

One repository, both halves, versioned together:

module.json id, version, coreApi, mounts, extensions
server/index.js the entry point — exports register(ctx, api)
server/db/schema.sql idempotent fragment, replayed every boot
server/db/purge.sql destructive; only ever run by an explicit purge
server/router/ routers and controllers
server/model/ *.model.js (logic) + *.db.js (SQL) pairs
client/src/entry.jsx registers routes, nav, providers
client/src/shim/ the shared-dependency shims — see below
client/dist/entry.js PREBUILT chunk, published by your CI

client/dist/ is committed by your release, not by hand — the operator never builds, so the built chunk has to be in the bundle.

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.

Zero require/import may reach outside your own directory. Not “few”. Zero.

Terminal window
npm run check:imports --prefix server

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

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 empty

Alias 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, not load. load is first-wins, so an earlier plugin returning the module’s contents means the guard is never called. Written against load, 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:

Terminal window
npm run build --prefix client # build BEFORE the tests — two of them read the chunk
npm run check:externals --prefix client
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.