feat(modules): the client registry, window.__rg and the chunk's script injection #134

Merged
whitlocktech merged 1 commits from feature/module-client-registry into edge 2026-08-11 04:02:01 +00:00
Member

Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7 — the client half's delivery. A module's prebuilt chunk is served, injected, handed core's React and UI kit, and its routes are rendered by App.jsx. The registry is empty on a bare core, so nothing an operator can see changes. Docs half: docs#132.

The part that matters: every test passed against a build that did not work

The unit tests were green. The chunk fetched, executed, resolved window.__rg, and registered its route. The page still redirected home.

document.readyState during a deferred script is 'interactive', not 'loading' — a deferred script runs after the document is parsed and before DOMContentLoaded. So the obvious readyState === 'loading' guard is false, core mounted immediately, and it read the registry before a single module chunk had evaluated. A module's routes were absent from the first render and its URL fell through to core's catch-all — indistinguishable from a module that failed to load, with nothing logged anywhere.

No test in this repo can see that. There is no DOM in either runner, and the ordering being asserted is the browser's, not the code's. It was found by writing a throwaway module with a hand-written ESM entry, pointing MODULES_DIR at it and opening the page in Chrome; the smoke is now written down as a repeatable procedure in MODULE_API.md §7.7.

The same run confirmed the property §3.6 calls the highest-risk mechanical detail in the whole plan, against the enforced policy rather than a reasoned one:

Content-Security-Policy: default-src 'self';script-src 'self'; …
[smoke] window.__rg version 1.0.0
[smoke] kit PublicLayout,PageHeader,Loading,ErrorState,EmptyState,useAsync,useAuth,useSite
[smoke] registered smoke/hello

Zero CSP reports; the module page rendered inside core's PublicLayout with core's React. Screenshot in the review notes below.

Client

File What it is
modules/registry.js registerRoutes / registerNav / registerFeatureProvider, plus the readers. The URL namespace is written by core, so a module cannot spell its way out of it — a leading /, several, or a trailing one all land under <id>/.
modules/shared.js window.__rg: React, react-dom/client, react-router-dom, react/jsx-runtime, the registry, the UI kit and the request primitive. Frozen one level down as well as at the top — the object a module reaches for its React is not somewhere to leave something for the next module.
modules/version.js The client's MODULE_API_VERSION. Duplicated deliberately (it must be on the global before any chunk evaluates, which is earlier than a fetch could answer); a test asserts the two halves agree.
App.jsx routesFor for all three areas. Nav consumption is PR 8.
main.jsx Publishes the global, then mounts on DOMContentLoaded.
api/client.js Exports req as request — the primitive, not the api object.

Server

  • The loader validates client.entry and publishes clientChunks() / clientEntryUrls(). An entry sitting directly in the module root is rejected: the directory it sits in is what gets served, and one express.static over a module root would publish server source, module.json and the schema fragment. Containment is checked first, before the checks that a real file outside the module would pass.
  • app.js mounts each chunk at /modules/<id>/, behind the module's own state guard (503 failed, 404 disabled — the same answers its API gives) with no-cache and nosniff, and fallthrough: false. Anything else under /modules is a 404 rather than 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.
  • htmlShell injects before </body>. Module scripts execute in document order, so core's bundle has to run first. </head> works today only because Vite hoists core's entry into <head> — a bundler's emit choice, and if it changed every module in the wild would break with nothing here edited.

Verification

  • 933 server tests (+23) and 123 client tests (+14). moduleClientChunk.test.js boots the real app.js against a module on a temp volume, so the mount's three properties are tested where they live: nothing above the dist directory is reachable, the guard answers for the live state, and a miss is a 404.
  • routes.manifest.json unchanged at 230 routes, routes.guards.json unchanged, npm run swagger regenerates byte-identical. /modules/<id>/ is a filesystem-conditional static mount, not API surface — the same reason /uploads and /brand are outside the manifest.
  • One flake to disclose: on the first full run, test/announceLegs.test.js (PR 4's, untouched here) failed at file level with no failing subtest. It passed alone and in three subsequent full runs, and the baseline suite on edge is clean. Not reproduced; recorded rather than swept up.

Decisions

All four settled with the org lead before implementation; all four the recommended option.

Decision Settled
PR 7 / PR 8 boundary Routes now, nav (and MOD_PATHS, the feature seam) in PR 8
Injection point Before </body>
Chunk serving Dist-rooted, state guard, no-cache
UI kit Ship the seven that exist; strike AdminPage from the contract

  • AI-assisted: written with Claude Code (Claude Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8

Phase 2, **PR 7** of `docs/website/MODULE_SYSTEM.md` §2.7 — the client half's delivery. A module's prebuilt chunk is served, injected, handed core's React and UI kit, and its routes are rendered by `App.jsx`. The registry is empty on a bare core, so nothing an operator can see changes. Docs half: **docs#132**. ## The part that matters: every test passed against a build that did not work The unit tests were green. The chunk fetched, executed, resolved `window.__rg`, and registered its route. The page still redirected home. `document.readyState` during a **deferred** script is `'interactive'`, not `'loading'` — a deferred script runs *after* the document is parsed and *before* `DOMContentLoaded`. So the obvious `readyState === 'loading'` guard is false, core mounted immediately, and it read the registry before a single module chunk had evaluated. A module's routes were absent from the first render and its URL fell through to core's catch-all — **indistinguishable from a module that failed to load, with nothing logged anywhere.** No test in this repo can see that. There is no DOM in either runner, and the ordering being asserted is the browser's, not the code's. It was found by writing a throwaway module with a hand-written ESM entry, pointing `MODULES_DIR` at it and opening the page in Chrome; the smoke is now written down as a repeatable procedure in `MODULE_API.md` §7.7. The same run confirmed the property §3.6 calls the highest-risk mechanical detail in the whole plan, against the **enforced** policy rather than a reasoned one: ``` Content-Security-Policy: default-src 'self';script-src 'self'; … [smoke] window.__rg version 1.0.0 [smoke] kit PublicLayout,PageHeader,Loading,ErrorState,EmptyState,useAsync,useAuth,useSite [smoke] registered smoke/hello ``` Zero CSP reports; the module page rendered inside core's `PublicLayout` with core's React. Screenshot in the review notes below. ## Client | File | What it is | |---|---| | `modules/registry.js` | `registerRoutes` / `registerNav` / `registerFeatureProvider`, plus the readers. The URL namespace is written by **core**, so a module cannot spell its way out of it — a leading `/`, several, or a trailing one all land under `<id>/`. | | `modules/shared.js` | `window.__rg`: React, `react-dom/client`, `react-router-dom`, `react/jsx-runtime`, the registry, the UI kit and the request primitive. Frozen one level down as well as at the top — the object a module reaches for its React is not somewhere to leave something for the next module. | | `modules/version.js` | The client's `MODULE_API_VERSION`. Duplicated deliberately (it must be on the global before any chunk evaluates, which is earlier than a fetch could answer); a test asserts the two halves agree. | | `App.jsx` | `routesFor` for all three areas. Nav consumption is PR 8. | | `main.jsx` | Publishes the global, then mounts on `DOMContentLoaded`. | | `api/client.js` | Exports `req` as `request` — the primitive, not the `api` object. | ## Server - **The loader validates `client.entry`** and publishes `clientChunks()` / `clientEntryUrls()`. An entry sitting **directly in the module root is rejected**: the directory it sits in is what gets served, and one `express.static` over a module root would publish server source, `module.json` and the schema fragment. Containment is checked first, before the checks that a real file outside the module would pass. - **`app.js` mounts each chunk** at `/modules/<id>/`, behind the module's own state guard (`503` failed, `404` disabled — the same answers its API gives) with `no-cache` and `nosniff`, and `fallthrough: false`. Anything else under `/modules` is a 404 rather than 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. - **`htmlShell` injects before `</body>`.** Module scripts execute in document order, so core's bundle has to run first. `</head>` works today only because Vite hoists core's entry into `<head>` — a bundler's emit choice, and if it changed every module in the wild would break with nothing here edited. ## Verification - **933 server tests** (+23) and **123 client tests** (+14). `moduleClientChunk.test.js` boots the **real** `app.js` against a module on a temp volume, so the mount's three properties are tested where they live: nothing above the dist directory is reachable, the guard answers for the live state, and a miss is a 404. - `routes.manifest.json` **unchanged at 230 routes**, `routes.guards.json` unchanged, `npm run swagger` regenerates **byte-identical**. `/modules/<id>/` is a filesystem-conditional static mount, not API surface — the same reason `/uploads` and `/brand` are outside the manifest. - One flake to disclose: on the first full run, `test/announceLegs.test.js` (PR 4's, untouched here) failed at file level with no failing subtest. It passed alone and in three subsequent full runs, and the baseline suite on `edge` is clean. Not reproduced; recorded rather than swept up. ## Decisions All four settled with the org lead before implementation; all four the recommended option. | Decision | Settled | |---|---| | PR 7 / PR 8 boundary | Routes now, nav (and `MOD_PATHS`, the feature seam) in PR 8 | | Injection point | Before `</body>` | | Chunk serving | Dist-rooted, state guard, `no-cache` | | UI kit | Ship the seven that exist; strike `AdminPage` from the contract | --- - [x] AI-assisted: written with Claude Code (Claude Opus 5) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018ocYxQWk3EhZe5gWRJXFU8
wtclaude added 1 commit 2026-08-11 03:55:50 +00:00
feat(modules): the client registry, window.__rg and the chunk's script injection
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 1m37s
e0927bc255
Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md 2.7 — the client half's
delivery. A module's prebuilt chunk is served, injected, handed core's React
and its UI kit, and its routes are rendered by App.jsx. The registry is empty
on a bare core, so nothing an operator can see changes.

Client:
  - modules/registry.js — registerRoutes/registerNav/registerFeatureProvider,
    with the URL namespace written by core, never by the module
  - modules/shared.js — window.__rg: React, react-dom/client, react-router-dom,
    react/jsx-runtime, the registry, the seven-member UI kit and the request
    primitive, frozen
  - App.jsx reads routesFor for all three areas; nav consumption is PR 8
  - main.jsx publishes the global, then mounts on DOMContentLoaded

Server:
  - the loader validates client.entry and publishes clientChunks() and
    clientEntryUrls(); an entry in the module root is rejected, because the
    directory it sits in is what gets served
  - app.js mounts each chunk at /modules/<id>/ behind the module's state guard
    with no-cache; anything else under /modules is a 404, not the SPA shell
  - htmlShell injects the tag before </body>, so core's bundle runs first
    wherever a bundler puts it

Found by loading a real chunk in a browser, and fixed here: core mounted before
any module chunk had evaluated, because document.readyState during a deferred
script is 'interactive', not 'loading'. Every test passed against that build.
The smoke is written down in MODULE_API.md 7.7.

933 server tests (+23), 123 client tests (+14). routes.manifest.json unchanged
at 230 routes; the OpenAPI spec regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech merged commit e3c999b704 into edge 2026-08-11 04:02:01 +00:00
whitlocktech deleted branch feature/module-client-registry 2026-08-11 04:02:02 +00:00
Sign in to join this conversation.
No description provided.