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>
313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
// Point the DB at a closed port before the pool is built; the settings read is
|
||
// monkeypatched in every test that reaches it, so no query runs.
|
||
process.env.DB_HOST = '127.0.0.1'
|
||
process.env.DB_PORT = '59999'
|
||
// A brand URL, so the og:image absolutization of an uploaded path is exercised
|
||
// rather than being dead code in the test environment.
|
||
process.env.BRAND_URL = process.env.BRAND_URL || 'https://shard.example'
|
||
// BRAND_LOGO defaults to empty (no logo image rendered), which would make the
|
||
// "og:image still comes from env" assertions below pass vacuously.
|
||
process.env.BRAND_LOGO = process.env.BRAND_LOGO || '/brand/logo.png'
|
||
|
||
const { test, afterEach, after } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
// The cached, settings-aware HTML shell (docs/website/THEMING_AND_NAV.md §4.3).
|
||
// Three properties are load-bearing enough to lock here: that an untouched
|
||
// instance gets byte-for-byte the shell it got before this feature existed, that
|
||
// a DB fault still serves a page, and that the steady state is one cached string
|
||
// rather than a settings read per page view.
|
||
const htmlShell = require('../src/utils/htmlShell')
|
||
const settings = require('../src/model/settings/settings.model')
|
||
const brand = require('../src/config/brand')
|
||
const db = require('../src/utils/db')
|
||
|
||
after(() => db.close())
|
||
|
||
const originalGetShellBrand = settings.getShellBrand
|
||
afterEach(() => {
|
||
settings.getShellBrand = originalGetShellBrand
|
||
})
|
||
|
||
// A stand-in for the built client/dist/index.html: the two tags the shell
|
||
// rewrites plus the stylesheet link the theme block has to follow.
|
||
const TEMPLATE = `<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>Vite App</title>
|
||
<meta name="description" content="placeholder" />
|
||
<link rel="stylesheet" href="/assets/index-abc123.css" />
|
||
</head>
|
||
<body><div id="root"></div></body>
|
||
</html>`
|
||
|
||
// The shell app.js served BEFORE this phase, reproduced verbatim. The point of
|
||
// the test is that this string and the new renderer's output are identical for
|
||
// an instance with no brand_assets and no theme_visual row (§9), so it is copied
|
||
// rather than imported.
|
||
function legacyRenderIndexHtml(html) {
|
||
const htmlEscape = (s) =>
|
||
String(s).replace(
|
||
/[&<>"']/g,
|
||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]),
|
||
)
|
||
const title = htmlEscape(brand.name)
|
||
const desc = htmlEscape(brand.description)
|
||
const tags = [
|
||
`<meta property="og:title" content="${title}" />`,
|
||
`<meta property="og:description" content="${desc}" />`,
|
||
'<meta property="og:type" content="website" />',
|
||
brand.url ? `<meta property="og:url" content="${htmlEscape(brand.url)}" />` : '',
|
||
brand.logo ? `<meta property="og:image" content="${htmlEscape(brand.logo)}" />` : '',
|
||
'<meta name="twitter:card" content="summary_large_image" />',
|
||
`<meta name="twitter:title" content="${title}" />`,
|
||
`<meta name="twitter:description" content="${desc}" />`,
|
||
brand.favicon ? `<link rel="icon" href="${htmlEscape(brand.favicon)}" />` : '',
|
||
]
|
||
.filter(Boolean)
|
||
.join('\n ')
|
||
return 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>`)
|
||
}
|
||
|
||
// ── The byte-identical guarantee (§9) ─────────────────────────────────
|
||
|
||
test('with no overrides the shell is byte-identical to the pre-feature one', () => {
|
||
assert.equal(htmlShell.render(TEMPLATE, {}), legacyRenderIndexHtml(TEMPLATE))
|
||
})
|
||
|
||
test('an empty theme and empty assets are the same as no overrides at all', () => {
|
||
// A row that parsed to nothing usable resolves to null/undefined rather than
|
||
// to an empty block, or "reset" would leave a `<style>:root{}` behind forever.
|
||
assert.equal(htmlShell.render(TEMPLATE, { theme: null }), legacyRenderIndexHtml(TEMPLATE))
|
||
assert.equal(htmlShell.render(TEMPLATE, { theme: {} }), legacyRenderIndexHtml(TEMPLATE))
|
||
})
|
||
|
||
// ── Brand assets ──────────────────────────────────────────────────────
|
||
|
||
test('an uploaded favicon replaces the env one and touches nothing else', () => {
|
||
const html = htmlShell.render(TEMPLATE, { favicon: '/uploads/1-a.png' })
|
||
assert.match(html, /<link rel="icon" href="\/uploads\/1-a\.png" \/>/)
|
||
assert.ok(!html.includes(`href="${brand.favicon}"`), 'the env favicon is gone')
|
||
// §9: setting only the favicon changes the favicon only.
|
||
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
|
||
assert.equal(ogImage ? ogImage[1] : '', brand.logo, 'og:image still resolves from env')
|
||
})
|
||
|
||
test('an uploaded logo becomes og:image, absolutized against BRAND_URL', () => {
|
||
const html = htmlShell.render(TEMPLATE, { logo: '/uploads/2-b.png' })
|
||
assert.match(html, /<meta property="og:image" content="https:\/\/shard\.example\/uploads\/2-b\.png" \/>/)
|
||
})
|
||
|
||
test('an env logo is passed through untouched even when relative', () => {
|
||
// The shell an instance gets today is the operator's choice; only an uploaded
|
||
// path — which is always relative and is read off-site by scrapers — is made
|
||
// absolute. Anything else would break the byte-identical guarantee above.
|
||
const html = htmlShell.render(TEMPLATE, {})
|
||
const ogImage = html.match(/<meta property="og:image" content="([^"]*)"/)
|
||
assert.equal(ogImage ? ogImage[1] : '', brand.logo)
|
||
})
|
||
|
||
// ── The theme boot block (removes the first-paint flash) ───────────────
|
||
|
||
test('a resolved theme is emitted as a :root block after the stylesheet', () => {
|
||
const html = htmlShell.render(TEMPLATE, { theme: { '--accent': '#123456', '--bg': '#0b0f14' } })
|
||
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456;--bg:#0b0f14\}<\/style>/)
|
||
// Custom properties are equal-specificity, so the later block wins: it must
|
||
// come after the built stylesheet or a themed instance would paint :root.
|
||
assert.ok(
|
||
html.indexOf('theme-boot') > html.indexOf('/assets/index-abc123.css'),
|
||
'the theme block follows the stylesheet link',
|
||
)
|
||
})
|
||
|
||
test('a token that could carry markup is dropped, not escaped into the block', () => {
|
||
const html = htmlShell.render(TEMPLATE, {
|
||
theme: { '--accent': '#123456', '--x': '</style><script>alert(1)</script>', 'color': 'red' },
|
||
})
|
||
assert.match(html, /<style id="theme-boot">:root\{--accent:#123456\}<\/style>/)
|
||
assert.ok(!html.includes('alert(1)'), 'no injected markup survives')
|
||
assert.ok(!html.includes('color:red'), 'a non-custom-property name never reaches the block')
|
||
})
|
||
|
||
// ── Caching, invalidation and the DB-fault fallback ────────────────────
|
||
|
||
test('the shell is rendered once and then served from cache', async () => {
|
||
let reads = 0
|
||
settings.getShellBrand = async () => {
|
||
reads += 1
|
||
return { logo: brand.logo, favicon: '/uploads/cached.png', theme: null }
|
||
}
|
||
htmlShell.init(TEMPLATE)
|
||
const first = await htmlShell.get()
|
||
const second = await htmlShell.get()
|
||
assert.equal(reads, 1, 'a settings read per page view would put the DB on every route')
|
||
assert.equal(first, second)
|
||
assert.match(first, /\/uploads\/cached\.png/)
|
||
})
|
||
|
||
test('a burst of requests on a cold cache does one read', async () => {
|
||
let reads = 0
|
||
settings.getShellBrand = async () => {
|
||
reads += 1
|
||
await new Promise((r) => setTimeout(r, 5))
|
||
return { logo: brand.logo, favicon: brand.favicon, theme: null }
|
||
}
|
||
htmlShell.init(TEMPLATE)
|
||
await Promise.all([htmlShell.get(), htmlShell.get(), htmlShell.get()])
|
||
assert.equal(reads, 1)
|
||
})
|
||
|
||
test('invalidate() makes the next request re-read', async () => {
|
||
let favicon = '/uploads/old.png'
|
||
let reads = 0
|
||
settings.getShellBrand = async () => {
|
||
reads += 1
|
||
return { logo: brand.logo, favicon, theme: null }
|
||
}
|
||
htmlShell.init(TEMPLATE)
|
||
assert.match(await htmlShell.get(), /old\.png/)
|
||
favicon = '/uploads/new.png'
|
||
assert.match(await htmlShell.get(), /old\.png/, 'still cached until told otherwise')
|
||
htmlShell.invalidate()
|
||
assert.match(await htmlShell.get(), /new\.png/)
|
||
assert.equal(reads, 2)
|
||
})
|
||
|
||
test('the cache expires on its own, so a second process converges', async () => {
|
||
let favicon = '/uploads/old.png'
|
||
settings.getShellBrand = async () => ({ logo: brand.logo, favicon, theme: null })
|
||
htmlShell.init(TEMPLATE)
|
||
assert.match(await htmlShell.get(), /old\.png/)
|
||
// Nothing invalidates here: this is the worker that did NOT handle the write.
|
||
favicon = '/uploads/new.png'
|
||
const realNow = Date.now
|
||
Date.now = () => realNow() + htmlShell.TTL_MS + 1
|
||
try {
|
||
assert.match(await htmlShell.get(), /new\.png/)
|
||
} finally {
|
||
Date.now = realNow
|
||
}
|
||
})
|
||
|
||
test('a settings read that throws serves the env-only shell instead of failing', async () => {
|
||
settings.getShellBrand = async () => {
|
||
throw new Error('ER_CON_COUNT_ERROR')
|
||
}
|
||
htmlShell.init(TEMPLATE)
|
||
assert.equal(await htmlShell.get(), legacyRenderIndexHtml(TEMPLATE))
|
||
})
|
||
|
||
test('the fallback is cached too — an outage is not a query per page view', async () => {
|
||
let reads = 0
|
||
settings.getShellBrand = async () => {
|
||
reads += 1
|
||
throw new Error('down')
|
||
}
|
||
htmlShell.init(TEMPLATE)
|
||
await htmlShell.get()
|
||
await htmlShell.get()
|
||
assert.equal(reads, 1)
|
||
})
|
||
|
||
test('an invalidation during a render is not overwritten by the stale result', async () => {
|
||
let favicon = '/uploads/old.png'
|
||
settings.getShellBrand = async () => {
|
||
const value = favicon
|
||
await new Promise((r) => setTimeout(r, 10))
|
||
return { logo: brand.logo, favicon: value, theme: null }
|
||
}
|
||
htmlShell.init(TEMPLATE)
|
||
const inflight = htmlShell.get() // reads 'old'
|
||
favicon = '/uploads/new.png'
|
||
htmlShell.invalidate() // the write lands mid-render
|
||
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/'))
|
||
})
|