spike(modules): carry /public/atlas/* behind the proposed module surface

THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.

The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:

  • zero internal-file imports from the module into core; the built chunk has
    zero bare import specifiers and bundles no React
  • routes.manifest.json AND routes.guards.json are byte-identical
  • /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
    zero CSP violation reports

729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.

Two things the spike changed in the contract:

  • ctx.express / ctx.validator. A module lives outside server/, so Node never
    reaches server/node_modules and require('express') fails outright — the
    server-side twin of the one-React rule, which §2.6 had only for the client.
  • window.__rg.jsxRuntime, so a module can build with the automatic JSX
    runtime its tooling already assumes rather than being forced to classic.

And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-10 05:29:35 -05:00
parent f1dda8fe66
commit bf470c7658
55 changed files with 4638 additions and 601 deletions

View File

@@ -0,0 +1,100 @@
// ── The client-side module registry ────────────────────────────────────────
//
// A module's prebuilt chunk registers its routes, nav entries and feature
// provider here, and App.jsx / the nav components read them back. This is the
// client half of docs/website/MODULE_API.md §3.3.
//
// Timing is the whole design. Module chunks are `<script type="module" src>`
// tags injected into <head> by the server (utils/htmlShell.js). Module scripts
// are deferred, so they evaluate after the SPA's own bundle has run — which is
// where window.__rg is published — and before DOMContentLoaded. main.jsx waits
// for that same event before calling render(), so registration is complete
// before React reads any of this and there is no re-render to orchestrate.
//
// Registration is therefore a plain synchronous write with no subscribers, not
// an observable store. If that ever changes, it changes here and not in twelve
// consumers.
const routes = { public: [], admin: [], player: [] }
const nav = { public: [], admin: [], player: [] }
const featureProviders = 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 for one area.
* @param {string} id the module id, used to namespace the URL segment
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`; `path` is relative to the module's
* namespace and core prefixes it (`/uo/…`, `/admin/uo/…`, `/player/uo/…`)
*/
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, so a module cannot claim a path
// outside its own namespace however it spells `path`.
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 Moderation and System groups, and a "UO"
* group at the bottom would be a visible regression (MODULE_SYSTEM.md §1.4).
* @param {string} id
* @param {{area: string, items: Array<{label, to, group?, order?, roles?, feature?}>}} spec
*/
export function registerNav(id, spec) {
const { area, items } = spec || {}
assertArea(area, 'registerNav')
for (const item of items || []) nav[area].push({ ...item, moduleId: 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; with no
* module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today (MODULE_SYSTEM.md §1.5).
*/
export function registerFeatureProvider(id, namespace, hook) {
featureProviders.set(namespace, { id, hook })
}
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for, stable within equal orders so two
// modules registering the same slot stay in load (alphabetical id) order.
export const navFor = (area) =>
[...(nav[area] || [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
export const featureProviderFor = (namespace) => featureProviders.get(namespace)
export const registeredIds = () => [...registered]
// Test seam.
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
featureProviders.clear()
registered.clear()
}
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
registeredIds,
}

View File

@@ -0,0 +1,65 @@
// ── window.__rg — the shared-dependency global ─────────────────────────────
//
// A module's client half is a PREBUILT ESM chunk (the operator never builds
// anything), 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 CSP forbids it
// (MODULE_SYSTEM.md §1.14). So the shared dependencies ride on a global and the
// module's externals resolve against it — docs/website/MODULE_API.md §3.2.
//
// 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 the first useState.
import * as react from 'react'
import * as reactDom from 'react-dom/client'
import * as router from 'react-router-dom'
// The automatic JSX runtime. Without this a module would have to build with
// `jsxRuntime: 'classic'` — its bundler emits `react/jsx-runtime` imports by
// default, and those have to resolve to CORE's React like every other one.
// Exposing it here is what lets a module use the modern default.
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 { useAsync } from '../lib/useAsync.js'
import { useAuth } from '../contexts/AuthContext.jsx'
import { useSite } from '../contexts/SiteContext.jsx'
import { request, ApiError } from '../api/client.js'
// The kit is CURATED AND CLOSED, not a re-export of components/ — see §3.4.
// Adding to it is a minor MODULE_API_VERSION bump; changing a member's props is
// a major one. That is a real constraint on core, and it is the price of module
// pages looking like the site they are installed in.
const ui = {
PublicLayout,
PageHeader,
Loading,
ErrorState,
EmptyState,
useAsync,
useAuth,
useSite,
}
// The request PRIMITIVE, not the api object: api.atlas and api.shard are module
// bindings that live in core's client today and move out with the module (§3.5).
// A module owns the paths it calls, which is right — it owns the routes at the
// other end.
const api = { request, ApiError }
export function publishSharedDependencies() {
window.__rg = Object.freeze({
version: MODULE_API_VERSION,
react,
reactDom,
router,
jsxRuntime,
registry,
ui: Object.freeze(ui),
api: Object.freeze(api),
})
}

View File

@@ -0,0 +1,8 @@
// The client's copy of MODULE_API_VERSION. Must equal the server's
// (server/src/modules/version.js) — they version ONE contract, and a module
// checks whichever half it is talking to.
//
// Duplicated rather than fetched: the value has to be on window.__rg before the
// first module script evaluates, and that is earlier than any network round trip.
// A test asserts the two files agree.
export const MODULE_API_VERSION = '1.0.0'