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:
@@ -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 />} />
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
137
client/src/modules/registry.js
Normal file
137
client/src/modules/registry.js
Normal 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,
|
||||
}
|
||||
96
client/src/modules/shared.js
Normal file
96
client/src/modules/shared.js
Normal 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
|
||||
}
|
||||
14
client/src/modules/version.js
Normal file
14
client/src/modules/version.js
Normal 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'
|
||||
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