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> and gets nothing back when the slot is unfilled, so an instance with no module installed renders exactly what it rendered before -- the same untouched-path guarantee withModuleNav makes. A slot is named for a PLACE, never for a meaning. Core supplies the position and the styling; 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 phase 3 exists to remove. This is the one place the client registry is not fail-open. An unknown slot, a non-component and a second fill all throw, matching checkExtensionShape server-side, because a dropped nav row costs a link the viewer can reach another way while a silently dropped extension is invisible to everyone including its author. A throw is always a programming error and never a race: core declares in its own bundle and every module chunk is a deferred script injected after it. Reading stays fail-safe -- undeclared and unfilled both read null -- and a filling component renders inside an error boundary. That asymmetry is where the client differs from the server: a module route that throws costs the module's own page, but an extension throws inside CORE's, and the whole reason core keeps ownership of that page is that it stays usable. Core decorates a slot through <Slot wrap>, not by asking whether it is filled. The obvious alternative is right about the unfilled case and wrong about the failed one -- the extension is filled, so the separator renders, and then the component throws into the boundary and leaves the separator behind on its own. wrap puts core's decoration inside the boundary where it shares the extension's fate. Found in a browser, with the footer's separator, which is the only place either could have been found. MODULE_API_VERSION 1.1.0 -> 1.2.0, both halves: the two state ONE version. Contract: docs/website/MODULE_API.md 3.7. Co-Authored-By: Claude <noreply@anthropic.com>
222 lines
10 KiB
JavaScript
222 lines
10 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.
|
|
*
|
|
* @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) {
|
|
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,
|
|
}
|