docs(website): record Phase 2 PR 7 — the client chunk's delivery contract

MODULE_API.md
  - 3.1 gains the four normative constraints PR 7 settled: the static root is
    the entry's directory (an entry in the module root is rejected), the mount
    sits behind the module's state guard with no-cache, the script tag is
    injected before </body> so core's bundle runs first, and core renders on
    DOMContentLoaded with a readyState === 'complete' check
  - 2.1: client.entry must be in a subdirectory; present-but-empty is rejected
  - 3.4: AdminPage struck from the UI kit — core has no such component
  - 7.7 (new): the browser smoke, and the timing bug no test in this repo can
    see. Every test passed against a build that did not work in a browser

MODULE_SYSTEM.md
  - 2.6 step 3 amended to the </body> injection point
  - 2.7 records PR 7's four decisions and what verified them; 1-7 of 9 done

BACKEND_DESIGN.md
  - /modules is the fourth filesystem-conditional static mount outside the
    route manifest, with its root, guard and cache policy stated

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 22:54:39 -05:00
parent fdede79909
commit 17d8608a7f
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. domain split makes that necessary.
Scope: the manifest keeps `/api/**` and `/.well-known/**` from the public app plus everything on the 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 internal listener. The SPA catch-all, `/uploads`, `/brand` and `/modules` are filesystem-conditional
mounts — not API contract, and including them would make the output depend on whether CI had built static mounts — not API contract, and including them would make the output depend on whether CI had
the client. 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, 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 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. | | `version` | yes | Semver. Recorded in `installed_modules`; shown on failure. |
| `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). | | `coreApi` | yes | Semver range checked against `MODULE_API_VERSION` (§1.1). |
| `server` | no | Entry point, relative to the module root. Absent ⇒ client-only module. | | `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). | | `schema` | no | Idempotent SQL fragment (§2.6). |
| `purge` | no | Destructive teardown (§2.6). Required if `schema` is present. | | `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. | | `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`, 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**. `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. `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 3. `utils/htmlShell.js` injects `<script type="module" src="/modules/<id>/entry.js">` **before
`</head>` rewrite it already performs (line 111), for each **started** module. `</body>`**, for each **started** module.
4. Before that tag, core has published `window.__rg` (§3.2) from its own bundle. The module's 4. Before that tag, core has published `window.__rg` (§3.2) from its own bundle. The module's
externals resolve against it. 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 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 produce two copies of the hook dispatcher and fail at the first `useState`; the externals config in
§3.5 is what prevents it. §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` ### 3.2 `window.__rg`
Populated by core's `main.jsx` **before** it renders, and frozen afterwards. 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 | | Export | From | Why it is in the kit |
| --- | --- | --- | | --- | --- | --- |
| `PublicLayout` | `components/PublicLayout.jsx` | the public chrome; a module page without it is a bare page | | `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 | | `PageHeader` | `components/PageHeader.jsx` | title/subtitle furniture |
| `Loading`, `ErrorState`, `EmptyState` | `components/PageState.jsx` | the three states every data page has | | `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 | | `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 **major** one. That is a real constraint on core and it is the price of the boundary being worth
anything. 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 ### 3.5 `api` — the request primitive
`client/src/api/client.js` is one 518-line object, and it already carries module namespaces: `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 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 ordering constraint invisible, enforced by where a `require` sits rather than by an argument that is
missing if it is wrong. 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 # The Module System — design of record
**Status:** approved design, **in implementation** — Phase 2's core scaffolding is landing on the **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 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. 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 **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 **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 `htmlShell.js` injects a **same-origin** `<script type="module" src>`, which `'self'` already
allows. 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, 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 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. 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>` 3. **`htmlShell.js` injects the module's entry script.** It already rewrites the shell it serves, so
(`utils/htmlShell.js:111`), so this is an extension of a working mechanism, not a new one. The tag this is an extension of a working mechanism, not a new one. The tag is
is `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no `<script type="module" src="/modules/uo/entry.js">` — same-origin, so `'self'` passes with no
nonce and no inline. 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 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` — 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 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, **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 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). 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 7. Client `src/modules/registry.js`, the `window.__rg` shared-dependency global, the chunk's static
`htmlShell` script injection — empty registry, no visible change. 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). 8. `MOD_PATHS` → `roles`-derived (§1.4); the generic feature-provider seam (§1.5).
9. `docker-compose.yml` gains the `modules` volume. 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 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. 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 - **PR 1** — `installed_modules` and the state machine, with the stored shape and the boot rules
settled in §2.4 above. 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 schemas. The published mirror [`api-route-inventory.json`](./api-route-inventory.json) is refreshed
to match. 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 **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; 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 `scripts/importSpawnAtlas.js` and `db/spawnAtlas.art.json`; `usersShard.controller.js` **minus