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:
@@ -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 core’s 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 core’s 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/'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user