Merge pull request 'docs(website): Phase 2 PR 7 — the client chunk's delivery contract' (#132) from docs/module-client-registry into main

Reviewed-on: #132
This commit is contained in:
2026-08-11 04:01:28 +00:00
3 changed files with 121 additions and 18 deletions

View File

@@ -735,9 +735,16 @@ slashes from generated path keys — see *Regenerating the spec* in the website
domain split makes that necessary.
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the
internal listener. The SPA catch-all, `/uploads` and `/brand` are filesystem-conditional static
mounts — not API contract, and including them would make the output depend on whether CI had built
the client.
internal listener. The SPA catch-all, `/uploads`, `/brand` and `/modules` are filesystem-conditional
static mounts — not API contract, and including them would make the output depend on whether CI had
built the client, or on which modules happened to be on the volume of the machine that generated it.
`/modules/<id>/` is the last of those and the newest: an installed module's prebuilt client chunk,
served from the directory its `client.entry` sits in and never from the module root, behind the
module's own state guard (`503` when it failed to start, `404` when disabled) and with
`Cache-Control: no-cache`, because Vite's library build emits an unhashed `entry.js`. Anything else
under `/modules` is a `404` rather than the SPA shell. The full contract is
[`MODULE_API.md`](MODULE_API.md) §3.1.
A third generated file, `server/routes.guards.json`, is a **review aid and not a contract**: per route,
the middleware handler count plus the *named* middleware on its mount chain. It exists because a

View File

@@ -85,7 +85,7 @@ rejected rather than ignored, so a typo is a loud failure and not a silently-ine
| `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. |
| `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). |
| `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. |
| `client.entry` | no | Prebuilt ESM chunk, relative to the module root. Absent ⇒ server-only module. |
| `client.entry` | no | Prebuilt ESM chunk, relative to the module root, and **in a subdirectory** — the directory it sits in is what gets served (§3.1). Absent ⇒ server-only module; present-but-empty is rejected, since it claims a client half and delivers none. |
| `schema` | no | Idempotent SQL fragment (§2.6). |
| `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. |
| `mounts` | no | Declared prefixes per tier (§2.3). Declaration is the contract; the loader compares it against what the module actually registers and rejects a mismatch. |
@@ -430,17 +430,50 @@ Exactly as `MODULE_SYSTEM.md` §2.6 resolved, and Phase 1's spike is what proves
1. Module CI builds `client/dist/entry.js` with Vite in **library mode**, `react`, `react-dom`,
`react-dom/client` and `react-router-dom` declared **external**.
2. Core serves the module directory statically at `/modules/<id>/` — same-origin, so
2. Core serves the **directory the entry sits in** statically at `/modules/<id>/` — same-origin, so
`script-src 'self'` (`config/csp.js:49`) admits it with no nonce and no inline.
3. `utils/htmlShell.js` injects `<script type="module" src="/modules/<id>/entry.js">` at the
`</head>` rewrite it already performs (line 111), for each **started** module.
3. `utils/htmlShell.js` injects `<script type="module" src="/modules/<id>/entry.js">` **before
`</body>`**, for each **started** module.
4. Before that tag, core has published `window.__rg` (§3.2) from its own bundle. The module's
externals resolve against it.
5. Core renders **on `DOMContentLoaded`**, which is after every one of those scripts, so the routes a
module registers are present in the first render.
There is exactly one React instance and core owns it. A module that bundles its own React will
produce two copies of the hook dispatcher and fail at the first `useState`; the externals config in
§3.5 is what prevents it.
Four of those five steps carry a constraint that is easy to get wrong and impossible to notice in a
unit test. All four are normative.
**The static root is the entry's directory, never the module root.** One `express.static` over a
module root publishes its server source, its `module.json` and its schema fragment. The loader
therefore **rejects an entry sitting directly in the module root** — an entry must be in a
subdirectory — rather than leaving the rule to whoever writes the mount. The mount sits behind the
module's own state guard, so a chunk is `503` while the module is `startup_failed` and `404` while it
is `disabled`, exactly as its API routes are: the browser must not be running the client half of
something the server half has stopped serving. Anything else under `/modules` is a `404`, not the SPA
shell — answering a `<script src>` with an HTML page turns a missing file into a MIME-type refusal
with a `200` in the network tab. And because a library build emits an **unhashed** `entry.js`, chunks
are served `Cache-Control: no-cache`: revalidation is what stops an upgraded module serving
yesterday's code out of the disk cache.
**The injection point is `</body>`, and that is a contract, not a formatting choice.** Module scripts
are deferred and execute in **document order**, so core's bundle — which publishes `window.__rg`
has to come first or every import in every module chunk resolves against `undefined`. Injecting into
`</head>` happens to work today only because Vite hoists core's entry script into `<head>`; that is a
bundler's emit decision, and if it ever changed, every module in the wild would break with nothing in
core having been edited. Last in the body is after core's script wherever core's script is.
**Core's render waits for `DOMContentLoaded`, and the readyState check is `'complete'`, not
`'loading'`.** A deferred script runs *after* the document is parsed, so by the time core's bundle
executes `document.readyState` is already `'interactive'` and `DOMContentLoaded` has not fired yet.
A `readyState === 'loading'` test therefore mounts immediately, before any module chunk has
evaluated, and a module's routes are missing from the first render — which is indistinguishable from
a module that failed to load: its URL falls through to core's catch-all and redirects home. This was
found by loading a real chunk in a browser, not by a test, and it is why PR 7's verification includes
one (§7.7).
### 3.2 `window.__rg`
Populated by core's `main.jsx` **before** it renders, and frozen afterwards.
@@ -535,7 +568,6 @@ The kit is **curated and closed**, not a re-export of `components/`:
| Export | From | Why it is in the kit |
| --- | --- | --- |
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome; a module page without it is a bare page |
| `AdminPage` | `routes/admin/…` | the admin content frame |
| `PageHeader` | `components/PageHeader.jsx` | title/subtitle furniture |
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has |
| `useAsync` | `lib/useAsync.js` | the fetch/loading/error hook every data page uses |
@@ -546,6 +578,11 @@ Adding to the kit is a **minor** `MODULE_API_VERSION` bump; changing a kit compo
**major** one. That is a real constraint on core and it is the price of the boundary being worth
anything.
The kit is those **seven** members. An earlier draft of this table listed an eighth, `AdminPage`, and
core has no such component — admin views are plain markup inside `AdminLayout`. It was struck in
Phase 2 PR 7 rather than satisfied by inventing a core component with no consumer until Phase 3;
adding it later costs a minor bump, which is the case this versioning exists for.
### 3.5 `api` — the request primitive
`client/src/api/client.js` is one 518-line object, and it already carries module namespaces:
@@ -963,3 +1000,28 @@ alternative and was rejected for two reasons: the loader now needs the tier rout
for the §4.3 collision check, which a require-time side effect cannot receive; and it would make the
ordering constraint invisible, enforced by where a `require` sits rather than by an argument that is
missing if it is wrong.
### 7.7 The client half has to be verified in a browser — **the timing bug no test could see**
Phase 2 PR 7 built the delivery mechanism: the static mount, the injected tag, `window.__rg`, the
registry, and core's consumption of it. Everything above is unit-tested, and the tests all passed
against a build that **did not work in a browser**.
The smoke that found it is worth repeating whenever this seam changes, and it is four steps: write a
throwaway `modules/<id>/` with a hand-written ESM `entry.js` — no bundler needed, since
`window.__rg.react.createElement` is enough to render a page — point `MODULES_DIR` at it, boot the
server against the built client, and load the module's URL in a real browser with the console open.
What it caught was step 5 of §3.1: core mounted before any module chunk had evaluated, because
`document.readyState` during a deferred script is `'interactive'` and not `'loading'`. The page
redirected home — the same thing a module that failed to load does — with **no error anywhere**:
the chunk had fetched, executed, and registered its route into a registry nothing read again. No unit
test in this repo can see it. There is no DOM in the server or client test runner, and the ordering
being asserted is the browser's, not the code's.
Two smaller things the same run confirmed, both worth keeping in the loop when re-running it: the
chunk executes under the **enforced** `script-src 'self'` with no CSP report, which is the property
§3.6 called the highest-risk detail in the plan; and the shell is read **once at boot**
(`htmlShell.init`), so rebuilding the client without restarting the server serves an `index.html`
pointing at a hashed bundle that no longer exists — core never runs, `window.__rg` is undefined, and
the failure looks exactly like a contract violation in the module.

View File

@@ -1,7 +1,7 @@
# The Module System — design of record
**Status:** approved design, **in implementation** — Phase 2's core scaffolding is landing on the
website `edge` branch, PRs 16 of 9 done (§2.7 tracks what each settled). Every decision in Part 3
website `edge` branch, PRs 17 of 9 done (§2.7 tracks what each settled). Every decision in Part 3
has been settled with the org lead; Part 1 records what was verified against the working trees on
2026-08-10, including the places the original draft was wrong.
@@ -267,8 +267,9 @@ It also rules out import maps as the shared-dependency mechanism: `config/csp.js
**Resolved** — see §2.6. The path that survives all three constraints is: the module's CI ships a
**prebuilt ESM chunk**, core hands it React through a **global** rather than an import map, and
`htmlShell.js:111` injects a **same-origin** `<script type="module" src>`, which `'self'` already
allows.
`htmlShell.js` injects a **same-origin** `<script type="module" src>`, which `'self'` already
allows. Verified in a browser against the enforced policy in Phase 2 PR 7, not only reasoned about
([`MODULE_API.md`](MODULE_API.md) §7.7).
---
@@ -442,10 +443,14 @@ builds nothing, production pulls a prebuilt image, and `script-src 'self'` forbi
2. **Core exposes the shared dependencies on a global** before mount — `window.__rg = { react,
reactDom, router, registry }` — and the module's externals resolve to it. A global, not an import
map, precisely because an import map must be inline and CSP forbids that.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites `</head>`
(`utils/htmlShell.js:111`), so this is an extension of a working mechanism, not a new one. The tag
is `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline.
3. **`htmlShell.js` injects the module's entry script.** It already rewrites the shell it serves, so
this is an extension of a working mechanism, not a new one. The tag is
`<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline. *(Amended in Phase 2 PR 7: the tag is injected before `</body>`, not at the
`</head>` rewrite this step assumed. Module scripts execute in document order and core's bundle
has to run first, so the injection must be after core's own script tag wherever a bundler chooses
to put it — [`MODULE_API.md`](MODULE_API.md) §3.1, which also states the static mount's root, its
state guard and its cache policy.)*
4. **The SPA reads `/api/v1/public/modules`** to feature-detect against what this backend is
serving. Registration happens when the injected chunk executes and calls `window.__rg.registry` —
it is not gated on this call. *(Amended by [`MODULE_API.md`](MODULE_API.md) §6.7: this step
@@ -489,8 +494,8 @@ too (API §7.2).
**serving**, shaped like the existing branding/site-settings endpoints (anonymous, database-free,
not site-mode gated). The SPA and the Android plan both feature-detect against it; it is not what
loads a client chunk ([`MODULE_API.md`](MODULE_API.md) §2.9 and §6.7).
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, and the
`htmlShell` script injection — empty registry, no visible change.
7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, the chunk's static
mount and the `htmlShell` script injection — empty registry, no visible change.
8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` volume.
@@ -499,7 +504,7 @@ it is wrong. PR 6 is the single deliberate exception in the phase and it *adds*:
gains exactly one line, `GET /api/v1/public/modules`, and nothing else in the file moves. Every other
PR in Phase 2 produces a zero-line diff.
**Progress: PRs 1-6 done.**
**Progress: PRs 1-7 done.**
- **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
settled in §2.4 above.
@@ -593,6 +598,35 @@ fixed enum because the leg set is whatever has been registered.
schemas. The published mirror [`api-route-inventory.json`](./api-route-inventory.json) is refreshed
to match.
- **PR 7** — the client half's delivery: `client/src/modules/registry.js`, `window.__rg`
(`modules/shared.js`), the chunk's static mount and the `htmlShell` injection, with `App.jsx`
reading `routesFor` for all three areas. The registry is empty on a bare core, so nothing an
operator can see changes. Four decisions, all recorded in [`MODULE_API.md`](MODULE_API.md) §3.1 and
§3.4: **routes now, nav in PR 8** — PR 7 is "a module chunk loads and renders its page", PR 8 is
"it appears in the nav", which keeps the nav interleave and its override merge in one reviewable
change; **the script tag is injected before `</body>`**, not into `</head>`, so the ordering that
the whole client contract rests on comes from document structure rather than from Vite's choice to
hoist core's entry into `<head>`; **the static mount is rooted at the entry's directory, behind the
module's state guard, with `no-cache`** — a mount rooted at the module root would publish server
source, `module.json` and the schema fragment, so an entry in the module root is rejected outright;
and **the UI kit ships its seven real members**, with `AdminPage` struck from the contract rather
than invented in core to satisfy a table.
The verification that mattered was **not a test**. Everything passed against a build that did not
work in a browser: core mounted before any module chunk had evaluated, because `document.readyState`
during a deferred script is `'interactive'`, not `'loading'`. A module's routes were missing from
the first render and its URL redirected home — indistinguishable from a module that failed to load,
and with nothing logged anywhere. It was found by loading a hand-written chunk in Chrome, and that
smoke is now written down as part of the contract ([`MODULE_API.md`](MODULE_API.md) §7.7) because no
test in this repo can see it. The same run confirmed the property §3.6 called the highest-risk
detail in the plan: the chunk executes under the **enforced** `script-src 'self'`, resolving core's
React and UI kit off the global, with zero CSP reports.
**933 server tests** (+23) and **123 client tests** (+14) pass; `routes.manifest.json` is unchanged
at 230 routes and the OpenAPI spec regenerates byte-identical — `/modules/<id>/` is a
filesystem-conditional static mount, not API surface, for the same reason `/uploads` and `/brand`
are not in the manifest.
**Phase 3 — Extract `module-uo`.** Moves out of `website/`: the 8 model directories and their 25
tables; the nine UO `utils/` files plus `newsGump.js`; the 13 router/controller files;
`scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus