Org lead's correction, and it changes what this phase ships.
TEAMS.md §3.1 and §3.5 put four public pages and three nav rows in core. They
should never have been core's. **Teams is the platform primitive that the API
contract exposes; the module builds the pages on top of it.** module-uo builds
guilds; the Rust module that comes next builds clans. Core does not own the word
for a Team, so a core page under a noun core invented would have sat beside
module-uo's existing /uo/guilds saying the same thing in the wrong vocabulary.
Removed: /teams, /teams/:slug, /teams/:slug/roster, /player/teams, the public
and portal nav rows, the `teams` feature flag and the core feature provider that
answered it. /admin/teams stays — an operator inspecting the primitive is
looking at the primitive.
Kept, and unchanged: the tables, the reconciler, the access resolver, the
activity feed, the retention prune, the whole public/player/admin API,
optionalAuth and the roster projection. That is the contract, and it is what
this phase was actually for.
**So the extension slots invert, which is a new direction in MODULE_API §3.7.**
`team.overview` and `team.member.row` assumed core rendered the page. In their
place `registry.declareModuleSlot(id, name)` lets a MODULE declare a place on
its own page and core fill it. Core fills `uo.guild.detail` with the Team
activity feed — the one part of that page core cannot hand over, because only
core can resolve whether the viewer is inside the Team and the public/members
split is a security boundary.
Three things about the inverted direction are load-bearing:
- the name is namespaced under the declaring module and that is enforced, not
conventional: it is the only thing keeping two modules off one name;
- core's fills are applied at MOUNT rather than eagerly. Core's bundle
evaluates before every module chunk, so when core registers a fill the slot
does not exist yet — filling eagerly would silently do nothing;
- a fill for a slot nobody declared is a no-op, never an error. The declaring
module is simply not installed, which is the ordinary case. That is the
opposite of §3.7, where an unknown slot throws, and the asymmetry is real:
there, core declares first, so an unknown name is always a typo.
`Slot` becomes the eighth member of the shared UI kit, so a module renders the
place with core's own error boundary. It matters more here than anywhere else in
the kit: the thing being contained is core's content failing inside the module's
page.
`GET /public/teams/by-external/:moduleId/:externalId` is added because a module
names a Team in its own vocabulary and core keys the feed by slug. The module id
is matched rather than trusted — an external id is unique only within a module.
Co-Authored-By: Claude <noreply@anthropic.com>
112 lines
5.2 KiB
JavaScript
112 lines
5.2 KiB
JavaScript
// ── window.__rg — the shared-dependency global ─────────────────────────────
|
|
//
|
|
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the normative shape is
|
|
// docs/website/MODULE_API.md §3.2.
|
|
//
|
|
// A module's client half is a PREBUILT ESM chunk — the operator never builds
|
|
// anything (MODULE_SYSTEM.md §1.14) — served same-origin and loaded under
|
|
// `script-src 'self'` with no 'unsafe-inline'. That combination is what rules out
|
|
// an import map: an import map has to be an inline `<script type="importmap">`,
|
|
// and the policy forbids inline scripts outright. So the shared dependencies ride
|
|
// on a global, and the module's Rollup externals are aliased to two-line shims
|
|
// that re-export from it (§3.6).
|
|
//
|
|
// **There is exactly one React in the page and core owns it.** A module that
|
|
// bundled its own would get a second hook dispatcher and fail at its first
|
|
// useState. That is the same rule the server half enforces for `express` and
|
|
// `express-validator` on `ctx`, and for the same reason: anything shared between
|
|
// core and a module is owned by core and HANDED OVER, never resolved by the
|
|
// module.
|
|
|
|
import * as react from 'react'
|
|
import * as reactDom from 'react-dom/client'
|
|
import * as router from 'react-router-dom'
|
|
// The automatic JSX runtime, and it is not decoration. A module's bundler
|
|
// compiles every .jsx file to imports from `react/jsx-runtime` under the modern
|
|
// default, and those have to resolve to CORE's React like every other import.
|
|
// Without it here a module would have to build with `jsxRuntime: 'classic'`;
|
|
// with it, a module uses the default its tooling already assumes.
|
|
import * as jsxRuntime from 'react/jsx-runtime'
|
|
|
|
import { registry } from './registry.js'
|
|
import { MODULE_API_VERSION } from './version.js'
|
|
|
|
import PublicLayout from '../components/PublicLayout.jsx'
|
|
import PageHeader from '../components/PageHeader.jsx'
|
|
import { Loading, ErrorState, EmptyState } from '../components/PageState.jsx'
|
|
import Slot from './Slot.jsx'
|
|
import { useAsync } from '../lib/useAsync.js'
|
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
|
import { useSite } from '../contexts/SiteContext.jsx'
|
|
import { request, ApiError, BASE } from '../api/client.js'
|
|
|
|
// The UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
|
|
// eight exports — five table rows in §3.4, since `PageState` contributes three —
|
|
// are what the smallest UO page already needs beyond React and the router:
|
|
// without them a module either reaches into core's tree — violating the
|
|
// zero-import rule the whole boundary rests on — or ships its own copies, which
|
|
// means a module page that does not look like the site it is installed in, and
|
|
// that drifts further every time core's layout changes.
|
|
//
|
|
// Adding a member is a MINOR MODULE_API_VERSION bump; changing a member's props
|
|
// is a MAJOR one. That is a real constraint on core's own refactoring and it is
|
|
// the price of the boundary being worth anything.
|
|
//
|
|
// `AdminPage` was in an early draft of §3.4's table and is deliberately absent:
|
|
// core has no such component — admin views are plain markup inside AdminLayout —
|
|
// and inventing one to satisfy a table would be a core change with no consumer
|
|
// until Phase 3. The contract was amended rather than the code padded (it no
|
|
// longer lists it), and adding it later costs a minor bump, which is exactly the
|
|
// case the versioning is for.
|
|
const ui = {
|
|
PublicLayout,
|
|
PageHeader,
|
|
Loading,
|
|
ErrorState,
|
|
EmptyState,
|
|
useAsync,
|
|
useAuth,
|
|
useSite,
|
|
// The eighth member, for the INVERTED slot direction (TEAMS.md Part 3). A
|
|
// module that declares a slot on its own page needs the same component core
|
|
// renders its own with — the error boundary in particular, since the thing
|
|
// being contained here is CORE's content failing inside the MODULE's page.
|
|
// Shared rather than reimplemented for the reason the whole kit exists: two
|
|
// boundaries with different behaviour would be two bugs.
|
|
Slot,
|
|
}
|
|
|
|
// The request PRIMITIVE, not the `api` object (§3.5): a module builds its own
|
|
// namespace over `request` and owns the paths it calls, which is right, because
|
|
// it owns the routes at the other end.
|
|
//
|
|
// `BASE` was in §3.5 from the start and missing from this object until slice 3,
|
|
// which is when something first needed it. `request` is fetch-only, so an
|
|
// EventSource — the shard's live feed is two of them — has to build its own URL,
|
|
// and the alternative is a module hardcoding `/api/v1`: an assertion about where
|
|
// core mounts its API that core has never promised to keep.
|
|
const api = { request, ApiError, BASE }
|
|
|
|
/**
|
|
* Publish `window.__rg`. Called by main.jsx before it renders, and before any
|
|
* module chunk evaluates.
|
|
*
|
|
* Frozen, one level down as well as at the top: the object a module reaches for
|
|
* its React is not somewhere a module gets to leave something for the next one.
|
|
* Cross-module communication is a thing the contract does not have, and an
|
|
* unfrozen global is how a codebase acquires one by accident.
|
|
*/
|
|
export function publishSharedDependencies() {
|
|
window.__rg = Object.freeze({
|
|
version: MODULE_API_VERSION,
|
|
react,
|
|
reactDom,
|
|
router,
|
|
jsxRuntime,
|
|
registry,
|
|
ui: Object.freeze(ui),
|
|
api: Object.freeze(api),
|
|
})
|
|
return window.__rg
|
|
}
|