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:
164
client/test/moduleRegistry.test.js
Normal file
164
client/test/moduleRegistry.test.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { test, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
registry,
|
||||
registerRoutes,
|
||||
registerNav,
|
||||
registerFeatureProvider,
|
||||
routesFor,
|
||||
navFor,
|
||||
featureProviderFor,
|
||||
registeredIds,
|
||||
_reset,
|
||||
} from '../src/modules/registry.js'
|
||||
import { MODULE_API_VERSION } from '../src/modules/version.js'
|
||||
|
||||
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
|
||||
// isolation from React, like the nav-override merge next door, because the
|
||||
// property worth proving has nothing to do with rendering: a module gets exactly
|
||||
// the URL namespace core gave it, however it spells the paths it registers.
|
||||
//
|
||||
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
|
||||
// there is no DOM in this runner. What it publishes is React, the router and
|
||||
// core components: a wiring test would assert that an import statement imported
|
||||
// something. Phase 1's spike proved the half that can actually fail, which is a
|
||||
// real chunk resolving its externals against the global in a browser under an
|
||||
// enforced CSP.
|
||||
|
||||
beforeEach(() => _reset())
|
||||
|
||||
test('a module route is namespaced under the module id', () => {
|
||||
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
|
||||
assert.deepEqual(
|
||||
routesFor('public').map((r) => r.path),
|
||||
['uo/atlas'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a module cannot spell its way out of its namespace', () => {
|
||||
// Whatever the module writes, the segment it lands under is core's to choose:
|
||||
// leading slashes, several of them, a trailing one, or nothing at all.
|
||||
registerRoutes('uo', {
|
||||
public: [
|
||||
{ path: '/atlas' },
|
||||
{ path: '//atlas/creatures' },
|
||||
{ path: 'atlas/' },
|
||||
{ path: '' },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(
|
||||
routesFor('public').map((r) => r.path),
|
||||
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
|
||||
// `..` is not stripped, and does not need to be: React Router matches path
|
||||
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
|
||||
// than a route that resolves somewhere else. Asserted so that a future
|
||||
// "cleanup" that starts resolving these knows it changed a behaviour.
|
||||
registerRoutes('uo', { public: [{ path: '../admin' }] })
|
||||
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
|
||||
})
|
||||
|
||||
test('routes keep their gate and carry the owning module id', () => {
|
||||
registerRoutes('uo', {
|
||||
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
|
||||
})
|
||||
const [route] = routesFor('admin')
|
||||
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
|
||||
assert.equal(route.moduleId, 'uo')
|
||||
assert.equal(route.element, 'OPS')
|
||||
})
|
||||
|
||||
test('the three areas are kept apart', () => {
|
||||
registerRoutes('uo', {
|
||||
public: [{ path: 'atlas' }],
|
||||
admin: [{ path: 'link' }],
|
||||
player: [{ path: 'chars' }],
|
||||
})
|
||||
assert.equal(routesFor('public').length, 1)
|
||||
assert.equal(routesFor('admin').length, 1)
|
||||
assert.equal(routesFor('player').length, 1)
|
||||
// An area nobody registered is an empty list, never undefined: App.jsx maps
|
||||
// over all three unconditionally.
|
||||
_reset()
|
||||
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
|
||||
})
|
||||
|
||||
test('an unknown area throws rather than being dropped', () => {
|
||||
// Loudly, because the alternative is a module whose pages simply never appear
|
||||
// and no indication anywhere of why.
|
||||
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
|
||||
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
|
||||
assert.equal(registeredIds().length, 0)
|
||||
})
|
||||
|
||||
test('nav items sort by order, and equal orders keep load order', () => {
|
||||
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
|
||||
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
|
||||
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
|
||||
assert.deepEqual(
|
||||
navFor('admin').map((i) => i.label),
|
||||
['First', 'Second', 'Third'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a nav item with no order sorts after the ones that asked for a place', () => {
|
||||
registerNav('uo', {
|
||||
area: 'public',
|
||||
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
|
||||
})
|
||||
assert.deepEqual(
|
||||
navFor('public').map((i) => i.label),
|
||||
['Early', 'Unordered'],
|
||||
)
|
||||
})
|
||||
|
||||
test('a feature provider is stored under its namespace, with its owner', () => {
|
||||
const hook = () => ({ atlas: true })
|
||||
registerFeatureProvider('uo', 'shard', hook)
|
||||
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
|
||||
assert.equal(featureProviderFor('nothing'), undefined)
|
||||
})
|
||||
|
||||
test('every registration marks the module registered', () => {
|
||||
registerRoutes('a', { public: [{ path: 'x' }] })
|
||||
registerNav('b', { area: 'public', items: [] })
|
||||
registerFeatureProvider('c', 'ns', () => {})
|
||||
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
test('the registry object handed to modules exposes the whole surface', () => {
|
||||
// window.__rg.registry is the ONLY way a module reaches any of this, so a
|
||||
// member missing from the object is a member that does not exist.
|
||||
assert.deepEqual(Object.keys(registry).sort(), [
|
||||
'featureProviderFor',
|
||||
'navFor',
|
||||
'registerFeatureProvider',
|
||||
'registerNav',
|
||||
'registerRoutes',
|
||||
'registeredIds',
|
||||
'routesFor',
|
||||
])
|
||||
})
|
||||
|
||||
test('the client and server halves declare the same MODULE_API_VERSION', () => {
|
||||
// The value is duplicated because it has to be on window.__rg before the first
|
||||
// module chunk evaluates, which is earlier than a fetch could answer. This is
|
||||
// the test that pays for the copy: a bump that edits one file fails here
|
||||
// instead of shipping a core whose two halves disagree about the contract they
|
||||
// implement.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const server = fs.readFileSync(
|
||||
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
|
||||
'utf8',
|
||||
)
|
||||
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
|
||||
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
|
||||
assert.equal(MODULE_API_VERSION, match[1])
|
||||
})
|
||||
Reference in New Issue
Block a user