Merge pull request 'feat(modules): the client registry, window.__rg and the chunk's script injection' (#134) from feature/module-client-registry into edge

Reviewed-on: #134
This commit is contained in:
2026-08-11 04:01:59 +00:00
13 changed files with 1114 additions and 22 deletions

View File

@@ -5,6 +5,7 @@ import MaintenanceGate from './components/MaintenanceGate.jsx'
import RequireAuth from './components/RequireAuth.jsx' import RequireAuth from './components/RequireAuth.jsx'
import RequirePlayer from './components/RequirePlayer.jsx' import RequirePlayer from './components/RequirePlayer.jsx'
import RoleGate from './components/RoleGate.jsx' import RoleGate from './components/RoleGate.jsx'
import { routesFor } from './modules/registry.js'
// Public // Public
import Portal from './routes/public/Portal.jsx' import Portal from './routes/public/Portal.jsx'
@@ -115,6 +116,16 @@ export default function App() {
<Route path="/site/market/vendors/:serial" element={<MarketVendor />} /> <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
<Route path="/wiki" element={<Wiki />} /> <Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} /> <Route path="/wiki/:slug" element={<WikiArticle />} />
{/* Installed modules' public pages, namespaced `/<id>/…` — the
registry prefixes the segment, so a module cannot spell its way
out of it (docs/website/MODULE_API.md §3.3). Declared before the
CMS catch-all below: React Router ranks a static segment over a
dynamic one, so the order is not what saves us, but keeping the
two adjacent makes the relationship visible to whoever adds the
next route here. */}
{routesFor('public').map((r) => (
<Route key={r.path} path={`/${r.path}`} element={r.element} />
))}
{/* CMS pages: top-level /:slug, matched only after the named routes {/* CMS pages: top-level /:slug, matched only after the named routes
above (React Router ranks static routes over this dynamic one). */} above (React Router ranks static routes over this dynamic one). */}
<Route path="/:slug" element={<CmsPage />} /> <Route path="/:slug" element={<CmsPage />} />
@@ -205,6 +216,19 @@ export default function App() {
<Route path="users/:id" element={<UserDetail />} /> <Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} /> <Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
RequireAuth + AdminLayout. A module cannot supply its own auth
wrapper — only an optional { roles }, which core applies as the
same RoleGate its own routes above use, so the sidebar and the
route table cannot disagree about who may see what. Before the
`*` redirect, which would otherwise swallow every one of them. */}
{routesFor('admin').map((r) => (
<Route
key={r.path}
path={r.path}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
@@ -225,6 +249,17 @@ export default function App() {
<Route path="/player/char/:serial" element={<PlayerCharacter />} /> <Route path="/player/char/:serial" element={<PlayerCharacter />} />
<Route path="/account" element={<PlayerAccount />} /> <Route path="/account" element={<PlayerAccount />} />
<Route path="/account/appeals" element={<PlayerAppeals />} /> <Route path="/account/appeals" element={<PlayerAppeals />} />
{/* Installed modules' player-portal pages, at /player/<id>/…. This
group's own routes are absolute (its layout route has no path),
so the prefix is written here rather than inherited — the one
place the three areas do not read alike. */}
{routesFor('player').map((r) => (
<Route
key={r.path}
path={`/player/${r.path}`}
element={r.gate ? <RoleGate roles={r.gate.roles}>{r.element}</RoleGate> : r.element}
/>
))}
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -42,6 +42,17 @@ function safeParse(text) {
} }
} }
// The request PRIMITIVE, exported for installed modules and handed to them on
// `window.__rg.api` (docs/website/MODULE_API.md §3.5). Core owns the fetch
// semantics — same-origin /api/v1, cookies included, JSON in and out, ApiError
// on a non-2xx — and nothing above them: a module owns the paths it calls,
// because it owns the routes at the other end.
//
// The `api` object below stays core's own binding surface. Its `atlas` and
// `shard` namespaces are module bindings that only still live here because
// Phase 3 has not moved them yet.
export { req as request }
export const api = { export const api = {
// ----- auth ----- // ----- auth -----
me: () => req('/auth/me'), me: () => req('/auth/me'),

View File

@@ -2,8 +2,43 @@ import React from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx' import App from './App.jsx'
import { publishSharedDependencies } from './modules/shared.js'
import './styles/theme.css' import './styles/theme.css'
// Publish window.__rg BEFORE rendering and before any module chunk evaluates.
// Installed modules arrive as `<script type="module" src="/modules/<id>/…">`
// tags the server injects into the shell (server/src/utils/htmlShell.js), placed
// after this bundle's own tag; module scripts execute in document order, so they
// resolve their externals against the global this call sets up
// (docs/website/MODULE_API.md §3.2).
publishSharedDependencies()
// Render on DOMContentLoaded rather than immediately, and that is the one line
// of core's boot the module system changes.
//
// Deferred scripts — which every `type="module"` script is — execute in document
// order and ALL of them finish before DOMContentLoaded fires. Waiting for that
// event is therefore the guarantee that every installed module has registered
// its routes before React reads the registry: no loading state, no re-render,
// and no ordering race between core's bundle and a module's. A module chunk that
// 404s or throws does not hold the event back, so a broken module costs its own
// pages and not the site.
//
// The readyState check below is `'complete'`, and it is not the obvious
// `'loading'`. A DEFERRED script — which every `type="module"` script is — runs
// after the document has been parsed, so by the time this line executes
// readyState is already `'interactive'`; DOMContentLoaded has NOT fired yet and
// still comes after every deferred script. Testing for `'loading'` therefore
// mounts immediately, before any module chunk has evaluated, and a module's
// routes are missing from the very first render — which looks exactly like a
// module that failed to load: its URL falls through to core's catch-all and
// redirects home. Found by loading a real chunk in a browser; no unit test in
// this repo can see it.
//
// `'complete'` is only reached after `load`, which is strictly later than any
// static deferred script, so this branch is the genuine "the event has already
// been and gone" case and not a wrong guess about our own timing.
function mount() {
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
@@ -11,3 +46,10 @@ createRoot(document.getElementById('root')).render(
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,
) )
}
if (document.readyState === 'complete') {
mount()
} else {
document.addEventListener('DOMContentLoaded', mount, { once: true })
}

View File

@@ -0,0 +1,137 @@
// ── The client-side module registry ────────────────────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7. The normative contract is
// docs/website/MODULE_API.md §3.3; where the two disagree, the contract wins.
//
// A module's prebuilt chunk registers its routes, its nav entries and its feature
// provider here, and core reads them back. This is the client twin of the
// server's modules/loader.js — with one structural difference worth stating,
// because it is what makes the file this short: core *hands* the registry to the
// module (on `window.__rg`, see shared.js) rather than discovering it. There is
// nothing to scan, nothing to validate a manifest against, and no failure mode
// where half a module is registered.
//
// **Timing is the whole design.** Module chunks are `<script type="module" src>`
// tags the server injects before `</body>` (server/src/utils/htmlShell.js), after
// core's own bundle. Module scripts are deferred, so they evaluate after that
// bundle has run — which is where `window.__rg` is published — and all of them
// finish before DOMContentLoaded. main.jsx waits for that same event before
// calling render(), so registration is complete before React reads any of this.
//
// That is what buys the simplicity here: registration is a plain synchronous
// write with no subscribers, not an observable store, because nothing can
// register after the first render. If that ever stops being true it changes in
// this file and in main.jsx, not in a dozen consumers.
//
// What PR 7 wires up is `routesFor` (App.jsx). `navFor` and `featureProviderFor`
// are stored and returned faithfully but core does not read them yet — PR 8 adds
// the nav interleave and the feature-provider seam. Storing them is not the kind
// of accepting stub the server's registries refused to be: nothing is discarded
// here, so a module that registers nav in this core gets it back from `navFor`.
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, by area.
*
* @param {string} id the module id — the URL segment its routes are namespaced under
* @param {{public?: Array, admin?: Array, player?: Array}} byArea
* each entry `{ path, element, gate? }`. `path` is relative to the module's
* namespace; core prefixes it and mounts it inside the area's existing wrapper
* (`/<id>/…` under MaintenanceGate, `/admin/<id>/…` under RequireAuth +
* AdminLayout, `/player/<id>/…` under RequirePlayer + PlayerPortalLayout).
* `gate` is an optional `{ roles: [...] }` that core applies as its own
* RoleGate — a module cannot supply an auth wrapper, because the sidebar and
* the route table have to agree about who may see what (§3.3).
*/
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: a module cannot claim a path
// outside its own namespace however it spells `path` — a leading `/`, a
// trailing one, or several — because it never gets to write the segment
// its routes hang under.
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 own Moderation and System groups; a "UO"
* group at the bottom of the sidebar would be a visible regression on the day
* the module is extracted (MODULE_SYSTEM.md §1.4). `group` names an existing
* core group, `order` sorts within it, and an unknown group name appends rather
* than dropping the item — a mis-typed group must cost a position, never a link.
*
* @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 })
registered.add(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 — `uo` fills
* its namespace with today's `useShardFeatures` (MODULE_SYSTEM.md §1.5). With no
* module installed the nav filter is a correct no-op, because no core nav item
* carries a `feature` today.
*/
export function registerFeatureProvider(id, namespace, hook) {
featureProviders.set(namespace, { id, hook })
registered.add(id)
}
export const routesFor = (area) => routes[area] || []
// Sorted by the `order` a module asked for. Array#sort is stable in every engine
// this ships to, so two modules asking for the same slot keep load order —
// which is alphabetical by id, the same order the server scans in (§4.2).
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. Nothing in the app calls this — there is no unregistering. */
export function _reset() {
for (const area of AREAS) {
routes[area].length = 0
nav[area].length = 0
}
featureProviders.clear()
registered.clear()
}
// The object handed to modules on window.__rg.registry. Deliberately the write
// calls plus the read ones: a module reading `routesFor` is how it finds out
// another module is installed, which is the only supported form of module-to-
// module awareness (there is no dependency resolution).
export const registry = {
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
registeredIds,
}

View File

@@ -0,0 +1,96 @@
// ── 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 { 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 UI kit is CURATED AND CLOSED (§3.4), not a re-export of components/. These
// seven 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` appears in §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 is amended rather than the code padded, 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 request PRIMITIVE, not the `api` object (§3.5). `api.atlas` and `api.shard`
// are module bindings that only still live in core's client because Phase 3 has
// not moved them; 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.
const api = { request, ApiError }
/**
* 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
}

View File

@@ -0,0 +1,14 @@
// The client's copy of MODULE_API_VERSION. It must equal the server's
// (server/src/modules/version.js) — the two halves version ONE contract
// (docs/website/MODULE_API.md §1.1), and a module checks whichever half it is
// talking to: `coreApi` against the server's at load time, `window.__rg.version`
// against the client's before it registers anything.
//
// Duplicated rather than fetched, and that is deliberate. The value has to be on
// `window.__rg` before the first module chunk evaluates, which is earlier than
// any network round trip could answer — a fetched version would mean either an
// await before render or a module reading `undefined`. The cost of the copy is
// that the two files can drift, so a test asserts they agree
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
// both.
export const MODULE_API_VERSION = '1.0.0'

View File

@@ -0,0 +1,164 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import {
registry,
registerRoutes,
registerNav,
registerFeatureProvider,
routesFor,
navFor,
featureProviderFor,
registeredIds,
_reset,
} from '../src/modules/registry.js'
import { MODULE_API_VERSION } from '../src/modules/version.js'
// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
// isolation from React, like the nav-override merge next door, because the
// property worth proving has nothing to do with rendering: a module gets exactly
// the URL namespace core gave it, however it spells the paths it registers.
//
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
// there is no DOM in this runner. What it publishes is React, the router and
// core components: a wiring test would assert that an import statement imported
// something. Phase 1's spike proved the half that can actually fail, which is a
// real chunk resolving its externals against the global in a browser under an
// enforced CSP.
beforeEach(() => _reset())
test('a module route is namespaced under the module id', () => {
registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
assert.deepEqual(
routesFor('public').map((r) => r.path),
['uo/atlas'],
)
})
test('a module cannot spell its way out of its namespace', () => {
// Whatever the module writes, the segment it lands under is core's to choose:
// leading slashes, several of them, a trailing one, or nothing at all.
registerRoutes('uo', {
public: [
{ path: '/atlas' },
{ path: '//atlas/creatures' },
{ path: 'atlas/' },
{ path: '' },
],
})
assert.deepEqual(
routesFor('public').map((r) => r.path),
['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
)
})
test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
// `..` is not stripped, and does not need to be: React Router matches path
// patterns literally, so `/uo/../admin` is a route nothing navigates to rather
// than a route that resolves somewhere else. Asserted so that a future
// "cleanup" that starts resolving these knows it changed a behaviour.
registerRoutes('uo', { public: [{ path: '../admin' }] })
assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
})
test('routes keep their gate and carry the owning module id', () => {
registerRoutes('uo', {
admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
})
const [route] = routesFor('admin')
assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
assert.equal(route.moduleId, 'uo')
assert.equal(route.element, 'OPS')
})
test('the three areas are kept apart', () => {
registerRoutes('uo', {
public: [{ path: 'atlas' }],
admin: [{ path: 'link' }],
player: [{ path: 'chars' }],
})
assert.equal(routesFor('public').length, 1)
assert.equal(routesFor('admin').length, 1)
assert.equal(routesFor('player').length, 1)
// An area nobody registered is an empty list, never undefined: App.jsx maps
// over all three unconditionally.
_reset()
for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
})
test('an unknown area throws rather than being dropped', () => {
// Loudly, because the alternative is a module whose pages simply never appear
// and no indication anywhere of why.
assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
assert.equal(registeredIds().length, 0)
})
test('nav items sort by order, and equal orders keep load order', () => {
registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
assert.deepEqual(
navFor('admin').map((i) => i.label),
['First', 'Second', 'Third'],
)
})
test('a nav item with no order sorts after the ones that asked for a place', () => {
registerNav('uo', {
area: 'public',
items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
})
assert.deepEqual(
navFor('public').map((i) => i.label),
['Early', 'Unordered'],
)
})
test('a feature provider is stored under its namespace, with its owner', () => {
const hook = () => ({ atlas: true })
registerFeatureProvider('uo', 'shard', hook)
assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
assert.equal(featureProviderFor('nothing'), undefined)
})
test('every registration marks the module registered', () => {
registerRoutes('a', { public: [{ path: 'x' }] })
registerNav('b', { area: 'public', items: [] })
registerFeatureProvider('c', 'ns', () => {})
assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
})
test('the registry object handed to modules exposes the whole surface', () => {
// window.__rg.registry is the ONLY way a module reaches any of this, so a
// member missing from the object is a member that does not exist.
assert.deepEqual(Object.keys(registry).sort(), [
'featureProviderFor',
'navFor',
'registerFeatureProvider',
'registerNav',
'registerRoutes',
'registeredIds',
'routesFor',
])
})
test('the client and server halves declare the same MODULE_API_VERSION', () => {
// The value is duplicated because it has to be on window.__rg before the first
// module chunk evaluates, which is earlier than a fetch could answer. This is
// the test that pays for the copy: a bump that edits one file fails here
// instead of shipping a core whose two halves disagree about the contract they
// implement.
const here = path.dirname(fileURLToPath(import.meta.url))
const server = fs.readFileSync(
path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
'utf8',
)
const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
assert.equal(MODULE_API_VERSION, match[1])
})

View File

@@ -186,6 +186,47 @@ modules.load({
app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' })) app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' }))
// Installed modules' prebuilt client chunks, at /modules/<id>/ — same-origin, so
// `script-src 'self'` admits them with no nonce and no inline script
// (docs/website/MODULE_API.md §3.1). Three properties, each load-bearing:
//
// • The static root is the directory the ENTRY sits in, never the module root.
// One express.static over a module root would publish its server source, its
// module.json and its schema fragment; the loader rejects an entry that would
// make those the same directory.
// • Behind the module's own state guard, so a failed module's chunk is 503 and
// a disabled one's is 404 — the same answers its API gives, for the same
// reason: the browser should not be running the client half of something the
// server half has stopped serving.
// • `fallthrough: false`, so a missing file is a 404 here rather than falling
// through to the SPA catch-all and answering a `<script src>` with the index
// shell, which the browser then rejects on its MIME type instead.
//
// Vite's library build emits an unhashed `entry.js`, so `no-cache` (revalidate,
// not "do not store") is what stops an upgraded module serving yesterday's chunk
// out of the disk cache.
for (const chunk of modules.clientChunks()) {
app.use(
chunk.url,
chunk.guard,
express.static(chunk.dir, {
fallthrough: false,
setHeaders: (res) => {
res.set('Cache-Control', 'no-cache')
res.set('X-Content-Type-Options', 'nosniff')
},
}),
)
}
// Everything else under /modules is a 404, not the SPA shell. The namespace
// belongs to installed modules' chunks — an unknown module id or a file a module
// does not ship is a missing file, and answering a `<script src>` with an HTML
// page turns that into a MIME-type refusal in the console with a 200 in the
// network tab. It also keeps the namespace's boundary a fact of the app rather
// than of whichever catch-all happens to be mounted after it.
app.use('/modules', (req, res) => res.status(404).json({ message: 'Not found' }))
// ── /.well-known ────────────────────────────────────────────────────── // ── /.well-known ──────────────────────────────────────────────────────
// Android App Links verification file at the web root (M9 follow-up). Mounted // Android App Links verification file at the web root (M9 follow-up). Mounted
// before the SPA catch-all so it returns JSON, not the index shell. 404s unless // before the SPA catch-all so it returns JSON, not the index shell. 404s unless

View File

@@ -17,9 +17,11 @@
// past its own try/catch — a bad module must cost the site its routes, never its // past its own try/catch — a bad module must cost the site its routes, never its
// boot (§4.4). // boot (§4.4).
// //
// What is deliberately NOT here yet, each landing with the PR that first calls // PR 7 added the client half's server-side end: validating `client.entry` and
// it (§2.7): GET /api/v1/public/modules and the client chunk's static mount // publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
// (PRs 6-7). // utils/htmlShell.js can inject its script tag. The loader resolves and validates;
// it does not mount, because the chunk hangs off the ROOT app rather than a tier
// router, and app.js is where core's own static mounts live.
// //
// PR 3 added the fragment half of the schema story: this file VALIDATES a // PR 3 added the fragment half of the schema story: this file VALIDATES a
// fragment (statement by statement, at load time, before anything is mounted) // fragment (statement by statement, at load time, before anything is mounted)
@@ -322,6 +324,58 @@ function ownedByCore(tierRouter, prefix) {
) )
} }
// ── The client chunk ───────────────────────────────────────────────────────
// A chunk filename, and the same character set utils/htmlShell.js will accept in
// a script src. Two copies of the rule, deliberately: this one rejects the module
// at load time, that one refuses to write the tag. A validator three files away
// staying strict is not something an HTML attribute should depend on.
const CHUNK_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
/**
* Resolve and validate `client.entry` — where a module's prebuilt chunk lives on
* disk, and the URL it is served at (§3.1).
*
* The rule that matters most is the last one, and it is the one a reviewer would
* not think to ask for: the static mount is rooted at the DIRECTORY THE ENTRY IS
* IN, not at the module root. One `express.static` over a module root would
* publish its server source, its `module.json` and its schema fragment to the
* internet. So an entry sitting directly in the module root is rejected rather
* than quietly turning the whole module into a public directory.
*
* @returns {{dir: string, url: string, entryUrl: string}|null} null when the
* module ships no client half — a server-only module is perfectly normal.
*/
function resolveClient(dir, id, manifest) {
// Absent `client` is a server-only module. Present but empty is not the same
// thing: it states a client half and delivers none, which would be a module
// whose pages never load and nothing anywhere saying why.
if (manifest.client === undefined) return null
const { entry } = manifest.client
if (typeof entry !== 'string' || !entry.trim()) fail('manifest', 'client.entry must be a path')
const file = path.resolve(dir, entry)
// Containment before anything else: `../../server/src/config` resolves to a
// real, readable directory, and every check below it would pass.
if (file !== dir && !file.startsWith(dir + path.sep)) {
fail('manifest', `client.entry "${entry}" escapes the module directory`)
}
if (!CHUNK_FILE.test(path.basename(file))) {
fail('manifest', `client.entry "${entry}" must name a .js file`)
}
const chunkDir = path.dirname(file)
if (chunkDir === dir) {
fail('manifest', `client.entry "${entry}" must be in a subdirectory — its directory is served`)
}
if (!fs.existsSync(file)) fail('manifest', `client.entry "${entry}" is missing`)
return {
dir: chunkDir,
url: `/modules/${id}`,
entryUrl: `/modules/${id}/${path.basename(file)}`,
}
}
function readManifest(dir, id, tierRouters) { function readManifest(dir, id, tierRouters) {
const file = path.join(dir, 'module.json') const file = path.join(dir, 'module.json')
const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) const manifest = JSON.parse(fs.readFileSync(file, 'utf8'))
@@ -358,6 +412,15 @@ function readManifest(dir, id, tierRouters) {
if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`) if (!registries.hasSlot(slot)) fail('extensions', `unknown extension slot "${slot}"`)
} }
if (manifest.client !== undefined) {
if (typeof manifest.client !== 'object' || manifest.client === null || Array.isArray(manifest.client)) {
fail('manifest', 'client must be an object')
}
for (const key of Object.keys(manifest.client)) {
if (key !== 'entry') fail('manifest', `unknown key "client.${key}" in module.json`)
}
}
if (manifest.schema && !manifest.purge) { if (manifest.schema && !manifest.purge) {
// A module that can create tables and cannot drop them leaves an operator // A module that can create tables and cannot drop them leaves an operator
// with orphaned data and no supported way to remove it. // with orphaned data and no supported way to remove it.
@@ -434,6 +497,7 @@ function load(tierRouters) {
tables: new Set(), tables: new Set(),
called: new Set(), called: new Set(),
hooks: { onBoot: null, onShutdown: null }, hooks: { onBoot: null, onShutdown: null },
client: null,
ctx: null, ctx: null,
state: 'installed', state: 'installed',
stage: null, stage: null,
@@ -446,6 +510,7 @@ function load(tierRouters) {
let stage = 'manifest' let stage = 'manifest'
try { try {
record.manifest = readManifest(dir, id, tierRouters) record.manifest = readManifest(dir, id, tierRouters)
record.client = resolveClient(dir, id, record.manifest)
stage = 'schema' stage = 'schema'
record.tables = tablesOf(dir, record.manifest) record.tables = tablesOf(dir, record.manifest)
checkTableNames(id, record.tables) checkTableNames(id, record.tables)
@@ -522,14 +587,32 @@ function load(tierRouters) {
function mount(record, tierRouters) { function mount(record, tierRouters) {
for (const tier of TIERS) { for (const tier of TIERS) {
for (const [prefix, router] of record.routes[tier]) { for (const [prefix, router] of record.routes[tier]) {
tierRouters[tier].use(prefix, (req, res, next) => { tierRouters[tier].use(prefix, stateGuard(record), router)
}
}
}
/**
* The dispatch guard, as a middleware over the LIVE record.
*
* A closure over the record rather than over its state: everything mounts once,
* at boot, and the states that matter here are reached afterwards — the schema
* replay fails, `onBoot` throws, an admin disables the module. A guard that read
* the state at mount time would answer for the state a module was in before any
* of that happened.
*
* Used for a module's API routes and, since PR 7, for its client chunk: a module
* answering 503 on its API must not also be handing the browser the script that
* calls it, and one an admin has disabled should be as absent from the page as it
* is from the nav.
*/
function stateGuard(record) {
return (req, res, next) => {
if (record.state === 'startup_failed') { if (record.state === 'startup_failed') {
return res.status(503).json({ message: 'Module unavailable' }) return res.status(503).json({ message: 'Module unavailable' })
} }
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' }) if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
return next() return next()
}, router)
}
} }
} }
@@ -657,7 +740,58 @@ function fragments() {
.map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) })) .map((r) => ({ id: r.id, file: path.join(r.dir, r.manifest.schema) }))
} }
/**
* Every module that ships a client chunk, with where to serve it from and the
* guard to serve it behind — in scan order.
*
* Listed regardless of state, because mounting happens once at boot and the
* guard is what answers for the state at request time (the same arrangement the
* API routes have). A module that failed VALIDATION never reaches here at all:
* `record.client` is only resolved once the manifest passed.
*
* `dir` is the directory the entry sits in, never the module root — see
* resolveClient. app.js does the mounting; this file does not know about the
* root app.
*
* @returns {{id: string, dir: string, url: string, entryUrl: string, guard: Function}[]}
*/
function clientChunks() {
assertLoaded('clientChunks')
return [...modules.values()]
.filter((r) => r.client)
.map((r) => ({ id: r.id, ...r.client, guard: stateGuard(r) }))
}
/**
* The script URLs the HTML shell should inject, in scan order.
*
* `started` only, and that is the difference between this and clientChunks():
* the mount is a standing offer answered by a guard, while the tag is a decision
* taken per page render, when the state is already known. A module whose `onBoot`
* failed keeps its URLs and answers 503 on them — loading its client half would
* render its pages against a backend that cannot serve them.
*
* @returns {string[]}
*/
function clientEntryUrls() {
assertLoaded('clientEntryUrls')
return [...modules.values()]
.filter((r) => r.client && r.state === 'started')
.map((r) => r.client.entryUrl)
}
/** Absolute path of the modules directory. */ /** Absolute path of the modules directory. */
const dir = () => MODULES_DIR const dir = () => MODULES_DIR
module.exports = { load, list, setState, fragments, bootable, shutdownHooks, isLoaded, dir } module.exports = {
load,
list,
setState,
fragments,
bootable,
shutdownHooks,
clientChunks,
clientEntryUrls,
isLoaded,
dir,
}

View File

@@ -81,8 +81,9 @@ function absolutize(url) {
* byte-identical property without a database. * byte-identical property without a database.
* *
* @param {string} html the built index.html * @param {string} html the built index.html
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides] * @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides]
* effective brand assets and theme; anything absent falls back to BRAND_* env * effective brand assets and theme; anything absent falls back to BRAND_* env.
* `moduleEntries` are the same-origin URLs of installed modules' client chunks.
* @returns {string} * @returns {string}
*/ */
function render(html, overrides = {}) { function render(html, overrides = {}) {
@@ -105,10 +106,43 @@ function render(html, overrides = {}) {
] ]
.filter(Boolean) .filter(Boolean)
.join('\n ') .join('\n ')
return html const scripts = moduleScriptTags(overrides.moduleEntries)
const withHead = html
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`) .replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`) .replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
.replace(/<\/head>/i, ` ${tags}\n </head>`) .replace(/<\/head>/i, ` ${tags}\n </head>`)
if (scripts.length === 0) return withHead
return withHead.replace(/<\/body>/i, ` ${scripts.join('\n ')}\n </body>`)
}
// Installed modules' prebuilt client chunks (docs/website/MODULE_API.md §3.1).
//
// `type="module"` with a `src`, never inline: `script-src 'self'` admits a
// same-origin src with no nonce, and an inline tag would be blocked outright —
// which is also why the shared dependencies ride on window.__rg rather than an
// import map, since an import map has to be inline.
//
// **Injected before `</body>`, not into `</head>`, and the position is the
// contract.** Module scripts are deferred, so they execute in document order
// after core's own bundle — which is where `window.__rg` is published, and what
// every one of a module's imports resolves against. Vite happens to hoist core's
// entry script into `<head>` today, which would make a `</head>` injection work
// too; that is a bundler's emit choice, and if it ever changed, every module in
// the wild would break on its first import with nothing in this repo having been
// edited. Last in the body is after core's script wherever core's script is.
//
// The path is built by the loader from the module id and the entry's basename,
// both already validated, so nothing operator-supplied reaches the attribute.
// It is re-checked here anyway: what may appear in an HTML attribute should be a
// property of the code that writes the HTML, not of a validator two files away
// staying strict.
const MODULE_ENTRY_PATH = /^\/modules\/[a-z][a-z0-9-]{1,31}\/[A-Za-z0-9][A-Za-z0-9._-]*\.js$/
function moduleScriptTags(entries) {
if (!Array.isArray(entries)) return []
return entries
.filter((src) => typeof src === 'string' && MODULE_ENTRY_PATH.test(src))
.map((src) => `<script type="module" src="${htmlEscape(src)}"></script>`)
} }
// The admin theme as a :root block, or '' when this instance has never been // The admin theme as a :root block, or '' when this instance has never been
@@ -170,7 +204,26 @@ async function get() {
// mean a failing query per page view. // mean a failing query per page view.
overrides = {} overrides = {}
} }
const html = render(template, overrides) // The module list is in-memory and filesystem-derived, so unlike the brand
// read above it cannot fail on a DB fault and needs no fallback of its own.
// Required lazily for the same reason the settings model is: app.js requires
// this file, and the loader would otherwise be pulled into that chain.
let moduleEntries = []
try {
// eslint-disable-next-line global-require
moduleEntries = require('../modules/loader').clientEntryUrls()
} catch {
// The only reachable throw is §7.6's guard — the shell rendered before
// modules.load() ran, which app.js's ordering makes impossible and a test
// that renders in isolation makes possible. A page with no module scripts
// is the right answer either way; it is what a bare core serves.
moduleEntries = []
}
// Note for whoever builds the admin Modules screen: a state change after boot
// (an operator disabling a module) has to call invalidate(), exactly as a
// brand-asset write does. The TTL converges on its own within five minutes;
// the explicit call is what makes the toggle feel like it did something.
const html = render(template, { ...overrides, moduleEntries })
// An invalidation that landed while this read was in flight means the value // An invalidation that landed while this read was in flight means the value
// we just read may already be stale. Serve it, but do not cache it. // we just read may already be stale. Serve it, but do not cache it.
if (generation === startedAt) cached = { html, at: Date.now() } if (generation === startedAt) cached = { html, at: Date.now() }

View File

@@ -227,3 +227,86 @@ test('an invalidation during a render is not overwritten by the stale result', a
await inflight await inflight
assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached') assert.match(await htmlShell.get(), /new\.png/, 'the pre-write value must not have been cached')
}) })
// ── Installed modules' client chunks (MODULE_API.md §3.1) ──────────────────
// The built shell as Vite actually emits it: core's entry is a module script in
// <head>, and the injection has to land AFTER it wherever it is.
const BUILT_TEMPLATE = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite App</title>
<meta name="description" content="placeholder" />
<script type="module" crossorigin src="/assets/index-abc123.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-abc123.css" />
</head>
<body><div id="root"></div></body>
</html>`
test('a module chunk is injected as a same-origin module script', () => {
const html = htmlShell.render(TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] })
assert.match(html, /<script type="module" src="\/modules\/uo\/entry\.js"><\/script>/)
})
test('the injection lands after cores own bundle, not before it', () => {
// The property the whole client contract rests on: module scripts are deferred
// and execute in document order, so core's bundle must run first — it is what
// publishes window.__rg, and every import in the chunk resolves against it.
// Injecting into </head> would work today only because Vite hoists core's
// entry there; before </body> is after it wherever a bundler decides to put it.
const html = htmlShell.render(BUILT_TEMPLATE, { moduleEntries: ['/modules/uo/entry.js'] })
assert.ok(
html.indexOf('/assets/index-abc123.js') < html.indexOf('/modules/uo/entry.js'),
'the module chunk must come after cores bundle',
)
assert.ok(html.indexOf('/modules/uo/entry.js') < html.indexOf('</body>'))
assert.ok(html.indexOf('</head>') < html.indexOf('/modules/uo/entry.js'))
})
test('several modules keep the order they were given', () => {
const html = htmlShell.render(TEMPLATE, {
moduleEntries: ['/modules/aaa/entry.js', '/modules/zzz/entry.js'],
})
assert.ok(html.indexOf('/modules/aaa/') < html.indexOf('/modules/zzz/'))
})
test('no installed modules leaves the shell byte-identical', () => {
// A bare core must serve exactly what it served before this PR — including
// when the list is absent rather than empty, which is what a render before
// modules.load() produces.
const baseline = legacyRenderIndexHtml(TEMPLATE)
assert.equal(htmlShell.render(TEMPLATE, { moduleEntries: [] }), baseline)
assert.equal(htmlShell.render(TEMPLATE, {}), baseline)
})
test('anything that is not a module chunk URL is refused, not escaped into the page', () => {
// The loader builds these from a validated id and a validated basename, so
// none of this is reachable today. It is enforced here anyway: what may appear
// in a script src should be a property of the code writing the HTML, not of a
// validator two files away staying strict.
const html = htmlShell.render(TEMPLATE, {
moduleEntries: [
'https://evil.example/entry.js', // off-origin
'/modules/uo/../../etc/passwd', // traversal
'/modules/UO/entry.js', // not a valid module id
'/modules/uo/entry.js"></script><script>alert(1)</script>', // attribute break-out
'/modules/uo/../secrets.js',
'/uploads/entry.js', // right shape, wrong root
42,
null,
],
})
assert.ok(!html.includes('<script type="module" src='), html)
assert.equal(htmlShell.render(TEMPLATE, { moduleEntries: [] }), legacyRenderIndexHtml(TEMPLATE))
})
test('get() renders without module scripts when the loader has never scanned', async () => {
// db/seed.js's problem, one layer up: this file is required by app.js, and a
// render that reached the loader before modules.load() would throw §7.6's
// guard on a request path. A bare shell is the right answer.
settings.getShellBrand = async () => ({ logo: brand.logo, favicon: brand.favicon, theme: null })
htmlShell.init(TEMPLATE)
const html = await htmlShell.get()
assert.ok(!html.includes('/modules/'))
})

View File

@@ -0,0 +1,180 @@
// ── A module's client chunk, served by the real app ────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the contract is
// MODULE_API.md §3.1. moduleLoader.test.js proves the loader resolves and
// validates the chunk; this file proves what the app does with the answer, and
// it boots the REAL app.js to do it — because the three properties worth locking
// are properties of the mount, not of the loader:
//
// 1. the module's own dist directory is published, and nothing above it;
// 2. the chunk is served behind the module's state guard, so a failed or
// disabled module's client half is as absent as its API;
// 3. a miss is a 404 and never the SPA shell, which a browser would reject on
// its MIME type after the request appeared to succeed.
//
// The modules directory is written and MODULES_DIR is set BEFORE app.js is
// required, because the scan is synchronous and happens during that require.
// Node's test runner gives each file its own process, so this cannot disturb
// another test's view of the loader.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, before, after } = require('node:test')
const assert = require('node:assert/strict')
const CHUNK = 'export const hello = 1\n'
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-chunk-'))
const dist = path.join(tmpRoot, 'uo', 'client', 'dist')
fs.mkdirSync(dist, { recursive: true })
fs.writeFileSync(path.join(dist, 'entry.js'), CHUNK)
fs.writeFileSync(path.join(dist, 'sidecar.js'), 'export const also = 2\n')
// The two files a static mount rooted one level too high would publish.
fs.writeFileSync(path.join(tmpRoot, 'uo', 'secrets.js'), 'const TOKEN = "leak"\n')
fs.writeFileSync(
path.join(tmpRoot, 'uo', 'module.json'),
JSON.stringify({
id: 'uo',
name: 'Ultima Online',
version: '1.0.0',
coreApi: '^1.0.0',
client: { entry: 'client/dist/entry.js' },
}),
)
process.env.MODULES_DIR = tmpRoot
/* eslint-disable global-require */
const app = require('../src/app')
const loader = require('../src/modules/loader')
const db = require('../src/utils/db')
const htmlShell = require('../src/utils/htmlShell')
const settings = require('../src/model/settings/settings.model')
/* eslint-enable global-require */
let server
let base
before(async () => {
server = await new Promise((resolve) => {
const s = app.listen(0, '127.0.0.1', () => resolve(s))
})
base = `http://127.0.0.1:${server.address().port}`
// The state the module would be in after a clean boot. lifecycle.js does this
// against the database; here it is set directly, since what is under test is
// what the mount does with a state, not how the state was reached.
loader.setState('uo', 'started')
})
after(async () => {
server.closeAllConnections()
await new Promise((resolve) => server.close(resolve))
await db.close()
fs.rmSync(tmpRoot, { recursive: true, force: true })
})
test('the chunk is served at the URL the shell injects', async () => {
const [entryUrl] = loader.clientEntryUrls()
assert.equal(entryUrl, '/modules/uo/entry.js')
const res = await fetch(base + entryUrl)
assert.equal(res.status, 200)
assert.equal(await res.text(), CHUNK)
// A module chunk is JavaScript to the browser or it is nothing: a `<script
// type="module">` whose response is not a JS MIME type is refused outright.
assert.match(res.headers.get('content-type'), /javascript/)
})
test('a sibling file in the same dist directory is served too', async () => {
// Not incidental: Rollup can split a chunk, and the entry then imports its
// siblings by relative URL. Publishing only the named entry would break every
// module that is more than one file.
const res = await fetch(`${base}/modules/uo/sidecar.js`)
assert.equal(res.status, 200)
})
test('nothing above the dist directory is reachable', async () => {
// The failure this rule exists to prevent: server source, module.json and the
// schema fragment published to the internet by one over-broad static mount.
for (const p of ['/modules/uo/module.json', '/modules/uo/secrets.js', '/modules/uo/../module.json']) {
const res = await fetch(base + p)
assert.notEqual(res.status, 200, `${p} must not be served`)
assert.ok(!(await res.text()).includes('leak'))
}
})
test('the chunk revalidates rather than being cached to a stale copy', async () => {
// Vite's library build emits an unhashed entry.js, so an upgraded module would
// otherwise keep serving yesterday's chunk out of the browser's disk cache.
const res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.headers.get('cache-control'), 'no-cache')
assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
})
test('a missing file is a 404, not the SPA shell', async () => {
const res = await fetch(`${base}/modules/uo/nope.js`)
assert.equal(res.status, 404)
assert.ok(!(await res.text()).includes('<div id="root">'))
})
test('an unknown module id is not served at all', async () => {
const res = await fetch(`${base}/modules/nope/entry.js`)
assert.notEqual(res.status, 200)
})
test('a failed modules chunk is 503 and a disabled ones is 404', async () => {
// The same answers the module's API routes give, and for the same reason: the
// browser must not be running the client half of something the server half has
// stopped serving.
loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'onBoot threw' })
let res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 503)
loader.setState('uo', 'disabled')
res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 404)
// And the guard reads the LIVE state — the mount happened once, at boot, long
// before any of these transitions.
loader.setState('uo', 'started')
res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 200)
})
test('the module is published to clients while it is started, and only then', async () => {
// Ties the two surfaces together: /public/modules and the injected script tag
// answer the same question — what is serving — and they must never disagree.
const seen = async () => {
const res = await fetch(`${base}/api/v1/public/modules`)
return (await res.json()).modules.map((m) => m.id)
}
assert.deepEqual(await seen(), ['uo'])
assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])
loader.setState('uo', 'disabled')
assert.deepEqual(await seen(), [])
assert.deepEqual(loader.clientEntryUrls(), [])
loader.setState('uo', 'started')
})
test('the shell hands the browser the tag for the chunk the app serves', async () => {
// The one seam htmlShell.test.js cannot cover, because it renders against a
// literal list: that the shell asks the LOADER, and gets back a URL this same
// app answers 200 on. The two are wired through a lazy require inside a
// try/catch, which is exactly the shape that can silently return [] forever.
//
// The settings read is stubbed rather than left to fail: the pool points at a
// dead port, and its ten-second connect timeout would be paid here for a
// fallback the test does not care about.
settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
htmlShell.init('<!doctype html><html><head><title>t</title></head><body><div id="root"></div></body></html>')
const html = await htmlShell.get()
assert.match(html, /<script type="module" src="\/modules\/uo\/entry\.js"><\/script>/)
const res = await fetch(`${base}/modules/uo/entry.js`)
assert.equal(res.status, 200)
})

View File

@@ -640,3 +640,105 @@ test('a module colliding with an already-registered name fails alone, unmounted'
assert.equal(claims('/first'), true) assert.equal(claims('/first'), true)
assert.equal(claims('/second'), false) assert.equal(claims('/second'), false)
}) })
// ── The client chunk (MODULE_API.md §3.1) ──────────────────────────────────
/** A module shipping a prebuilt chunk at the conventional client/dist/entry.js. */
function withChunk(id, { entry = 'client/dist/entry.js', write = true, body = 'export default 1' } = {}) {
const dir = writeModule(id, { manifest: { client: { entry } } })
if (write) {
const file = path.join(dir, entry)
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, body)
}
return dir
}
test('a module with a chunk publishes where to serve it from and its URL', () => {
const dir = withChunk('uo')
const loader = freshLoader(tmpRoot)
const [chunk] = loader.clientChunks()
assert.equal(chunk.id, 'uo')
assert.equal(chunk.url, '/modules/uo')
assert.equal(chunk.entryUrl, '/modules/uo/entry.js')
// The DIRECTORY THE ENTRY IS IN, never the module root: one express.static over
// a module root would publish its server source, its module.json and its schema
// fragment.
assert.equal(chunk.dir, path.join(dir, 'client', 'dist'))
assert.equal(typeof chunk.guard, 'function')
})
test('a server-only module contributes no chunk', () => {
writeModule('plain', { server: 'module.exports = () => {}' })
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientChunks(), [])
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('an entry directly in the module root is refused — its directory is served', () => {
// The rule with the largest blast radius in this file. Accepting it would root
// the static mount at the module root and publish everything in it.
const dir = writeModule('uo', { manifest: { client: { entry: 'entry.js' } } })
fs.writeFileSync(path.join(dir, 'entry.js'), 'export default 1')
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /must be in a subdirectory/)
assert.deepEqual(loader.clientChunks(), [])
})
test('an entry that escapes the module directory is refused before anything else', () => {
// `../../server/src/config/csp.js` is a real, readable file, and every check
// after containment would have passed.
withChunk('uo', { entry: '../../server/src/config/csp.js', write: false })
const loader = freshLoader(tmpRoot)
assert.equal(stateOf(loader, 'uo').state, 'startup_failed')
assert.match(stateOf(loader, 'uo').reason, /escapes the module directory/)
})
test('an entry that is not a .js file, or is missing, is refused', () => {
withChunk('aaa', { entry: 'client/dist/entry.mjs' })
withChunk('bbb', { entry: 'client/dist/entry.js', write: false })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'aaa').reason, /must name a \.js file/)
assert.match(stateOf(loader, 'bbb').reason, /is missing/)
})
test('a malformed client key is a loud failure, not an ignored setting', () => {
writeModule('aaa', { manifest: { client: 'client/dist/entry.js' } })
writeModule('bbb', { manifest: { client: { entry: 'x/e.js', chunks: ['a.js'] } } })
writeModule('ccc', { manifest: { client: {} } })
const loader = freshLoader(tmpRoot)
assert.match(stateOf(loader, 'aaa').reason, /client must be an object/)
assert.match(stateOf(loader, 'bbb').reason, /unknown key "client\.chunks"/)
assert.match(stateOf(loader, 'ccc').reason, /client\.entry must be a path/)
for (const id of ['aaa', 'bbb', 'ccc']) assert.equal(stateOf(loader, id).stage, 'manifest')
})
test('only a STARTED module gets a script tag, though every one keeps its mount', () => {
// The mount is a standing offer answered by a guard; the tag is a decision
// taken per render, when the state is known. A module answering 503 on its API
// must not also be handing the browser the script that calls it.
withChunk('uo')
const loader = freshLoader(tmpRoot)
assert.deepEqual(loader.clientEntryUrls(), [], 'registered is not yet serving')
loader.setState('uo', 'started')
assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])
loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'nope' })
assert.deepEqual(loader.clientEntryUrls(), [])
assert.equal(loader.clientChunks().length, 1, 'the mount stays; the guard answers for it')
loader.setState('uo', 'disabled')
assert.deepEqual(loader.clientEntryUrls(), [])
})
test('the chunk accessors throw before load(), like every other one', () => {
process.env.MODULES_DIR = tmpRoot
delete require.cache[require.resolve('../src/modules/loader')]
// eslint-disable-next-line global-require
const loader = require('../src/modules/loader')
assert.throws(() => loader.clientChunks(), /modules\.clientChunks\(\) before modules\.load\(\)/)
assert.throws(() => loader.clientEntryUrls(), /modules\.clientEntryUrls\(\) before modules\.load\(\)/)
})