feat(client): the whole client half (phase 3, slice 3)
The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
// ── module-uo's client entry point ─────────────────────────────────────────
|
||||
//
|
||||
// This file is the whole of the chunk's top-level behaviour: core injects
|
||||
// `dist/entry.js` as a `<script type="module" src>` before `</body>`, the module
|
||||
// registers what it has, and core renders it. The normative contract is
|
||||
// MODULE_API.md §3.3.
|
||||
// Core injects `dist/entry.js` as a `<script type="module" src>` before
|
||||
// `</body>`, this file registers what the module has, and core renders it. The
|
||||
// normative contract is MODULE_API.md §3.3.
|
||||
//
|
||||
// **Registration is synchronous and happens at evaluation time.** Module scripts
|
||||
// are deferred, so this runs after core's bundle — which is where `window.__rg`
|
||||
@@ -14,69 +13,177 @@
|
||||
// bug cost the Phase 2 client PR an afternoon and no unit test in either repo
|
||||
// can see it, which is why §7.7's browser smoke exists.
|
||||
//
|
||||
// Slice 0 of the Phase 3 extraction (MODULE_SYSTEM.md §2.7.1) registers NOTHING,
|
||||
// on purpose. What it proves is the delivery path itself, and the imports below
|
||||
// are how it proves the hardest part of it.
|
||||
// So everything below is a plain top-level call, and every page is a static
|
||||
// import. Lazy-loading the routes would be the natural instinct for a chunk this
|
||||
// size and it is the one thing this seam cannot have.
|
||||
|
||||
// These four specifiers are the whole shared-dependency contract, written the
|
||||
// ordinary way — which is the point. `vite.config.js` aliases each to a shim
|
||||
// that re-exports from `window.__rg`, so what ends up in the chunk is core's
|
||||
// React, core's renderer and core's router, and no second copy of any of them.
|
||||
// A module author writes these imports exactly as they would in any app.
|
||||
import { registry, coreApiVersion } from './core.js'
|
||||
import { IconShard } from './icons.jsx'
|
||||
import { useShardFlags } from './lib/useShardFeatures.js'
|
||||
|
||||
// Public pages — the twelve that used to live at /site/*.
|
||||
import Shard from './routes/public/Shard.jsx'
|
||||
import ShardActivity from './routes/public/ShardActivity.jsx'
|
||||
import ChampSpawns from './routes/public/ChampSpawns.jsx'
|
||||
import Guilds from './routes/public/Guilds.jsx'
|
||||
import Governors from './routes/public/Governors.jsx'
|
||||
import Houses from './routes/public/Houses.jsx'
|
||||
import Rules from './routes/public/Rules.jsx'
|
||||
import Atlas from './routes/public/Atlas.jsx'
|
||||
import AtlasCreature from './routes/public/AtlasCreature.jsx'
|
||||
import Leaderboards from './routes/public/Leaderboards.jsx'
|
||||
import Market from './routes/public/Market.jsx'
|
||||
import MarketVendor from './routes/public/MarketVendor.jsx'
|
||||
|
||||
// Admin views.
|
||||
import ShardAdmin from './routes/admin/ShardAdmin.jsx'
|
||||
import ShardOps from './routes/admin/ShardOps.jsx'
|
||||
import ShardVisibility from './routes/admin/ShardVisibility.jsx'
|
||||
import SpawnAtlas from './routes/admin/SpawnAtlas.jsx'
|
||||
import HousesAdmin from './routes/admin/HousesAdmin.jsx'
|
||||
import AdminCharacters from './routes/admin/AdminCharacters.jsx'
|
||||
import AdminCharacter from './routes/admin/AdminCharacter.jsx'
|
||||
|
||||
// Player-portal views.
|
||||
import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
|
||||
import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
|
||||
|
||||
// Extension-slot fills (§3.7) — module content inside a core page.
|
||||
import ShardStatusLink from './components/ShardStatusLink.jsx'
|
||||
import UserShardSections from './routes/admin/UserShardSections.jsx'
|
||||
import InviteGameAccountStep from './components/InviteGameAccountStep.jsx'
|
||||
|
||||
const ID = 'uo'
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// They are here in slice 0 rather than arriving with the first page because an
|
||||
// unexercised alias is an unproven one: with nothing importing `react`, the
|
||||
// build emits a 0.2 kB chunk, `checkExternals` passes vacuously, and the seam
|
||||
// this whole slice exists to prove has not been touched.
|
||||
import { createElement, isValidElement } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
// Paths are relative to this module's namespace and core prefixes them:
|
||||
// `/uo/…`, `/admin/uo/…`, `/player/uo/…`. A module cannot write the segment its
|
||||
// routes hang under however it spells `path`, which is the point.
|
||||
//
|
||||
// **These SPA paths changed and the API paths did not.** `/site/shard` is now
|
||||
// `/uo/shard` and `/admin/shard-ops` is now `/admin/uo/ops` — a clean break with
|
||||
// no redirects, settled in MODULE_SYSTEM.md §2.7. Every URL in `api.js` is
|
||||
// byte-identical to the one core called, because §1.2 freezes the API surface
|
||||
// and the shipped Android app calls seven of these routes.
|
||||
//
|
||||
// The admin paths lost their `shard-` prefixes on the way through: under a `/uo/`
|
||||
// namespace `/admin/uo/shard-visibility` says "shard" twice, and a clean break is
|
||||
// the only moment that tidy-up is free.
|
||||
//
|
||||
// `gate` is core's own RoleGate, applied by core. A module cannot supply an auth
|
||||
// wrapper — the sidebar and the route table have to agree about who may see what.
|
||||
const STAFF = { roles: ['admin', 'moderator'] }
|
||||
|
||||
const rg = window.__rg
|
||||
registry.registerRoutes(ID, {
|
||||
public: [
|
||||
{ path: 'shard', element: <Shard /> },
|
||||
{ path: 'shard/activity', element: <ShardActivity /> },
|
||||
{ path: 'champs', element: <ChampSpawns /> },
|
||||
{ path: 'guilds', element: <Guilds /> },
|
||||
{ path: 'governors', element: <Governors /> },
|
||||
{ path: 'houses', element: <Houses /> },
|
||||
{ path: 'rules', element: <Rules /> },
|
||||
{ path: 'atlas', element: <Atlas /> },
|
||||
{ path: 'atlas/:slug', element: <AtlasCreature /> },
|
||||
{ path: 'leaderboards', element: <Leaderboards /> },
|
||||
{ path: 'market', element: <Market /> },
|
||||
{ path: 'market/vendors/:serial', element: <MarketVendor /> },
|
||||
],
|
||||
admin: [
|
||||
// Admin-only: the sidecar's configuration, who may see which surface, and
|
||||
// the atlas import. No `gate` on the other three because AdminLayout already
|
||||
// requires staff and these carry their own role rows below.
|
||||
{ path: 'link', element: <ShardAdmin /> },
|
||||
{ path: 'visibility', element: <ShardVisibility /> },
|
||||
{ path: 'atlas', element: <SpawnAtlas /> },
|
||||
{ path: 'ops', element: <ShardOps />, gate: STAFF },
|
||||
{ path: 'houses', element: <HousesAdmin />, gate: STAFF },
|
||||
// Self-service, and deliberately ungated: a staff member's own characters
|
||||
// are theirs to read whatever their role. Staff are a superset of players.
|
||||
{ path: 'characters', element: <AdminCharacters /> },
|
||||
{ path: 'characters/:serial', element: <AdminCharacter /> },
|
||||
],
|
||||
player: [
|
||||
{ path: 'characters', element: <PlayerCharacters /> },
|
||||
{ path: 'characters/:serial', element: <PlayerCharacter /> },
|
||||
],
|
||||
})
|
||||
|
||||
// A module that cannot see the global is a module core did not load — which
|
||||
// means the injection or the ordering broke, not the module. Say so, once,
|
||||
// rather than throwing a TypeError about a property of undefined three frames
|
||||
// deep in a component.
|
||||
if (!rg) {
|
||||
console.error('[module-uo] window.__rg is missing — core did not publish its shared dependencies before this chunk evaluated.')
|
||||
} else {
|
||||
// JSX, so the `react/jsx-runtime` alias is exercised too. That one is the
|
||||
// easiest of the four to get wrong and the hardest to notice: Vite's
|
||||
// object-form alias prefix-matches, so a `react` key silently captures
|
||||
// `react/jsx-runtime` as well, and the failure surfaces as `jsx is not a
|
||||
// function` in whichever component happens to render first.
|
||||
const probe = <span>module-uo</span>
|
||||
// ── Nav ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Rows interleave into CORE groups rather than appending as a "UO" block, which
|
||||
// is what keeps the extraction invisible in the sidebar (MODULE_SYSTEM.md §1.4).
|
||||
//
|
||||
// `feature` names a flag resolved by the provider registered below — by THIS
|
||||
// module, so the strings are the bare names they have always been and nothing
|
||||
// parses a namespace out of them.
|
||||
registry.registerNav(ID, {
|
||||
area: 'public',
|
||||
items: [
|
||||
{ label: 'Shard', to: '/uo/shard', feature: 'status' },
|
||||
{ label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
||||
{ label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
||||
{ label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
||||
{ label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
||||
{ label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
||||
{ label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
||||
{ label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
||||
{ label: 'Market', to: '/uo/market', feature: 'market' },
|
||||
],
|
||||
})
|
||||
|
||||
// The self-check: are the bindings this chunk imported the SAME objects core
|
||||
// published? Identity is the only question worth asking. A bundled second
|
||||
// React satisfies every type check, renders its first element happily, and
|
||||
// then throws about an invalid hook call somewhere unrelated.
|
||||
const shared = [
|
||||
['react', createElement === rg.react.createElement],
|
||||
['react/jsx-runtime', isValidElement(probe)],
|
||||
['react-dom/client', createRoot === rg.reactDom.createRoot],
|
||||
['react-router-dom', Link === rg.router.Link],
|
||||
]
|
||||
const bundled = shared.filter(([, ok]) => !ok).map(([name]) => name)
|
||||
registry.registerNav(ID, {
|
||||
area: 'admin',
|
||||
items: [
|
||||
// Moderation: no `order`, because these two are last in that group today and
|
||||
// "append after core's rows" is exactly that — and stays that way if core
|
||||
// adds a moderation row later, which an explicit index would not.
|
||||
{ label: 'In-Game Ops', to: '/admin/uo/ops', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
{ label: 'Houses', to: '/admin/uo/houses', icon: IconShard, group: 'Moderation', roles: ['admin', 'moderator'] },
|
||||
// System: these three sit MID-list, between Discord Bot and Web Bot Activity.
|
||||
// Core's rows are keyed by their index and an explicit `order` beats a
|
||||
// coincidental one at a tie, so all three asking for 8 — Web Bot Activity's
|
||||
// index once the UO rows are gone — lands them ahead of it, in this order.
|
||||
{ label: 'Shard (uo-link)', to: '/admin/uo/link', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Shard Visibility', to: '/admin/uo/visibility', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
{ label: 'Spawn Atlas', to: '/admin/uo/atlas', icon: IconShard, group: 'System', order: 8, roles: ['admin'] },
|
||||
// No group: a trailing untitled group of its own, below core's Account row
|
||||
// rather than beside it (§3.3). One position lower than it sits today, and
|
||||
// the alternative — letting a module into core's furniture groups — is worse.
|
||||
{ label: 'My Characters', to: '/admin/uo/characters', icon: IconShard },
|
||||
],
|
||||
})
|
||||
|
||||
if (bundled.length) {
|
||||
console.error(
|
||||
`[module-uo] ${bundled.join(', ')} did not come from window.__rg — the chunk has bundled its own copy. ` +
|
||||
'Check the aliases in vite.config.js (MODULE_API.md §3.6).',
|
||||
)
|
||||
} else {
|
||||
// Registrations land here, slice by slice:
|
||||
//
|
||||
// rg.registry.registerRoutes('uo', { public: [...], admin: [...], player: [...] })
|
||||
// rg.registry.registerNav('uo', { area: 'public', items: [...] })
|
||||
// rg.registry.registerFeatureProvider('uo', 'uo', useShardFeatures)
|
||||
//
|
||||
// `MODULE_API_VERSION` is checked by core against `module.json`'s `coreApi`
|
||||
// before this file is ever served, so there is nothing to re-check here. It
|
||||
// is logged because a mismatch between the core that validated the manifest
|
||||
// and the core that published this global would otherwise be invisible from
|
||||
// the browser, which is where the client half actually fails.
|
||||
console.info(`[module-uo] loaded against core API ${rg.version}; shared dependencies OK`)
|
||||
}
|
||||
}
|
||||
registry.registerNav(ID, {
|
||||
area: 'player',
|
||||
// Order 0: Characters is the portal's first row today, and with the module
|
||||
// installed it is also what core's `/player` index resolves to.
|
||||
items: [{ label: 'Characters', to: '/player/uo/characters', order: 0 }],
|
||||
})
|
||||
|
||||
// ── Feature provider ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Core keeps a generic flag context and owns none of the semantics. Until this
|
||||
// slice core registered this same hook itself under owner id `core`, so that the
|
||||
// seam was exercised by real content from the day it was built; the registration
|
||||
// moves here and core's is deleted.
|
||||
registry.registerFeatureProvider(ID, ID, useShardFlags)
|
||||
|
||||
// ── Extension slots ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Three core pages have a piece of this module in them. Each was core's own fill
|
||||
// under owner id `core` until this slice, so all three are a swap rather than an
|
||||
// addition — and each throws rather than failing open if the slot is unknown or
|
||||
// already filled, which is how a slice that forgot to delete core's half finds
|
||||
// out immediately instead of rendering core's content forever (§3.7).
|
||||
registry.registerExtension(ID, 'site.footer.status', ShardStatusLink)
|
||||
registry.registerExtension(ID, 'admin.users.detail', UserShardSections)
|
||||
registry.registerExtension(ID, 'player.invite.accepted', InviteGameAccountStep)
|
||||
|
||||
// `module.json`'s `coreApi` range is checked by the loader before this file is
|
||||
// ever served, so there is nothing to re-check here. It is logged because a
|
||||
// mismatch between the core that validated the manifest and the core that
|
||||
// published this global would otherwise be invisible from the browser, which is
|
||||
// where the client half actually fails.
|
||||
console.info(`[module-uo] registered against core API ${coreApiVersion}`)
|
||||
|
||||
Reference in New Issue
Block a user