docs(website): client extension slots, and the client half is one slice (phase 3, slice 2) #137
@@ -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,92 @@ 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.
|
||||
|
||||
**Core decorates a slot with `<Slot wrap>`, never by asking whether it is filled.** `wrap` is called
|
||||
with the extension's element and rendered *inside* the boundary, so core's own markup around an
|
||||
extension — a separator, a heading, a rule — shares the extension's fate:
|
||||
|
||||
```jsx
|
||||
<Slot name="site.footer.status" linkStyle={LINK_STYLE} wrap={(link) => <> · {link}</>} />
|
||||
```
|
||||
|
||||
There is deliberately no `hasExtension` for a layout to branch on. Branching is right about the
|
||||
unfilled case and wrong about the failed one: the slot *is* filled, so the separator renders, and
|
||||
the component then throws into the boundary and leaves the separator behind on its own. Found in a
|
||||
browser with this exact footer separator, which is the only place it could have been found.
|
||||
|
||||
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.
|
||||
|
||||
**While core fills a slot, no module can** — first fill wins, and core registers first. In 1.2.0 that
|
||||
means both slots are occupied and a module's fill is rejected, loudly, naming `core`. This is
|
||||
scaffolding, not the steady state: core's two fills and the two files behind them are deleted by the
|
||||
client half of Phase 3, in the same change that registers the module's. Neither slot is one core
|
||||
intends to fill permanently, and a slot core *does* intend to fill is a slot that should not exist.
|
||||
|
||||
`declareSlot` and `extensionFor` 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 so is reading back who filled what.
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — The loader's obligations
|
||||
|
||||
@@ -779,9 +779,9 @@ Each slice is one `module-uo` PR (adds), one `website` PR (deletes), and one `do
|
||||
| --- | --- | --- |
|
||||
| 0 | **The bundle skeleton** | `module.json`, both `package.json`s, `server/index.js` registering nothing, the Vite library build + the four shared-dep shims, CI armed, the §5.1 zero-internal-imports check. `website` untouched. |
|
||||
| 1 | **The whole server half** | 40 files / ~9,674 lines, 25 of core's 82 test files, 27 of its 68 tables — every UO model, util, router and controller, `config/shardStreams.js`, `scripts/importSpawnAtlas.js` and the art JSON. **One merge, five commits** (below). |
|
||||
| 2 | **Public pages** | `Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`, `PlayersOnline`, `VendorSales`, `data/cityCrests.js`, `lib/shardEvents.js`, `lib/useShardFeed.js`, and their public nav rows — under `/uo/*` per §2.8 |
|
||||
| 3 | **Admin + player pages** | `ShardAdmin`, `ShardOps`, `ShardVisibility`, `SpawnAtlas`, `HousesAdmin`, `AdminCharacter(s)`, `PlayerCharacter(s)`, `GameAccounts`, `CharacterSheet`, `CharacterStats`, `ShardAccountActions`, `CreateGameAccountForm`, `useShardFeatures` and the `useShardFlags` feature provider — under `/admin/uo/*` and `/player/uo/*` |
|
||||
| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `SiteFooter`, `heroLayout`'s defaults, `api/client.js`'s `shard`/`atlas` namespaces, and the comments in `navOverrides.js` — plus the §5.2 CI grep that keeps them out |
|
||||
| 2 | **Client extension slots** | Core only, and the one slice that adds rather than moves: the client twin of `declareSlot`/`registerExtension` (API §3.7), the `site.footer.status` and `admin.users.detail` slots, and core filling both itself. `module-uo` untouched. |
|
||||
| 3 | **The whole client half** | 51 files / ~3,700 lines — all twelve public pages (`Shard`, `ShardActivity`, `Rules`, `Atlas`, `AtlasCreature`, `ChampSpawns`, `Market`, `MarketVendor`, `Governors`, `Guilds`, `Houses`, `Leaderboards`) under `/uo/*`, every admin and player view under `/admin/uo/*` and `/player/uo/*`, `PlayersOnline`, `VendorSales`, `CharacterStats`, `GameAccounts`, the `data/` and `lib/` UO leaves, the public nav rows, the feature provider, and both slot fills. |
|
||||
| 4 | **De-UO core's copy** | `About`, `Screenshots`, `Website`, `SiteFooter`'s prose, `heroLayout`'s defaults, `api/client.js`'s `shard`/`atlas` namespaces, and the comments in `navOverrides.js` — plus the §5.2 CI grep that keeps them out |
|
||||
| 5 | **Close the phase** | `module-uo`'s frozen route manifest and release workflow; `docs/modules/uo/` and `docs/modules/rust-dryrun.md` |
|
||||
|
||||
##### Why the server half cannot be sliced — found 2026-08-11, before writing any of it
|
||||
@@ -826,6 +826,91 @@ branches, and commits give most of the same reading order for none of it.
|
||||
*area*, with no prefix atomicity and no shared models — which is the same asymmetry that let the two
|
||||
halves be separated in the first place.
|
||||
|
||||
##### Why the client half is one slice after all — settled 2026-08-11
|
||||
|
||||
The paragraph above is right about the *mechanism* and wrong about the outcome. Routes do slice by
|
||||
area, but the files behind them do not divide along that line, and the reason is the same rule the
|
||||
server half taught: **a shared leaf moves with its last consumer.**
|
||||
|
||||
`lib/useShardFeed.js` and `lib/shardEvents.js` are imported by eight of the public pages *and* by
|
||||
three admin views. Splitting public from admin means the module needs them one slice before core is
|
||||
finished with them, and there are only three ways out — the module vendors a copy for one slice,
|
||||
public keeps only the four pages that never touch the live feed, or the two slices become one. The
|
||||
first two both trade a real cost for a boundary that lasts one review, so the client half is **one
|
||||
slice**: all twelve public pages, every admin and player view, and the leaves under them, in one
|
||||
`module-uo` PR and one `website` PR.
|
||||
|
||||
Two things that were separately tabled fold into it as a consequence, and both are improvements:
|
||||
|
||||
- **The nav rows and their feature provider stay one unit.** The nine UO rows in the public header
|
||||
carry `feature` gates resolved by `useShardFlags`, which core registers under namespace `uo`.
|
||||
Since resolution is by the *registering* module (§1.5), rows that move without their provider
|
||||
resolve against a namespace nothing answers for — and everything fails open, so nine rows an
|
||||
operator may have disabled or gated to staff would advertise themselves again for the length of a
|
||||
slice. `useShardFeatures.js` already says as much in its own closing comment.
|
||||
- **`VendorSales` was tabled with the public pages and has no public consumer at all.** Its three are
|
||||
`AdminCharacters`, `PlayerCharacters` and core's own `UserDetail` — which is the next finding.
|
||||
|
||||
##### Core's user-detail page needs a client extension slot — and so does the footer
|
||||
|
||||
`UserDetail.jsx` renders a core header, core's `SecurityAdmin`, and then six UO sections. The server
|
||||
half already had somewhere to put that: Phase 2 PR 4 declared the `admin.users.detail` slot and moved
|
||||
the six `/admin/users/:id/shard/*` routes behind it. **The client never got the twin** — `registry`
|
||||
takes routes, nav and feature providers, and nothing else — so the client half had nowhere to put the
|
||||
same page's other half.
|
||||
|
||||
The same gap shows up one component over. `SiteFooter` links to `/site/shard`, a URL the extraction
|
||||
deletes, and the footer is not nav so no registry answers for it.
|
||||
|
||||
Both are the same missing mechanism, and it is **slice 2**, core-only: core declares a slot, at most
|
||||
one module fills it, and core renders `<Slot>` — nothing when unfilled. The contract is
|
||||
[API §3.7](MODULE_API.md#37-extension-slots--module-content-inside-a-core-page); `MODULE_API_VERSION`
|
||||
goes to **1.2.0**.
|
||||
|
||||
Two decisions inside it, both settled with the org lead 2026-08-11:
|
||||
|
||||
- **The footer slot is named for a place, not for a meaning.** The idea started as "make shard status
|
||||
a hook any module can use", which is right, and the only refinement is that core must not learn
|
||||
what a game server's status *is*. `site.footer.status` is a position and a bit of styling; the
|
||||
label, the target, the data and whether anything renders at all are the module's. A slot typed by
|
||||
its content would put game semantics back in core, which is the thing this phase removes.
|
||||
- **This slice inverts the phase's merge order, once.** Everywhere else `module-uo` merges before
|
||||
`website` so `edge` is never missing a feature. Here core must go first, because a module chunk
|
||||
cannot call `registry.registerExtension` before the function exists. That is harmless precisely
|
||||
because this slice only *adds*: core declares both slots and fills them with its own existing
|
||||
components under owner id `core` — the same trick `useShardFlags` and the server's
|
||||
`registries.registerCore()` already use — so the rendered page is unchanged and the mechanism is
|
||||
proved by core's own content before a line of it moves.
|
||||
|
||||
#### Slice 2 — the slots (website#138, 2026-08-11)
|
||||
|
||||
`modules/registry.js` grows `declareSlot` / `registerExtension` / `extensionFor`; `modules/Slot.jsx`
|
||||
is the read side and core's only error boundary. `SiteFooter`'s link becomes `ShardStatusLink.jsx`
|
||||
and `UserDetail`'s six UO sections become `UserShardSections.jsx`, both registered by `main.jsx`
|
||||
under owner id `core`. 616 server + **169 client** tests (+9), manifest still 158, swagger
|
||||
byte-identical.
|
||||
|
||||
**The §7.7 browser smoke earned its place again, and this time by finding something no test would
|
||||
have.** A throwaway module filled both slots: the footer rendered the module's own label and target
|
||||
in core's `linkStyle`, the admin page received `userId`, and a deliberate render failure was
|
||||
contained to its own spot with the slot named in the console — but the footer was left showing
|
||||
`email · · Admin`. The separator was rendered *beside* the slot, guarded on `hasExtension`, which is
|
||||
right when nothing is installed and wrong when something is installed and fails. Core now decorates
|
||||
through `<Slot wrap>`, inside the boundary, and `hasExtension` is gone rather than kept as a trap for
|
||||
the next caller. Every unit test passed both before and after.
|
||||
|
||||
Two more things the smoke settled, neither of them a defect:
|
||||
|
||||
- **Core's own fill occupies the slot, so a module cannot fill it** — first fill wins and core
|
||||
registers first, so the smoke module's call was rejected naming `core`. That is correct and
|
||||
temporary: the client half deletes core's two fills in the change that registers the module's. It
|
||||
is worth stating in the contract, because a module written against 1.2.0 today cannot use either
|
||||
slot.
|
||||
- **Core's UO sections on the user-detail page now fail to load, and that is slice 1's doing, not
|
||||
this slice's** — core stopped serving `/admin/users/:id/shard/*` when the server half left, and
|
||||
with no module installed there is nothing at the other end. Identical before and after this change;
|
||||
the client half is what puts a live module behind it.
|
||||
|
||||
**Criterion 1 is a grep over code, not over prose** — see [API §5.2](MODULE_API.md#52-zero-uo-identifiers-in-core-ci-website-repo)
|
||||
for what that means precisely. Core's marketing copy says "shard" in a dozen places, and a literal
|
||||
word grep would have made every one of them a CI failure while proving nothing about the boundary.
|
||||
|
||||
Reference in New Issue
Block a user