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:
@@ -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\(\)/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user