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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–6 of 9 done (§2.7 tracks what each settled). Every decision in Part 3
|
||||
website `edge` branch, PRs 1–7 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
|
||||
|
||||
Reference in New Issue
Block a user