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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user