feat(modules): the client registry, window.__rg and the chunk's script injection
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:
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user