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

@@ -227,3 +227,86 @@ test('an invalidation during a render is not overwritten by the stale result', a
await inflight
assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached')
})
// ── Installed modules' client chunks (MODULE_API.md §3.1) ──────────────────
// The built shell as Vite actually emits it: core's entry is a module script in
// <head>, and the injection has to land AFTER it wherever it is.
const BUILT_TEMPLATE = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite App</title>
<meta name="description" content="placeholder" />
<script type="module" crossorigin src="/assets/index-abc123.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-abc123.css" />
</head>
<body><div id="root"></div></body>
</html>`
test('a module chunk is injected as a same-origin module script', () => {
const html = htmlShell.render(TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] })
assert.match(html, /<script type="module" src="\/modules\/uo\/entry\.js"><\/script>/)
})
test('the injection lands after cores own bundle, not before it', () => {
// The property the whole client contract rests on: module scripts are deferred
// and execute in document order, so core's bundle must run first — it is what
// publishes window.__rg, and every import in the chunk resolves against it.
// Injecting into </head> would work today only because Vite hoists core's
// entry there; before </body> is after it wherever a bundler decides to put it.
const html = htmlShell.render(BUILT_TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] })
assert.ok(
html.indexOf('/assets/index-abc123.js') < html.indexOf('/modules/uo/entry.js'),
'the module chunk must come after cores bundle',
)
assert.ok(html.indexOf('/modules/uo/entry.js') < html.indexOf('</body>'))
assert.ok(html.indexOf('</head>') < html.indexOf('/modules/uo/entry.js'))
})
test('several modules keep the order they were given', () => {
const html = htmlShell.render(TEMPLATE, {
moduleEntries: ['/modules/aaa/entry.js', '/modules/zzz/entry.js'],
})
assert.ok(html.indexOf('/modules/aaa/') < html.indexOf('/modules/zzz/'))
})
test('no installed modules leaves the shell byte-identical', () => {
// A bare core must serve exactly what it served before this PR — including
// when the list is absent rather than empty, which is what a render before
// modules.load() produces.
const baseline = legacyRenderIndexHtml(TEMPLATE)
assert.equal(htmlShell.render(TEMPLATE, { moduleEntries: [] }), baseline)
assert.equal(htmlShell.render(TEMPLATE, {}), baseline)
})
test('anything that is not a module chunk URL is refused, not escaped into the page', () => {
// The loader builds these from a validated id and a validated basename, so
// none of this is reachable today. It is enforced here anyway: what may appear
// in a script src should be a property of the code writing the HTML, not of a
// validator two files away staying strict.
const html = htmlShell.render(TEMPLATE, {
moduleEntries: [
'https://evil.example/entry.js', // off-origin
'/modules/uo/../../etc/passwd', // traversal
'/modules/UO/entry.js', // not a valid module id
'/modules/uo/entry.js"></script><script>alert(1)</script>', // attribute break-out
'/modules/uo/../secrets.js',
'/uploads/entry.js', // right shape, wrong root
42,
null,
],
})
assert.ok(!html.includes('<script type="module" src='), html)
assert.equal(htmlShell.render(TEMPLATE, { moduleEntries: [] }), legacyRenderIndexHtml(TEMPLATE))
})
test('get() renders without module scripts when the loader has never scanned', async () => {
// db/seed.js's problem, one layer up: this file is required by app.js, and a
// render that reached the loader before modules.load() would throw §7.6's
// guard on a request path. A bare shell is the right answer.
settings.getShellBrand = async () => ({ logo: brand.logo, favicon: brand.favicon, theme: null })
htmlShell.init(TEMPLATE)
const html = await htmlShell.get()
assert.ok(!html.includes('/modules/'))
})

View File

@@ -0,0 +1,180 @@
// ── A module's client chunk, served by the real app ────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the contract is
// MODULE_API.md §3.1. moduleLoader.test.js proves the loader resolves and
// validates the chunk; this file proves what the app does with the answer, and
// it boots the REAL app.js to do it — because the three properties worth locking
// are properties of the mount, not of the loader:
//
// 1. the module's own dist directory is published, and nothing above it;
// 2. the chunk is served behind the module's state guard, so a failed or
// disabled module's client half is as absent as its API;
// 3. a miss is a 404 and never the SPA shell, which a browser would reject on
// its MIME type after the request appeared to succeed.
//
// The modules directory is written and MODULES_DIR is set BEFORE app.js is
// required, because the scan is synchronous and happens during that require.
// Node's test runner gives each file its own process, so this cannot disturb
// another test's view of the loader.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, before, after } = require('node:test')
const assert = require('node:assert/strict')
const CHUNK = 'export const hello = 1\n'
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-chunk-'))
const dist = path.join(tmpRoot, 'uo', 'client', 'dist')
fs.mkdirSync(dist, { recursive: true })
fs.writeFileSync(path.join(dist, 'entry.js'), CHUNK)
fs.writeFileSync(path.join(dist, 'sidecar.js'), 'export const also = 2\n')
// The two files a static mount rooted one level too high would publish.
fs.writeFileSync(path.join(tmpRoot, 'uo', 'secrets.js'), 'const TOKEN = "leak"\n')
fs.writeFileSync(
path.join(tmpRoot, 'uo', 'module.json'),
JSON.stringify({
id: 'uo',
name: 'Ultima Online',
version: '1.0.0',
coreApi: '^1.0.0',
client: { entry: 'client/dist/entry.js' },
}),
)
process.env.MODULES_DIR = tmpRoot
/* eslint-disable global-require */
const app = require('../src/app')
const loader = require('../src/modules/loader')
const db = require('../src/utils/db')
const htmlShell = require('../src/utils/htmlShell')
const settings = require('../src/model/settings/settings.model')
/* eslint-enable global-require */
let server
let base
before(async () => {
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
base = `http://127.0.0.1:${server.address().port}`
// The state the module would be in after a clean boot. lifecycle.js does this
// against the database; here it is set directly, since what is under test is
// what the mount does with a state, not how the state was reached.
loader.setState('uo', 'started')
})
after(async () => {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
await db.close()
fs.rmSync(tmpRoot, { recursive: true, force: true })
})
test('the chunk is served at the URL the shell injects', async () => {
const [entryUrl] = loader.clientEntryUrls()
assert.equal(entryUrl, '/modules/uo/entry.js')
const res = await fetch(base + entryUrl)
assert.equal(res.status, 200)
assert.equal(await res.text(), CHUNK)
// A module chunk is JavaScript to the browser or it is nothing: a `<script
// type="module">` whose response is not a JS MIME type is refused outright.
assert.match(res.headers.get('content-type'), /javascript/)
})
test('a sibling file in the same dist directory is served too', async () => {
// Not incidental: Rollup can split a chunk, and the entry then imports its
// siblings by relative URL. Publishing only the named entry would break every
// module that is more than one file.
const res = await fetch(`${base}/modules/uo/sidecar.js`)
assert.equal(res.status, 200)
})
test('nothing above the dist directory is reachable', async () => {
// The failure this rule exists to prevent: server source, module.json and the
// schema fragment published to the internet by one over-broad static mount.
for (const p of ['/modules/uo/module.json', '/modules/uo/secrets.js', '/modules/uo/../module.json']) {
const res = await fetch(base + p)
assert.notEqual(res.status, 200, `${p} must not be served`)
assert.ok(!(await res.text()).includes('leak'))
}
})
test('the chunk revalidates rather than being cached to a stale copy', async () => {
// Vite's library build emits an unhashed entry.js, so an upgraded module would
// otherwise keep serving yesterday's chunk out of the browser's disk cache.
const res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.headers.get('cache-control'), 'no-cache')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
})
test('a missing file is a 404, not the SPA shell', async () => {
const res = await fetch(`${base}/modules/uo/nope.js`)
assert.equal(res.status, 404)
assert.ok(!(await res.text()).includes('<div id="root">'))
})
test('an unknown module id is not served at all', async () => {
const res = await fetch(`${base}/modules/nope/entry.js`)
assert.notEqual(res.status, 200)
})
test('a failed modules chunk is 503 and a disabled ones is 404', async () => {
// The same answers the module's API routes give, and for the same reason: the
// browser must not be running the client half of something the server half has
// stopped serving.
loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'onBoot threw' })
let res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 503)
loader.setState('uo', 'disabled')
res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 404)
// And the guard reads the LIVE state — the mount happened once, at boot, long
// before any of these transitions.
loader.setState('uo', 'started')
res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 200)
})
test('the module is published to clients while it is started, and only then', async () => {
// Ties the two surfaces together: /public/modules and the injected script tag
// answer the same question — what is serving — and they must never disagree.
const seen = async () => {
const res = await fetch(`${base}/api/v1/public/modules`)
return (await res.json()).modules.map((m) => m.id)
}
assert.deepEqual(await seen(), ['uo'])
assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])
loader.setState('uo', 'disabled')
assert.deepEqual(await seen(), [])
assert.deepEqual(loader.clientEntryUrls(), [])
loader.setState('uo', 'started')
})
test('the shell hands the browser the tag for the chunk the app serves', async () => {
// The one seam htmlShell.test.js cannot cover, because it renders against a
// literal list: that the shell asks the LOADER, and gets back a URL this same
// app answers 200 on. The two are wired through a lazy require inside a
// try/catch, which is exactly the shape that can silently return [] forever.
//
// The settings read is stubbed rather than left to fail: the pool points at a
// dead port, and its ten-second connect timeout would be paid here for a
// fallback the test does not care about.
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
htmlShell.init('<!doctype html><html><head><title>t</title></head><body><div id="root"></div></body></html>')
const html = await htmlShell.get()
assert.match(html, /<script type="module" src="\/modules\/uo\/entry\.js"><\/script>/)
const res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 200)
})

View File

@@ -640,3 +640,105 @@ test('a module colliding with an already-registered name fails alone, unmounted'
assert.equal(claims('/first'), true)
assert.equal(claims('/second'), false)
})
// ── The client chunk (MODULE_API.md §3.1) ──────────────────────────────────
/** A module shipping a prebuilt chunk at the conventional client/dist/entry.js. */
function withChunk(id, { entry = 'client/dist/entry.js', write = true, body = 'export default 1' } = {}) {
const dir = writeModule(id, { manifest: { client: { entry } } })
if (write) {
const file = path.join(dir, entry)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, body)
}
return dir
}
test('a module with a chunk publishes where to serve it from and its URL', () => {
const dir = withChunk('uo')
const loader = freshLoader(tmpRoot)
const [chunk] = loader.clientChunks()
assert.equal(chunk.id, 'uo')
assert.equal(chunk.url, '/modules/uo')
assert.equal(chunk.entryUrl, '/modules/uo/entry.js')
// The DIRECTORY THE ENTRY IS IN, never the module root: one express.static over
// a module root would publish its server source, its module.json and its schema
// fragment.
assert.equal(chunk.dir, path.join(dir, 'client', 'dist'))
assert.equal(typeof chunk.guard, 'function')
})
test('a server-only module contributes no chunk', () => {
writeModule('plain', { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientChunks(), [])
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('an entry directly in the module root is refused — its directory is served', () => {
// The rule with the largest blast radius in this file. Accepting it would root
// the static mount at the module root and publish everything in it.
const dir = writeModule('uo', { manifest: { client: { entry: 'entry.js' } } })
fs.writeFileSync(path.join(dir, 'entry.js'), 'export default 1')
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /must be in a subdirectory/)
assert.deepEqual(loader.clientChunks(), [])
})
test('an entry that escapes the module directory is refused before anything else', () => {
// `../../server/src/config/csp.js` is a real, readable file, and every check
// after containment would have passed.
withChunk('uo', { entry: '../../server/src/config/csp.js', write: false })
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /escapes the module directory/)
})
test('an entry that is not a .js file, or is missing, is refused', () => {
withChunk('aaa', { entry: 'client/dist/entry.mjs' })
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
assert.match(stateOf(loader, 'bbb').reason, /is missing/)
})
test('a malformed client key is a loud failure, not an ignored setting', () => {
writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
writeModule('ccc', { manifest: { client: {} } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).stage, 'manifest')
})
test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
// The mount is a standing offer answered by a guard; the tag is a decision
// taken per render, when the state is known. A module answering 503 on its API
// must not also be handing the browser the script that calls it.
withChunk('uo')
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientEntryUrls(), [], 'registered is not yet serving')
loader.setState('uo', 'started')
assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])
loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'nope' })
assert.deepEqual(loader.clientEntryUrls(), [])
assert.equal(loader.clientChunks().length, 1, 'the mount stays; the guard answers for it')
loader.setState('uo', 'disabled')
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('the chunk accessors throw before load(), like every other one', () => {
process.env.MODULES_DIR = tmpRoot
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
assert.throws(() => loader.clientChunks(), /modules\.clientChunks\(\) before modules\.load\(\)/)
assert.throws(() => loader.clientEntryUrls(), /modules\.clientEntryUrls\(\) before modules\.load\(\)/)
})