All checks were successful
PR checks / checks (pull_request) Successful in 1m13s
Twenty pages completing the tree section 10 planned: Modules (8), Architecture
(5) and Reference (7). Four decisions, D38-D41, recorded in PLAN.md section 10.
D39 is the one that shaped the phase. Section 1 forbids re-specifying a
contract, and a Reference section is exactly where that rule is most tempting to
break, so the line is drawn at names: every environment variable, config key,
installer command, visibility rung and canonical document is listed with one
terse line saying what it is FOR, while shapes, semantics and every "why" stay
in the canonical document.
That is only safe because the names are checked. checkReference.mjs compares six
enumerations against the repositories that own them, over the Gitea API, as set
comparisons in BOTH directions -- and the second direction is the one that earns
its keep, because a reference page does not usually rot by describing something
that vanished, it rots by quietly not mentioning what was added since.
The check went green on its first run, which is the least trustworthy possible
outcome, so it was verified by breaking it: seven mutations, all caught. The one
worth keeping is the visibility ladder REORDERED with its membership unchanged
-- it is a security boundary, and a set comparison alone would have passed it.
D41 turns plannedSidebar from a checklist into a checked invariant, and finding
out why was the phase's first defect: it had already drifted, because phase 7
added the Content page under D37 and never updated the list. Nothing failed,
because nothing read it. checkSidebar.mjs now asserts the two trees agree on
groups, labels and order -- order because the order of Getting started IS the
installation path.
Two more things the writing found. PLAN.md's page count was wrong and had been
since section 10 was written ("roughly 38, 37 planned" for a tree of forty).
And module.json's `mounts` and the SPA's paths are different mechanisms that no
single document stated plainly -- module-uo declares admin: ["/shard",
"/uo-link"] while its screen lives at /admin/uo/link, because API routes are
deliberately NOT namespaced while SPA routes are. That is precisely the
distinction the installer got wrong in v0.1.0, and it now has a named home.
D40: the docs link to /architecture/'s drawn diagrams rather than importing
them. Those components carry marketing chrome and depend on diagram.css, which
Starlight does not load; the docs use text diagrams, which paste into an issue.
npm run verify green: 40 pages across 5 groups agree with plannedSidebar, 2390
internal links resolve, 123 repository links point at a branch, 19 facts, 59
quickstart checks, 22 reference enumerations, astro check 0 errors, 36 tests.
Co-Authored-By: Claude <noreply@anthropic.com>
162 lines
6.1 KiB
Plaintext
162 lines
6.1 KiB
Plaintext
---
|
|
title: Building a module
|
|
description: The repository layout, the server half, and the client build — including the three things about bundling that everyone gets wrong once.
|
|
---
|
|
|
|
import { Aside } from '@astrojs/starlight/components';
|
|
|
|
Start from [the Integration Kit's template](/docs/modules/the-integration-kit/) 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
|
|
|
|
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.
|
|
|
|
## The server half
|
|
|
|
`server/index.js` exports one function, called once during core's require phase:
|
|
|
|
```js
|
|
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
|
|
|
|
**Zero `require`/`import` may reach outside your own directory.** Not "few". Zero.
|
|
|
|
```bash
|
|
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.
|
|
|
|
## 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
|
|
|
|
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.
|
|
|
|
```js
|
|
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
|
|
|
|
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.
|
|
|
|
```js
|
|
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
|
|
|
|
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.
|
|
|
|
<Aside type="caution" title="Why shims rather than plain externals">
|
|
Each shared dependency is aliased to a two-line module re-exporting from `window.__rg`.
|
|
|
|
The **named** re-exports matter: `import { useState } from 'react'` compiles to a named
|
|
import, and a shim with only a default export fails at link time in the browser with a
|
|
message about the binding — not about the shim.
|
|
|
|
Route every shim through one file that reads `window.__rg` and throws a useful error when
|
|
it is missing. Otherwise the first symptom of a core ordering fault is
|
|
`Cannot read properties of undefined (reading 'react')` thrown from a file called
|
|
`react.js`, which reads like *your* bundling is wrong when it is the opposite.
|
|
</Aside>
|
|
|
|
Verify with:
|
|
|
|
```bash
|
|
npm run build --prefix client # build BEFORE the tests — two of them read the chunk
|
|
npm run check:externals --prefix client
|
|
```
|
|
|
|
## Registering the client half
|
|
|
|
```js
|
|
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.
|
|
|
|
## Then
|
|
|
|
[Testing and release](/docs/modules/testing-and-release/) covers CI, the checks, and
|
|
publishing the bundle and its manifest.
|