The inverted slot direction reached exactly one module. Core filled three
literal names - uo.guild.detail, uo.guild.forum, uo.guild.header - matched by
exact name in applyCoreFills, so a second game declaring a place under its own
id got an empty page and no error. "A fill for a slot nobody declared is not an
error" is the rule that made the miss invisible, and it is the right rule; what
was wrong was core knowing a slot's name at all.
It also put a module identifier inside core, in three string literals
scripts/checkModuleIdentifiers.js masks by construction and could never catch.
Found by the integration kit while writing the chapter that teaches this shape
to an audience outside this org - which is what that phase is for.
So the module says WHERE, in its own vocabulary, and WHICH of core's
contributions goes there:
declareModuleSlot(ID, 'uo.guild.detail', { core: 'team.activity' })
and core offers into the catalogue rather than into a name:
offerCoreFill('team.activity', TeamActivityFeed)
CORE_CONTRIBUTIONS is exported and fixed at build time, so asking for one core
does not offer THROWS at the declaration. That asymmetry with an unfilled slot
is deliberate: an unknown contribution is always a typo or a version skew - the
module's coreApi range has already been checked - and the failure it would
otherwise produce is a page that renders empty forever with nothing logged.
options.core is optional; a slot that asks for nothing stays empty, which is
what a module declaring a place it fills itself wants. More than one slot may
ask for the same contribution and each gets it: how many places a module wants
its feed in is a layout decision on a page core does not own.
Amends MODULE_API 1.6.0 in place rather than adding 1.7.0 - the same rule the
eighth and ninth members were given, and 1.6.0 has only ever been on edge.
Also: the UI kit is nine exports, not eight. Slot made it nine in phase 3 and
the comment beside it still said eighth.
288 client tests, 1162 server tests.
Co-Authored-By: Claude <noreply@anthropic.com>
349 lines
17 KiB
JavaScript
349 lines
17 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 })
|
|
}
|
|
|
|
/**
|
|
* The contributions core has for a module-declared slot.
|
|
*
|
|
* **Core offers a CONTRIBUTION, not a slot name, and that is the whole of why
|
|
* this list exists.** The first cut of the inverted direction had core fill three
|
|
* literal names — `uo.guild.detail` and its two siblings — which worked for
|
|
* exactly one module and silently did nothing for any other: a second game
|
|
* declaring `clan.detail` under its own id got an empty page and no error,
|
|
* because "a fill for a slot nobody declared is not an error" is the rule that
|
|
* makes an unknown name invisible. It also put a module identifier in core, in
|
|
* three string literals `scripts/checkModuleIdentifiers.js` cannot see, since it
|
|
* masks string bodies by construction.
|
|
*
|
|
* So the module says WHERE (its own slot, in its own vocabulary) and WHICH of
|
|
* core's contributions goes there. Core never names a module id.
|
|
*
|
|
* Adding a member here is a **minor** MODULE_API bump. Requesting one that is not
|
|
* here THROWS at the declaration, deliberately: unlike an unfilled slot, an
|
|
* unknown contribution is always a typo or a version skew — core's list is fixed
|
|
* at build time and a module's `coreApi` range has already been checked — and the
|
|
* failure it would otherwise produce is a page that renders empty forever.
|
|
*/
|
|
export const CORE_CONTRIBUTIONS = Object.freeze({
|
|
/** The Team activity feed. Core's because only core can resolve the public/members split on it. */
|
|
'team.activity': true,
|
|
/** The Team forum panel. Core's because membership and manual grants are core's rules. */
|
|
'team.forum': true,
|
|
/** The per-Team notification control. Core's because it resolves whether the viewer is in the Team. */
|
|
'team.notify': true,
|
|
})
|
|
|
|
/**
|
|
* The INVERTED direction: a MODULE declares a slot and CORE fills it.
|
|
*
|
|
* Added for Teams (TEAMS.md Part 3). The original direction assumes core owns
|
|
* the page and a module contributes to it, which is right for the footer and the
|
|
* admin user detail. Teams is the other shape: **Teams is a contract primitive,
|
|
* not a surface.** Core owns the tables, the sync, the access rules and the
|
|
* activity feed; it does not own the vocabulary — a UO shard calls them guilds
|
|
* and the next game will call them something else — so the PAGE is the module's
|
|
* and the content core contributes to it is core's.
|
|
*
|
|
* Without this, core would have to publish a `/teams` page under a word it
|
|
* invented, next to the module's own Guilds page saying the same thing twice.
|
|
*
|
|
* A module namespaces its slot under its own id (`uo.guild.detail`), which is
|
|
* what stops two modules colliding and what makes the owner readable at the fill
|
|
* site. The namespace is enforced rather than conventional.
|
|
*
|
|
* **`options.core` names which of core's contributions belongs in that place.**
|
|
* It is optional — a module may declare a slot it fills itself, or one it keeps
|
|
* empty for now — and it is the only thing that gets core's content into the
|
|
* page. The place name stays the module's own word; the contribution is core's.
|
|
*
|
|
* **Ordering is why this is a separate call and not just `declareSlot` exposed
|
|
* to modules.** Core's bundle evaluates BEFORE any module chunk (module scripts
|
|
* are deferred and injected after core's), so at the moment core would like to
|
|
* fill one of these, it does not exist yet. Core therefore offers its
|
|
* contributions through `offerCoreFill` below, applied after every module chunk
|
|
* has evaluated — see main.jsx.
|
|
*/
|
|
export function declareModuleSlot(id, name, options = {}) {
|
|
if (!name.startsWith(`${id}.`)) {
|
|
throw new Error(`declareModuleSlot: "${name}" must be namespaced "${id}."`)
|
|
}
|
|
if (slots.has(name)) throw new Error(`extension slot "${name}" already declared`)
|
|
const contribution = options.core ?? null
|
|
if (contribution !== null && !Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
|
throw new Error(
|
|
`declareModuleSlot: "${name}" asks for core contribution "${contribution}", which core does not ` +
|
|
`offer. Known: ${Object.keys(CORE_CONTRIBUTIONS).join(', ')}.`,
|
|
)
|
|
}
|
|
slots.set(name, { Component: null, filledBy: null, declaredBy: id, wants: contribution })
|
|
}
|
|
|
|
// Core's pending contributions, applied once every module chunk has evaluated.
|
|
// Kept as a list rather than applied eagerly because no module-declared slot
|
|
// exists when core offers — see the ordering note above.
|
|
const coreFills = []
|
|
|
|
/**
|
|
* Core: "here is my <contribution>, for whichever module asked for it."
|
|
*
|
|
* Deliberately not an error when nothing asked. A deployment with no game module
|
|
* installed asks for none of these, and core offering content for a page that
|
|
* does not exist is the ordinary case rather than a misconfiguration — the mirror
|
|
* of an unfilled slot rendering nothing.
|
|
*
|
|
* More than one slot may ask for the same contribution, and each gets it. Core
|
|
* has no reason to care how many places a module wants its feed in, and refusing
|
|
* the second would be core making a layout decision on a page it does not own.
|
|
*/
|
|
export function offerCoreFill(contribution, Component) {
|
|
if (!Object.hasOwn(CORE_CONTRIBUTIONS, contribution)) {
|
|
throw new Error(`offerCoreFill: "${contribution}" is not in CORE_CONTRIBUTIONS`)
|
|
}
|
|
if (typeof Component !== 'function') throw new Error(`offerCoreFill: ${contribution} is not a component`)
|
|
coreFills.push([contribution, Component])
|
|
}
|
|
|
|
/** Apply core's contributions. Called once from main.jsx, after module chunks have run. */
|
|
export function applyCoreFills() {
|
|
for (const [contribution, Component] of coreFills) {
|
|
for (const entry of slots.values()) {
|
|
if (entry.wants !== contribution) continue
|
|
if (entry.filledBy) continue // a module already claimed it; first fill wins
|
|
entry.Component = Component
|
|
entry.filledBy = 'core'
|
|
}
|
|
}
|
|
coreFills.length = 0
|
|
}
|
|
|
|
/**
|
|
* 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()
|
|
coreFills.length = 0
|
|
// 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,
|
|
// The inverted direction (TEAMS.md Part 3): the module declares, core fills.
|
|
declareModuleSlot,
|
|
routesFor,
|
|
navFor,
|
|
featureProviderFor,
|
|
registeredIds,
|
|
}
|