Files
website/server/test/htmlShell.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

313 lines
14 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.

// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 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/'))
})