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

@@ -186,6 +186,47 @@ modules.load({
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
// Installed modules' prebuilt client chunks, at /modules/<id>/ — same-origin, so
// `script-src 'self'` admits them with no nonce and no inline script
// (docs/website/MODULE_API.md §3.1). Three properties, each load-bearing:
//
// • The static root is the directory the ENTRY sits in, never the module root.
// One express.static over a module root would publish its server source, its
// module.json and its schema fragment; the loader rejects an entry that would
// make those the same directory.
// • Behind the module's own state guard, so a failed module's chunk is 503 and
// a disabled one's is 404 — the same answers its API gives, for the same
// reason: the browser should not be running the client half of something the
// server half has stopped serving.
// • `fallthrough: false`, so a missing file is a 404 here rather than falling
// through to the SPA catch-all and answering a `<script src>` with the index
// shell, which the browser then rejects on its MIME type instead.
//
// Vite's library build emits an unhashed `entry.js`, so `no-cache` (revalidate,
// not "do not store") is what stops an upgraded module serving yesterday's chunk
// out of the disk cache.
for (const chunk of modules.clientChunks()) {
app.use(
chunk.url,
chunk.guard,
express.static(chunk.dir, {
fallthrough: false,
setHeaders: (res) => {
res.set('Cache-Control', 'no-cache')
res.set('X-Content-Type-Options', 'nosniff')
},
}),
)
}
// Everything else under /modules is a 404, not the SPA shell. The namespace
// belongs to installed modules' chunks — an unknown module id or a file a module
// does not ship is a missing file, and answering a `<script src>` with an HTML
// page turns that into a MIME-type refusal in the console with a 200 in the
// network tab. It also keeps the namespace's boundary a fact of the app rather
// than of whichever catch-all happens to be mounted after it.
app.use('/modules', (req, res) => res.status(404).json({ message: 'Not found' }))
// ── /.well-known ──────────────────────────────────────────────────────
// Android App Links verification file at the web root (M9 follow-up). Mounted
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless

View File

@@ -17,9 +17,11 @@
// past its own try/catch — a bad module must cost the site its routes, never its
// boot (§4.4).
//
// What is deliberately NOT here yet, each landing with the PR that first calls
// it (§2.7): GET /api/v1/public/modules and the client chunk's static mount
// (PRs 6-7).
// PR 7 added the client half's server-side end: validating `client.entry` and
// publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
// utils/htmlShell.js can inject its script tag. The loader resolves and validates;
// it does not mount, because the chunk hangs off the ROOT app rather than a tier
// router, and app.js is where core's own static mounts live.
//
// PR 3 added the fragment half of the schema story: this file VALIDATES a
// fragment (statement by statement, at load time, before anything is mounted)
@@ -322,6 +324,58 @@ function ownedByCore(tierRouter, prefix) {
)
}
// ── The client chunk ───────────────────────────────────────────────────────
// A chunk filename, and the same character set utils/htmlShell.js will accept in
// a script src. Two copies of the rule, deliberately: this one rejects the module
// at load time, that one refuses to write the tag. A validator three files away
// staying strict is not something an HTML attribute should depend on.
const CHUNK_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
/**
* Resolve and validate `client.entry` — where a module's prebuilt chunk lives on
* disk, and the URL it is served at (§3.1).
*
* The rule that matters most is the last one, and it is the one a reviewer would
* not think to ask for: the static mount is rooted at the DIRECTORY THE ENTRY IS
* IN, not at the module root. One `express.static` over a module root would
* publish its server source, its `module.json` and its schema fragment to the
* internet. So an entry sitting directly in the module root is rejected rather
* than quietly turning the whole module into a public directory.
*
* @returns {{dir: string, url: string, entryUrl: string}|null} null when the
* module ships no client half — a server-only module is perfectly normal.
*/
function resolveClient(dir, id, manifest) {
// Absent `client` is a server-only module. Present but empty is not the same
// thing: it states a client half and delivers none, which would be a module
// whose pages never load and nothing anywhere saying why.
if (manifest.client === undefined) return null
const { entry } = manifest.client
if (typeof entry !== 'string' || !entry.trim()) fail('manifest', 'client.entry must be a path')
const file = path.resolve(dir, entry)
// Containment before anything else: `../../server/src/config` resolves to a
// real, readable directory, and every check below it would pass.
if (file !== dir && !file.startsWith(dir + path.sep)) {
fail('manifest', `client.entry "${entry}" escapes the module directory`)
}
if (!CHUNK_FILE.test(path.basename(file))) {
fail('manifest', `client.entry "${entry}" must name a .js file`)
}
const chunkDir = path.dirname(file)
if (chunkDir === dir) {
fail('manifest', `client.entry "${entry}" must be in a subdirectory — its directory is served`)
}
if (!fs.existsSync(file)) fail('manifest', `client.entry "${entry}" is missing`)
return {
dir: chunkDir,
url: `/modules/${id}`,
entryUrl: `/modules/${id}/${path.basename(file)}`,
}
}
function readManifest(dir, id, tierRouters) {
const file = path.join(dir, 'module.json')
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
@@ -358,6 +412,15 @@ function readManifest(dir, id, tierRouters) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
}
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
}
for (const key of Object.keys(manifest.client)) {
if (key !== 'entry') fail('manifest', `unknown key "client.${key}" in module.json`)
}
}
if (manifest.schema && !manifest.purge) {
// A module that can create tables and cannot drop them leaves an operator
// with orphaned data and no supported way to remove it.
@@ -434,6 +497,7 @@ function load(tierRouters) {
tables: new Set(),
called: new Set(),
hooks: { onBoot: null, onShutdown: null },
client: null,
ctx: null,
state: 'installed',
stage: null,
@@ -446,6 +510,7 @@ function load(tierRouters) {
let stage = 'manifest'
try {
record.manifest = readManifest(dir, id, tierRouters)
record.client = resolveClient(dir, id, record.manifest)
stage = 'schema'
record.tables = tablesOf(dir, record.manifest)
checkTableNames(id, record.tables)
@@ -522,17 +587,35 @@ function load(tierRouters) {
function mount(record, tierRouters) {
for (const tier of TIERS) {
for (const [prefix, router] of record.routes[tier]) {
tierRouters[tier].use(prefix, (req, res, next) => {
if (record.state === 'startup_failed') {
return res.status(503).json({ message: 'Module unavailable' })
}
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
return next()
}, router)
tierRouters[tier].use(prefix, stateGuard(record), router)
}
}
}
/**
* The dispatch guard, as a middleware over the LIVE record.
*
* A closure over the record rather than over its state: everything mounts once,
* at boot, and the states that matter here are reached afterwards — the schema
* replay fails, `onBoot` throws, an admin disables the module. A guard that read
* the state at mount time would answer for the state a module was in before any
* of that happened.
*
* Used for a module's API routes and, since PR 7, for its client chunk: a module
* answering 503 on its API must not also be handing the browser the script that
* calls it, and one an admin has disabled should be as absent from the page as it
* is from the nav.
*/
function stateGuard(record) {
return (req, res, next) => {
if (record.state === 'startup_failed') {
return res.status(503).json({ message: 'Module unavailable' })
}
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
return next()
}
}
// ── State ──────────────────────────────────────────────────────────────────
// The states a loaded record may hold, deliberately a hardcoded subset rather
@@ -657,7 +740,58 @@ function fragments() {
.map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) }))
}
/**
* Every module that ships a client chunk, with where to serve it from and the
* guard to serve it behind — in scan order.
*
* Listed regardless of state, because mounting happens once at boot and the
* guard is what answers for the state at request time (the same arrangement the
* API routes have). A module that failed VALIDATION never reaches here at all:
* `record.client` is only resolved once the manifest passed.
*
* `dir` is the directory the entry sits in, never the module root — see
* resolveClient. app.js does the mounting; this file does not know about the
* root app.
*
* @returns {{id: string, dir: string, url: string, entryUrl: string, guard: Function}[]}
*/
function clientChunks() {
assertLoaded('clientChunks')
return [...modules.values()]
.filter((r) => r.client)
.map((r) => ({ id: r.id, ...r.client, guard: stateGuard(r) }))
}
/**
* The script URLs the HTML shell should inject, in scan order.
*
* `started` only, and that is the difference between this and clientChunks():
* the mount is a standing offer answered by a guard, while the tag is a decision
* taken per page render, when the state is already known. A module whose `onBoot`
* failed keeps its URLs and answers 503 on them — loading its client half would
* render its pages against a backend that cannot serve them.
*
* @returns {string[]}
*/
function clientEntryUrls() {
assertLoaded('clientEntryUrls')
return [...modules.values()]
.filter((r) => r.client && r.state === 'started')
.map((r) => r.client.entryUrl)
}
/** Absolute path of the modules directory. */
const dir = () => MODULES_DIR
module.exports = { load, list, setState, fragments, bootable, shutdownHooks, isLoaded, dir }
module.exports = {
load,
list,
setState,
fragments,
bootable,
shutdownHooks,
clientChunks,
clientEntryUrls,
isLoaded,
dir,
}

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() }