The three things core owes the client half before it can leave, all additive, all MODULE_API 1.2.0 → 1.3.0. `player.invite.accepted` is the third extension slot. Core's invite page owned a UO game-account step — it read a `gameAccountSignup` flag out of core's own settings and posted to a shard route — and an invite is a core concept that staff receive too, so the page stays and its optional next step becomes a slot. Named for the place, like the other two. Whether there is a step at all is the filling module's call, made from data core does not have; core keeps the shell, the skip control and the destination. `icon` on a nav item, because without it the six extracted UO rows would have been the only text-only entries in a sidebar where every other row has a glyph. Core supplies no fallback — an invented one is core making a presentation choice for content it knows nothing about. `icon` was already among the fields an override may not touch, so the concept predates a module being able to send one. `api.BASE` was in §3.5 from the first draft and never actually published. `request` is fetch-only, so an EventSource builds its own URL, and the shard's live feed is two of them; the alternative is a module hardcoding `/api/v1`, which asserts something about core that core has not promised. `AcceptInvite` is the one legitimate reader of `extensionFor` outside Slot.jsx: the answer decides a NAVIGATION, not a decoration. Decoration goes inside `<Slot wrap>`, which is why `hasExtension` stayed deleted. Co-Authored-By: Claude <noreply@anthropic.com>
232 lines
11 KiB
JavaScript
232 lines
11 KiB
JavaScript
// ── 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 providers = new Map()
|
|
// slot name → { Component, filledBy }.
|
|
const slots = 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.
|
|
*
|
|
* `icon` is a component core renders exactly as it renders its own rows' icons
|
|
* (1.3.0). It exists because without it the six UO rows would have extracted as
|
|
* the only text-only entries in a sidebar where every other row has a glyph,
|
|
* which reads as breakage rather than as a design. Core does not supply a
|
|
* fallback: a module that omits it gets no icon, the same as a core row that
|
|
* omits it, and inventing one would be core making a presentation choice for
|
|
* content it knows nothing about. Note that `icon` is already among the fields
|
|
* an override may not touch (lib/navOverrides.js) — the concept predates a
|
|
* module being able to supply one.
|
|
*
|
|
* @param {string} id
|
|
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?, icon?}>}} 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
|
|
* (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` — which has been literally
|
|
* true since Phase 3 slice 3 took the nine shard-gated rows out.
|
|
*/
|
|
export function registerFeatureProvider(id, namespace, hook) {
|
|
providers.set(namespace, { id, hook })
|
|
registered.add(id)
|
|
}
|
|
|
|
// ── Extension slots (§3.7) ─────────────────────────────────────────────────
|
|
//
|
|
// The client twin of the server's declareSlot/registerExtension, and the same
|
|
// rule in both halves: core declares a slot, ONLY core declares one, and at most
|
|
// one module fills it. Core renders `<Slot name>` (Slot.jsx) and gets nothing
|
|
// back when the slot is unfilled — so an instance with no module installed
|
|
// renders exactly what it renders today.
|
|
//
|
|
// A slot is named for a PLACE, never for a meaning. `site.footer.status` is a
|
|
// position in the footer and the styling that goes with it; the label, the
|
|
// target, the data and whether anything renders at all are the module's. The
|
|
// moment core types a slot by its content it has re-acquired the game semantics
|
|
// this whole extraction removes.
|
|
|
|
/**
|
|
* @param {string} name the slot id. Core-only — deliberately not on the
|
|
* `registry` object handed to modules.
|
|
*/
|
|
export function declareSlot(name) {
|
|
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
|
slots.set(name, { Component: null, filledBy: null })
|
|
}
|
|
|
|
/**
|
|
* Fill a declared slot with a component.
|
|
*
|
|
* **This is the one place the client registry is not fail-open**, and the
|
|
* asymmetry is deliberate. A dropped nav row costs a link the viewer can reach
|
|
* another way; a silently dropped extension is invisible to everyone including
|
|
* its author. So an unknown slot, a non-component, and a second fill all throw —
|
|
* exactly as the server's checkExtensionShape does.
|
|
*
|
|
* A throw here is always a programming error and never a race, because
|
|
* declaration structurally precedes filling: core declares in main.jsx, inside
|
|
* its own bundle, and every module chunk is a deferred script injected after it
|
|
* (§3.1).
|
|
*/
|
|
export function registerExtension(id, slot, Component) {
|
|
const entry = slots.get(slot)
|
|
if (!entry) throw new Error(`registerExtension: unknown extension slot "${slot}"`)
|
|
if (typeof Component !== 'function') throw new Error(`registerExtension: ${slot} is not a component`)
|
|
if (entry.filledBy) throw new Error(`extension slot "${slot}" is already filled by "${entry.filledBy}"`)
|
|
entry.Component = Component
|
|
entry.filledBy = id
|
|
registered.add(id)
|
|
}
|
|
|
|
/**
|
|
* The filling component, or null.
|
|
*
|
|
* Read by Slot.jsx and nothing else — deliberately. There is no `hasExtension`
|
|
* for a core layout to branch on, because a layout that asks whether a slot is
|
|
* filled and then renders its own decoration alongside gets the *failed* case
|
|
* wrong: the extension is filled, so the decoration renders, and the component
|
|
* then throws into the boundary leaving the decoration behind on its own. Core
|
|
* decorates through `<Slot wrap>` instead, which puts the decoration inside the
|
|
* boundary where it shares the extension's fate. (Found in a browser, with the
|
|
* footer's separator.)
|
|
*
|
|
* Undeclared and unfilled both read null: reading is fail-safe, and only writing
|
|
* is strict.
|
|
*/
|
|
export const extensionFor = (slot) => (slots.get(slot) || {}).Component || null
|
|
|
|
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) => providers.get(namespace)
|
|
|
|
/**
|
|
* Every registered provider, for core's feature context to call.
|
|
*
|
|
* Exported from the module but deliberately NOT a member of the `registry`
|
|
* object below: a module asks for a namespace it knows the name of, and has no
|
|
* business enumerating what everyone else registered. Core needs the list
|
|
* because it has to call each hook — unconditionally, in a fixed order, at the
|
|
* top of a component (modules/features.jsx).
|
|
*/
|
|
export const featureProviders = () =>
|
|
[...providers.entries()].map(([namespace, { id, hook }]) => ({ id, namespace, hook }))
|
|
|
|
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
|
|
}
|
|
providers.clear()
|
|
// Declarations go too, unlike the server's, where a slot is declared once at
|
|
// require time by the router that owns it. Core declares its slots in
|
|
// main.jsx — the one file no test loads — so on this side there is nothing
|
|
// declared at import time for a surviving declaration to protect.
|
|
slots.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,
|
|
registerExtension,
|
|
routesFor,
|
|
navFor,
|
|
featureProviderFor,
|
|
registeredIds,
|
|
}
|