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

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>
This commit is contained in:
2026-08-10 22:54:16 -05:00
parent fe83c91ba9
commit e0927bc255
13 changed files with 1114 additions and 22 deletions

View File

@@ -81,8 +81,9 @@ function absolutize(url) {
* byte-identical property without a database.
*
* @param {string} html the built index.html
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
* effective brand assets and theme; anything absent falls back to BRAND_* env
* @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides]
* effective brand assets and theme; anything absent falls back to BRAND_* env.
* `moduleEntries` are the same-origin URLs of installed modules' client chunks.
* @returns {string}
*/
function render(html, overrides = {}) {
@@ -105,10 +106,43 @@ function render(html, overrides = {}) {
]
.filter(Boolean)
.join('\n ')
return html
const scripts = moduleScriptTags(overrides.moduleEntries)
const withHead = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
.replace(/<\/head>/i, ` ${tags}\n </head>`)
if (scripts.length === 0) return withHead
return withHead.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`)
}
// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
//
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
// same-origin src with no nonce, and an inline tag would be blocked outright —
// which is also why the shared dependencies ride on window.__rg rather than an
// import map, since an import map has to be inline.
//
// **Injected before `</body>`, not into `</head>`, and the position is the
// contract.** Module scripts are deferred, so they execute in document order
// after core's own bundle — which is where `window.__rg` is published, and what
// every one of a module's imports resolves against. Vite happens to hoist core's
// entry script into `<head>` today, which would make a `</head>` injection work
// too; that is a bundler's emit choice, and if it ever changed, every module in
// the wild would break on its first import with nothing in this repo having been
// edited. Last in the body is after core's script wherever core's script is.
//
// The path is built by the loader from the module id and the entry's basename,
// both already validated, so nothing operator-supplied reaches the attribute.
// It is re-checked here anyway: what may appear in an HTML attribute should be a
// property of the code that writes the HTML, not of a validator two files away
// staying strict.
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
function moduleScriptTags(entries) {
if (!Array.isArray(entries)) return []
return entries
.filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
.map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
}
// The admin theme as a :root block, or '' when this instance has never been
@@ -170,7 +204,26 @@ async function get() {
// mean a failing query per page view.
overrides = {}
}
const html = render(template, overrides)
// The module list is in-memory and filesystem-derived, so unlike the brand
// read above it cannot fail on a DB fault and needs no fallback of its own.
// Required lazily for the same reason the settings model is: app.js requires
// this file, and the loader would otherwise be pulled into that chain.
let moduleEntries = []
try {
// eslint-disable-next-line global-require
moduleEntries = require('../modules/loader').clientEntryUrls()
} catch {
// The only reachable throw is §7.6's guard — the shell rendered before
// modules.load() ran, which app.js's ordering makes impossible and a test
// that renders in isolation makes possible. A page with no module scripts
// is the right answer either way; it is what a bare core serves.
moduleEntries = []
}
// Note for whoever builds the admin Modules screen: a state change after boot
// (an operator disabling a module) has to call invalidate(), exactly as a
// brand-asset write does. The TTL converges on its own within five minutes;
// the explicit call is what makes the toggle feel like it did something.
const html = render(template, { ...overrides, moduleEntries })
// An invalidation that landed while this read was in flight means the value
// we just read may already be stale. Serve it, but do not cache it.
if (generation === startedAt) cached = { html, at: Date.now() }