// 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 }