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>
This commit is contained in:
@@ -186,6 +186,47 @@ modules.load({
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
// 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
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
// past its own try/catch — a bad module must cost the site its routes, never its
|
||||
// boot (§4.4).
|
||||
//
|
||||
// What is deliberately NOT here yet, each landing with the PR that first calls
|
||||
// it (§2.7): GET /api/v1/public/modules and the client chunk's static mount
|
||||
// (PRs 6-7).
|
||||
// PR 7 added the client half's server-side end: validating `client.entry` and
|
||||
// publishing where the chunk lives (`clientChunks()`), so app.js can serve it and
|
||||
// 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
|
||||
// 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) {
|
||||
const file = path.join(dir, 'module.json')
|
||||
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 (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) {
|
||||
// A module that can create tables and cannot drop them leaves an operator
|
||||
// with orphaned data and no supported way to remove it.
|
||||
@@ -434,6 +497,7 @@ function load(tierRouters) {
|
||||
tables: new Set(),
|
||||
called: new Set(),
|
||||
hooks: { onBoot: null, onShutdown: null },
|
||||
client: null,
|
||||
ctx: null,
|
||||
state: 'installed',
|
||||
stage: null,
|
||||
@@ -446,6 +510,7 @@ function load(tierRouters) {
|
||||
let stage = 'manifest'
|
||||
try {
|
||||
record.manifest = readManifest(dir, id, tierRouters)
|
||||
record.client = resolveClient(dir, id, record.manifest)
|
||||
stage = 'schema'
|
||||
record.tables = tablesOf(dir, record.manifest)
|
||||
checkTableNames(id, record.tables)
|
||||
@@ -522,17 +587,35 @@ function load(tierRouters) {
|
||||
function mount(record, tierRouters) {
|
||||
for (const tier of TIERS) {
|
||||
for (const [prefix, router] of record.routes[tier]) {
|
||||
tierRouters[tier].use(prefix, (req, res, next) => {
|
||||
if (record.state === 'startup_failed') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
}
|
||||
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
|
||||
return next()
|
||||
}, router)
|
||||
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') {
|
||||
return res.status(503).json({ message: 'Module unavailable' })
|
||||
}
|
||||
if (record.state === 'disabled') return res.status(404).json({ message: 'Not found' })
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// The states a loaded record may hold, deliberately a hardcoded subset rather
|
||||
@@ -657,7 +740,58 @@ function fragments() {
|
||||
.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. */
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -81,8 +81,9 @@ function absolutize(url) {
|
||||
* byte-identical property without a database.
|
||||
*
|
||||
* @param {string} html the built index.html
|
||||
* @param {{logo?: string, favicon?: string, theme?: object|null}} [overrides]
|
||||
* effective brand assets and theme; anything absent falls back to BRAND_* env
|
||||
* @param {{logo?: string, favicon?: string, theme?: object|null, moduleEntries?: string[]}} [overrides]
|
||||
* 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}
|
||||
*/
|
||||
function render(html, overrides = {}) {
|
||||
@@ -105,10 +106,43 @@ function render(html, overrides = {}) {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n ')
|
||||
return html
|
||||
const scripts = moduleScriptTags(overrides.moduleEntries)
|
||||
const withHead = html
|
||||
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
||||
.replace(/(<meta\s+name="description"\s+content=")[\s\S]*?("\s*\/?>)/i, `$1${desc}$2`)
|
||||
.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
|
||||
@@ -170,7 +204,26 @@ async function get() {
|
||||
// mean a failing query per page view.
|
||||
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
|
||||
// we just read may already be stale. Serve it, but do not cache it.
|
||||
if (generation === startedAt) cached = { html, at: Date.now() }
|
||||
|
||||
@@ -227,3 +227,86 @@ test('an invalidation during a render is not overwritten by the stale result', a
|
||||
await inflight
|
||||
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 core’s 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 core’s 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/'))
|
||||
})
|
||||
|
||||
180
server/test/moduleClientChunk.test.js
Normal file
180
server/test/moduleClientChunk.test.js
Normal 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 module’s chunk is 503 and a disabled one’s 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)
|
||||
})
|
||||
@@ -640,3 +640,105 @@ test('a module colliding with an already-registered name fails alone, unmounted'
|
||||
assert.equal(claims('/first'), true)
|
||||
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\(\)/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user