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

241 lines
11 KiB
JavaScript

// The SPA's HTML shell: index.html templated with this instance's branding.
//
// This used to be a one-liner at module load in app.js — read the built
// index.html, template it from BRAND_* env, serve that one string forever. The
// admin-configurable brand assets (docs/website/THEMING_AND_NAV.md §4.3) make
// the favicon and OG image settings-driven, which is a lifecycle change rather
// than an `await`: the shell now depends on a row that can change while the
// process runs.
//
// Three properties this module exists to guarantee:
//
// • It is a cached string in the steady state. A settings read per page view
// would put the database on the critical path of every SPA route, including
// during an outage where the API is already degraded.
// • A DB fault never fails the page. A read error renders the env-only shell —
// exactly what the code did before this feature — and that fallback is
// cached like any other, so an outage cannot turn every page view into a
// failing query.
// • With no brand_assets and no theme_visual row it is BYTE-IDENTICAL to what
// app.js served before. That is an acceptance criterion of §9, and the
// reason the theme <style> block and the asset overrides are appended only
// when they exist rather than always emitted with default values.
//
// Invalidation is explicit — the settings controller calls invalidate() after a
// successful write to brand_assets or theme_visual — with a TTL as a safety net.
// The cache is per process: in a scaled deployment the process that handled the
// write is the only one that learns of it, so without the TTL every other worker
// would serve the old favicon until the next restart.
const brand = require('../config/brand')
// How long a rendered shell is trusted without an explicit invalidation. Short
// enough that a second process converges on its own, long enough that this is
// still one render per process per five minutes rather than one per request.
const TTL_MS = 5 * 60 * 1000
// A stored theme reaches the browser twice: in this block, and again as inline
// properties once the SPA has fetched /public/settings. The block exists purely
// so a themed instance does not paint the shipped palette for one frame first;
// the client drops it (by id) as soon as it has the authoritative payload — see
// contexts/SiteContext.jsx.
const THEME_STYLE_ID = 'theme-boot'
// Belt and braces over the theme validators. Every token name comes from a fixed
// map and every value from a closed set (hex color, curated font stack, bounded
// px, listed shadow), so nothing that reaches here can carry markup today. These
// two patterns make that a property of the HTML writer rather than of a validator
// three modules away that someone may one day loosen.
const SAFE_TOKEN_NAME = /^--[a-zA-Z0-9-_]+$/
const SAFE_TOKEN_VALUE = /^[a-zA-Z0-9 ,.()#%_'"/-]+$/
let template = null // the built index.html, read once
let cached = null // { html, at }
let inflight = null // de-dupes a burst of requests on a cold cache
let generation = 0 // bumped by invalidate(); an in-flight render checks it
// Escape user/brand text for safe interpolation into the HTML shell.
function htmlEscape(s) {
return String(s).replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]),
)
}
/**
* An uploaded asset path is always relative (`/uploads/…`), but og:image is read
* off-site by scrapers that handle a relative URL poorly. Absolutize it against
* BRAND_URL when we have one.
*
* Env values pass through untouched even when relative: the shell an instance
* gets today is the operator's choice and must not change just because this
* module now exists.
*/
function absolutize(url) {
if (!brand.url || !url.startsWith('/')) return url
return `${brand.url.replace(/\/+$/, '')}${url}`
}
/**
* Render the shell. Pure — every input is a parameter, so a test can assert the
* byte-identical property without a database.
*
* @param {string} html the built index.html
* @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 = {}) {
const title = htmlEscape(brand.name)
const desc = htmlEscape(brand.description)
// Effective values: an uploaded override wins over env, absence means env.
const logo = overrides.logo ? absolutize(overrides.logo) : brand.logo
const favicon = overrides.favicon || brand.favicon
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)}" />` : '',
logo ? `<meta property="og:image" content="${htmlEscape(logo)}" />` : '',
'<meta name="twitter:card" content="summary_large_image" />',
`<meta name="twitter:title" content="${title}" />`,
`<meta name="twitter:description" content="${desc}" />`,
favicon ? `<link rel="icon" href="${htmlEscape(favicon)}" />` : '',
themeStyleTag(overrides.theme),
]
.filter(Boolean)
.join('\n ')
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
// themed. Injected last in <head> so it follows the built stylesheet and wins
// the equal-specificity tie against theme.css's own :root.
function themeStyleTag(theme) {
if (!theme || typeof theme !== 'object') return ''
const decls = Object.entries(theme)
.filter(([name, value]) => SAFE_TOKEN_NAME.test(name) && typeof value === 'string' && SAFE_TOKEN_VALUE.test(value))
.map(([name, value]) => `${name}:${value}`)
.join(';')
return decls ? `<style id="${THEME_STYLE_ID}">:root{${decls}}</style>` : ''
}
/**
* Provide the built index.html. Called once at boot by app.js; a separate step
* from get() so the file read stays synchronous and startup still fails loudly
* if the client build is unreadable.
*/
function init(html) {
template = html
cached = null
inflight = null
generation += 1
}
/** Drop the cached shell. Called after any write that can change it. */
function invalidate() {
cached = null
inflight = null
generation += 1
}
/**
* The current shell. Renders on a cold or expired cache, otherwise returns the
* cached string. Never rejects: a settings read that fails yields the env-only
* shell.
*
* @returns {Promise<string>}
*/
async function get() {
if (template === null) throw new Error('htmlShell.init() was never called')
if (cached && Date.now() - cached.at < TTL_MS) return cached.html
if (inflight) return inflight
const startedAt = generation
const run = (async () => {
let overrides = {}
try {
// Required lazily: this module is loaded by app.js at boot, and the
// settings model pulls in the DB pool. Requiring it at the top would make
// the HTML shell a startup-time dependency of the database.
// eslint-disable-next-line global-require
const settings = require('../model/settings/settings.model')
overrides = await settings.getShellBrand()
} catch {
// A DB fault must never fail the page (§4.3). Fall back to the env-only
// shell — the pre-feature behaviour — and cache it, so an outage does not
// mean a failing query per page view.
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() }
// Only retire our own registration: an invalidation during the read may have
// already started a newer render, and clearing that one would cost an extra
// render on the next request.
if (inflight === run) inflight = null
return html
})()
inflight = run
return run
}
module.exports = { init, get, invalidate, render, TTL_MS, THEME_STYLE_ID }