Files
website/server/test/moduleClientChunk.test.js
wtclaude e0927bc255
All checks were successful
PR Checks / bot-install (pull_request) Successful in 21s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Successful in 1m37s
feat(modules): the client registry, window.__rg and the chunk's script injection
Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md 2.7 — the client half's
delivery. A module's prebuilt chunk is served, injected, handed core's React
and its UI kit, and its routes are rendered by App.jsx. The registry is empty
on a bare core, so nothing an operator can see changes.

Client:
  - modules/registry.js — registerRoutes/registerNav/registerFeatureProvider,
    with the URL namespace written by core, never by the module
  - modules/shared.js — window.__rg: React, react-dom/client, react-router-dom,
    react/jsx-runtime, the registry, the seven-member UI kit and the request
    primitive, frozen
  - App.jsx reads routesFor for all three areas; nav consumption is PR 8
  - main.jsx publishes the global, then mounts on DOMContentLoaded

Server:
  - the loader validates client.entry and publishes clientChunks() and
    clientEntryUrls(); an entry in the module root is rejected, because the
    directory it sits in is what gets served
  - app.js mounts each chunk at /modules/<id>/ behind the module's state guard
    with no-cache; anything else under /modules is a 404, not the SPA shell
  - htmlShell injects the tag before </body>, so core's bundle runs first
    wherever a bundler puts it

Found by loading a real chunk in a browser, and fixed here: core mounted before
any module chunk had evaluated, because document.readyState during a deferred
script is 'interactive', not 'loading'. Every test passed against that build.
The smoke is written down in MODULE_API.md 7.7.

933 server tests (+23), 123 client tests (+14). routes.manifest.json unchanged
at 230 routes; the OpenAPI spec regenerates byte-identical.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-10 22:54:16 -05:00

181 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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)
})