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/'))
})