// 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 ` : '' } /** * 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} */ 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 }