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

@@ -5,6 +5,7 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js'
// Public
import Portal from './routes/public/Portal.jsx'
@@ -115,6 +116,16 @@ export default function App() {
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` — the
registry prefixes the segment, so a module cannot spell its way
out of it (docs/website/MODULE_API.md §3.3). Declared before the
CMS catch-all below: React Router ranks a static segment over a
dynamic one, so the order is not what saves us, but keeping the
two adjacent makes the relationship visible to whoever adds the
next route here. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} />
@@ -205,6 +216,19 @@ export default function App() {
<Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
same RoleGate its own routes above use, so the sidebar and the
route table cannot disagree about who may see what. Before the
`*` redirect, which would otherwise swallow every one of them. */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
@@ -225,6 +249,17 @@ export default function App() {
<Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
place the three areas do not read alike. */}
{routesFor('player').map((r) => (
<Route
key={r.path}
path={`/player/${r.path}`}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
</Route>
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -42,6 +42,17 @@ function safeParse(text) {
}
}
// The request PRIMITIVE, exported for installed modules and handed to them on
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
//
// The `api` object below stays core's own binding surface. Its `atlas` and
// `shard` namespaces are module bindings that only still live here because
// Phase 3 has not moved them yet.
export { req as request }
export const api = {
// ----- auth -----
me: () => req('/auth/me'),

View File

@@ -2,12 +2,54 @@ import React from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import './styles/theme.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
// after this bundle's own tag; module scripts execute in document order, so they
// resolve their externals against the global this call sets up
// (docs/website/MODULE_API.md §3.2).
publishSharedDependencies()
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
// Deferred scripts — which every `type="module"` script is — execute in document
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
// event is therefore the guarantee that every installed module has registered
// its routes before React reads the registry: no loading state, no re-render,
// and no ordering race between core's bundle and a module's. A module chunk that
// 404s or throws does not hold the event back, so a broken module costs its own
// pages and not the site.
//
// The readyState check below is `'complete'`, and it is not the obvious
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
// after the document has been parsed, so by the time this line executes
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
// still comes after every deferred script. Testing for `'loading'` therefore
// mounts immediately, before any module chunk has evaluated, and a module's
// routes are missing from the very first render — which looks exactly like a
// module that failed to load: its URL falls through to core's catch-all and
// redirects home. Found by loading a real chunk in a browser; no unit test in
// this repo can see it.
//
// `'complete'` is only reached after `load`, which is strictly later than any
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
}
if (document.readyState === 'complete') {
mount()
} else {
document.addEventListener('DOMContentLoaded', mount, { once: true })
}

View File

@@ -0,0 +1,137 @@
// ── The client-side module registry ────────────────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
//
// A module's prebuilt chunk registers its routes, its nav entries and its feature
// provider here, and core reads them back. This is the client twin of the
// server's modules/loader.js — with one structural difference worth stating,
// because it is what makes the file this short: core *hands* the registry to the
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
// nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
//
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// core's own bundle. Module scripts are deferred, so they evaluate after that
// bundle has run — which is where `window.__rg` is published — and all of them
// finish before DOMContentLoaded. main.jsx waits for that same event before
// calling render(), so registration is complete before React reads any of this.
//
// That is what buys the simplicity here: registration is a plain synchronous
// write with no subscribers, not an observable store, because nothing can
// register after the first render. If that ever stops being true it changes in
// this file and in main.jsx, not in a dozen consumers.
//
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
// are stored and returned faithfully but core does not read them yet — PR 8 adds
// the nav interleave and the feature-provider seam. Storing them is not the kind
// of accepting stub the server's registries refused to be: nothing is discarded
// here, so a module that registers nav in this core gets it back from `navFor`.
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const featureProviders = new Map()
const registered = new Set()
const AREAS = ['public', 'admin', 'player']
function assertArea(area, call) {
if (!AREAS.includes(area)) throw new Error(`${call}: unknown area "${area}"`)
}
/**
* Route components, by area.
*
* @param {string} id the module id — the URL segment its routes are namespaced under
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`. `path` is relative to the module's
* namespace; core prefixes it and mounts it inside the area's existing wrapper
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
* `gate` is an optional `{ roles: [...] }` that core applies as its own
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
* the route table have to agree about who may see what (§3.3).
*/
export function registerRoutes(id, byArea) {
for (const [area, list] of Object.entries(byArea || {})) {
assertArea(area, 'registerRoutes')
for (const route of list || []) {
// Prefixed HERE rather than by the module: a module cannot claim a path
// outside its own namespace however it spells `path` — a leading `/`, a
// trailing one, or several — because it never gets to write the segment
// its routes hang under.
const path = `${id}/${String(route.path || '').replace(/^\/+/, '')}`.replace(/\/+$/, '')
routes[area].push({ ...route, path, moduleId: id })
}
}
registered.add(id)
}
/**
* Nav entries, interleaved into CORE groups rather than appended as a block.
*
* Today's UO items sit inside core's own Moderation and System groups; a "UO"
* group at the bottom of the sidebar would be a visible regression on the day
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
* core group, `order` sorts within it, and an unknown group name appends rather
* than dropping the item — a mis-typed group must cost a position, never a link.
*
* @param {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
*/
export function registerNav(id, spec) {
const { area, items } = spec || {}
assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: id })
registered.add(id)
}
/**
* The hook that answers "which of this module's features may this viewer see".
*
* Core keeps a generic flag context and owns none of the semantics — `uo` fills
* its namespace with today's `useShardFeatures` (MODULE_SYSTEM.md §1.5). With no
* module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today.
*/
export function registerFeatureProvider(id, namespace, hook) {
featureProviders.set(namespace, { id, hook })
registered.add(id)
}
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
// this ships to, so two modules asking for the same slot keep load order —
// which is alphabetical by id, the same order the server scans in (§4.2).
export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
export const registeredIds = () => [...registered]
/** Test seam. Nothing in the app calls this — there is no unregistering. */
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
featureProviders.clear()
registered.clear()
}
// The object handed to modules on window.__rg.registry. Deliberately the write
// calls plus the read ones: a module reading `routesFor` is how it finds out
// another module is installed, which is the only supported form of module-to-
// module awareness (there is no dependency resolution).
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
registeredIds,
}

View File

@@ -0,0 +1,96 @@
// ── window.__rg — the shared-dependency global ─────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
// docs/website/MODULE_API.md §3.2.
//
// A module's client half is a PREBUILT ESM chunk — the operator never builds
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
// an import map: an import map has to be an inline `<script type="importmap">`,
// and the policy forbids inline scripts outright. So the shared dependencies ride
// on a global, and the module's Rollup externals are aliased to two-line shims
// that re-export from it (§3.6).
//
// **There is exactly one React in the page and core owns it.** A module that
// bundled its own would get a second hook dispatcher and fail at its first
// useState. That is the same rule the server half enforces for `express` and
// `express-validator` on `ctx`, and for the same reason: anything shared between
// core and a module is owned by core and HANDED OVER, never resolved by the
// module.
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
// The automatic JSX runtime, and it is not decoration. A module's bundler
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
// default, and those have to resolve to CORE's React like every other import.
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
// with it, a module uses the default its tooling already assumes.
import * as jsxRuntime from 'react/jsx-runtime'
import { registry } from './registry.js'
import { MODULE_API_VERSION } from './version.js'
import PublicLayout from '../components/PublicLayout.jsx'
import PageHeader from '../components/PageHeader.jsx'
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
import { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError } from '../api/client.js'
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// seven are what the smallest UO page already needs beyond React and the router:
// without them a module either reaches into core's tree — violating the
// zero-import rule the whole boundary rests on — or ships its own copies, which
// means a module page that does not look like the site it is installed in, and
// that drifts further every time core's layout changes.
//
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
// the price of the boundary being worth anything.
//
// `AdminPage` appears in §3.4's table and is deliberately absent: core has no
// such component — admin views are plain markup inside AdminLayout — and
// inventing one to satisfy a table would be a core change with no consumer until
// Phase 3. The contract is amended rather than the code padded, and adding it
// later costs a minor bump, which is exactly the case the versioning is for.
const ui = {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
}
// The request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard`
// are module bindings that only still live in core's client because Phase 3 has
// not moved them; a module builds its own namespace over `request` and owns the
// paths it calls — which is right, because it owns the routes at the other end.
const api = { request, ApiError }
/**
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
* module chunk evaluates.
*
* Frozen, one level down as well as at the top: the object a module reaches for
* its React is not somewhere a module gets to leave something for the next one.
* Cross-module communication is a thing the contract does not have, and an
* unfrozen global is how a codebase acquires one by accident.
*/
export function publishSharedDependencies() {
window.__rg = Object.freeze({
version: MODULE_API_VERSION,
react,
reactDom,
router,
jsxRuntime,
registry,
ui: Object.freeze(ui),
api: Object.freeze(api),
})
return window.__rg
}

View File

@@ -0,0 +1,14 @@
// The client's copy of MODULE_API_VERSION. It must equal the server's
// (server/src/modules/version.js) — the two halves version ONE contract
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
// against the client's before it registers anything.
//
// Duplicated rather than fetched, and that is deliberate. The value has to be on
// `window.__rg` before the first module chunk evaluates, which is earlier than
// any network round trip could answer — a fetched version would mean either an
// await before render or a module reading `undefined`. The cost of the copy is
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
export const MODULE_API_VERSION = '1.0.0'

View 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])
})