docs(website): client extension slots, and the client half is one slice
Two findings from surveying the client half, and one org-lead decision that turns them into the same piece of work. The client half does not divide into public and admin after all. Routes slice by area, but useShardFeed and shardEvents are imported by eight public pages AND three admin views, and a shared leaf moves with its last consumer -- the same rule the server half taught. Of the three ways out, two trade a real cost for a boundary that lasts one review, so the client half is one slice. The nav rows keep their feature provider as a consequence (rows without it fail open, so disabled surfaces would advertise themselves again), and VendorSales turns out to have no public consumer at all. Its three consumers include core's own UserDetail, which renders six UO sections with nowhere to put them: the server got declareSlot/registerExtension in phase 2 PR 4 and the client never got the twin. SiteFooter's link to a URL the extraction deletes is the same gap one component over. So slice 2 is core-only and adds rather than moves: client extension slots (API 3.7), the site.footer.status and admin.users.detail slots, core filling both itself. MODULE_API_VERSION goes to 1.2.0. A slot is named for a PLACE, never for a meaning -- core supplies the position and the styling, the module owns the label, the target, the data and whether it renders at all. Typing a slot by its content would put game semantics back into core, which is what this phase removes. This is also the one slice where core merges before module-uo, because a chunk cannot call registerExtension before the function exists. Harmless here: the slice only adds, and core fills both slots with its own components under owner id `core`, so the page is unchanged and the mechanism is proved before anything moves. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -26,13 +26,17 @@ here extends the contract first, in this file, before the module is written agai
|
||||
Core exports a single integer-major semver string from `server/src/modules/version.js`:
|
||||
|
||||
```js
|
||||
const MODULE_API_VERSION = '1.1.0'
|
||||
const MODULE_API_VERSION = '1.2.0'
|
||||
```
|
||||
|
||||
The client half carries the same number (`client/src/modules/version.js`) and a test asserts the two
|
||||
agree. Duplicated rather than fetched because the value has to be on `window.__rg` before the first
|
||||
module chunk evaluates, which is earlier than any network round trip could answer.
|
||||
|
||||
**1.2.0 — Phase 3, the client half.** `registry` gained `registerExtension` and core gained client
|
||||
extension slots (§3.7). An addition only, and the first change to `window.__rg` since 1.0.0 — the
|
||||
server half is untouched, and both files bump because the two halves state ONE version.
|
||||
|
||||
**1.1.0 — Phase 3 slice 1.** `ctx` gained `activity.log`, `users.getById`, `site.baseUrl`, and
|
||||
`middleware.rateLimit` + `middleware.accountChangeLimiter`; `api` gained `registerPostHook`.
|
||||
Additions only. Each exists because module-uo's extraction needed it and none could be vendored — an
|
||||
@@ -540,6 +544,7 @@ on a mismatch, logging once — the client-side twin of §1.1, and the reason `v
|
||||
registry.registerRoutes(id, { public: [...], admin: [...], player: [...] })
|
||||
registry.registerNav(id, { area, items })
|
||||
registry.registerFeatureProvider(id, namespace, hook)
|
||||
registry.registerExtension(id, slot, Component) // 1.2.0 — §3.7
|
||||
```
|
||||
|
||||
**`registerRoutes`** — arrays of `{ path, element, gate? }`. Paths are relative to the module's
|
||||
@@ -762,6 +767,74 @@ decided here.
|
||||
approach; slice 0 proved the configuration, in a browser, under the enforced `script-src 'self'`,
|
||||
by checking each imported binding is **identity-equal** to the one core published.
|
||||
|
||||
### 3.7 Extension slots — module content inside a core page
|
||||
|
||||
Added in **1.2.0** (settled 2026-08-11). The client twin of §2.4's `registerExtension`, and the same
|
||||
rule in both halves: **core declares a slot, only core may declare one, and at most one module may
|
||||
fill it.**
|
||||
|
||||
```js
|
||||
registry.registerExtension(id, slot, Component)
|
||||
```
|
||||
|
||||
Core renders a slot with `<Slot name="…" {...props} />`, which renders the filling component with
|
||||
those props — or **nothing at all** when the slot is unfilled. An instance with no module installed
|
||||
therefore renders exactly what it renders today, which is the same "untouched path" guarantee
|
||||
`withModuleNav` makes for nav.
|
||||
|
||||
The slots in 1.2.0:
|
||||
|
||||
| Slot | Rendered in | Props core passes |
|
||||
| --- | --- | --- |
|
||||
| `site.footer.status` | `components/SiteFooter.jsx`, in the info row | `linkStyle` — the row's own link styling |
|
||||
| `admin.users.detail` | `routes/admin/views/UserDetail.jsx`, below the security panel | `userId` |
|
||||
|
||||
**A slot is named for a PLACE, never for a meaning.** `site.footer.status` is "the status-ish spot in
|
||||
the footer", not a declaration that core knows what a game server's status is: core supplies the
|
||||
position and the styling, and the module owns the label, the target, the data and whether it renders
|
||||
anything at all. This is the whole point of the mechanism in a game-agnostic core — the moment core
|
||||
types a slot by its content, it has re-acquired the semantics Phase 3 exists to remove, and the next
|
||||
module wanting that spot for something else needs a second mechanism.
|
||||
|
||||
**`admin.users.detail` is deliberately the same name as the server slot** (§2.4). One resource, one
|
||||
extension point, two halves — a module that adds routes under `/api/v1/admin/users/:id` is the module
|
||||
that has something to show on that page, and giving the two halves one name means an operator reading
|
||||
either side sees the same word.
|
||||
|
||||
`userId` is the only prop that slot gets, and not `scope`: core's `api.admin.userShard(id)` is a UO
|
||||
binding that leaves core in the client half of Phase 3, so a slot that handed it over would hand over
|
||||
something core is about to delete. The extension builds its own client for the routes it registered
|
||||
at the other end, which is §3.5's rule applied to a slot.
|
||||
|
||||
**Errors are contained.** Core renders a filled slot inside an error boundary: a component that
|
||||
throws costs its own section and a console error, never core's page. That asymmetry is deliberate and
|
||||
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 the page is
|
||||
that it stays usable.
|
||||
|
||||
**Malformed registrations throw, and that matches the server.** An unknown slot name, a non-function
|
||||
component, or a second module filling a filled slot all throw at the call, exactly as
|
||||
`checkExtensionShape` does server-side. This is the one place the client registry is not fail-open:
|
||||
a dropped nav row costs a link the user can reach another way, while a silently dropped extension is
|
||||
invisible to everyone including its author, and the ordering guarantee below means a throw here is
|
||||
always a programming error and never a race.
|
||||
|
||||
**Declaration always precedes filling, structurally.** Core declares its slots in `main.jsx`, which
|
||||
runs in core's own bundle; module chunks are deferred scripts injected after it (§3.1). So a module
|
||||
can never fill a slot that has not been declared yet, and "unknown slot" always means a typo or a
|
||||
version skew rather than a load-order accident.
|
||||
|
||||
**Core fills its own slots through the same seam** — `registerExtension('core', …)` in `main.jsx`,
|
||||
the client twin of the server's `registries.registerCore()` and the same trick `useShardFlags`
|
||||
already uses for the feature seam. It means the mechanism is exercised by core's own content from the
|
||||
moment it lands, and the extraction becomes a deletion rather than a rewrite made under extraction
|
||||
pressure.
|
||||
|
||||
`declareSlot` and `hasExtension` are module exports of `modules/registry.js` and deliberately **not**
|
||||
members of the `registry` object handed to modules, for the same reason `featureProviders()` is not:
|
||||
declaring is core's, and a module asks about a slot it knows the name of rather than enumerating what
|
||||
everyone else filled.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — The loader's obligations
|
||||
|
||||
Reference in New Issue
Block a user