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>
241 lines
11 KiB
JavaScript
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 }
|