/** * uix-boot-check — the guard that breaks the build when the compiled boot * on disk no longer describes the source it came from. * * The whole boot axis fails SILENTLY by construction. The tag is wrapped * in a mute `try`/`catch` (it runs in ``, before anything can * report), a stale artifact stamps values that are merely OLD rather than * invalid, and a browser that blocks the script leaves no trace in the * page. So the only guard worth having is one that stops the build: a * site that edits its preference schema and forgets to recompile must * learn it from `vite build`, not from a user in Cairo reading an LTR * page. (`createActiveUix`'s dev audit is the other half — it names the * axis that moved, in the browser, after the fact.) * * It lives in `scripts/` and not under `src/uix` on purpose: it imports * esbuild and Vite's plugin type, which are the HOST's build tools. The * framework does not ship its consumer's bundler. * * Two shapes, one check: * - `uixBootCheck()` — a Vite plugin. `buildStart` recompiles with the * site's own configuration and fails the build on any difference. In * dev it WARNS instead (a dev server that refuses to start over a * stale cosmetic artifact helps nobody) and registers every input the * compile read, so editing a dimension re-runs the check there and * then instead of waiting for a deploy. That doctrine covers a compile * that FAILS outright too — a half-typed schema module is the most * ordinary state a watched file is ever in, and it must cost a line in * the log, not the session. * - `checkUixBootArtifact()` — the same check as a promise, for a site * with no Vite (or a CI job that prefers a test over a build). */ import { readFileSync } from 'node:fs'; import { relative, resolve } from 'node:path'; import type { Plugin } from 'vite'; import { BOOT_OUTPUT, compileBoot, renderBootModule } from './generate-boot.ts'; export interface UixBootCheckOptions { /** * The module exporting `bootPrefsSchema` the artifact was compiled * with — the same path passed to `generate-boot --schema`. Omitted, * the framework's default schema. */ readonly schema?: string; /** The artifact on disk. Defaults to the framework's own. */ readonly out?: string; } export interface UixBootCheckResult { readonly ok: boolean; /** Why it is stale, in the words a site can act on. Absent when `ok`. */ readonly reason?: string; /** Every source file the compile read — what dev watches. */ readonly inputs: readonly string[]; } /** * Recompile and compare. The comparison is over the artifact's TEXT, not * over the hash alone: the hash lives in the same file, and a check that * only compared hashes would pass on a file where someone edited the * script and left the hash — the exact pair that makes a browser block * the tag. */ export async function checkUixBootArtifact( options: UixBootCheckOptions = {}, // Filled as the compile reads, and still filled when it throws — see // `compileBoot`. read = new Set() ): Promise { const out = options.out === undefined ? BOOT_OUTPUT : resolve(options.out); const schema = options.schema === undefined ? undefined : resolve(options.schema); const { script, inputs } = await compileBoot({ schema }, read); const expected = renderBootModule(script); let actual: string; try { // LF: the artifact is written with LF and a checkout with // `core.autocrlf` must not be read as a stale one. actual = readFileSync(out, 'utf8').replace(/\r\n/g, '\n'); } catch { return { ok: false, reason: `no compiled boot at ${out} — run the boot compiler`, inputs }; } if (actual === expected) return { ok: true, inputs }; return { ok: false, reason: `the compiled boot at ${out} is stale — it no longer matches ${ schema === undefined ? 'the framework schema' : relative(process.cwd(), schema) } and the modules it imports. Recompile it.`, inputs }; } /** * A compile that never produced a result — almost always a syntax error * the site is halfway through typing into its schema module. It is a * different event from a stale artifact and gets its own sentence, * because the artifact on disk may well be fine. */ function compileFailure(error: unknown): string { return `could not compile the boot: ${error instanceof Error ? error.message : String(error)}`; } export function uixBootCheck(options: UixBootCheckOptions = {}): Plugin { let serve = false; // The files the last compile READ. A file that is NOT one of them cannot // make the artifact stale, and a file that becomes one can only do it // through an edit to a file that already is — so membership is enough to // decide whether a save is worth a recompile. // // That holds for a compile that FAILED too, as long as the set is the one // it read before it broke: the file that broke it is in there, and so is // every file that imports it. Measured, twice: a set kept only from // SUCCESSFUL compiles was empty on a dev server that started on a // half-typed schema, and never held a module the schema imported broken // for the first time — either way the fix was discarded as "not an input" // and the guard stayed silent. Checking every save instead while broken // was measured too: ten duplicate warnings from a SvelteKit start-up, // which writes its own files. let inputs = new Set(); return { name: 'uix-boot-check', configResolved(config) { serve = config.command === 'serve'; }, async buildStart() { const read = new Set(); let result: UixBootCheckResult | undefined; let failure: unknown; try { result = await checkUixBootArtifact(options, read); } catch (error) { // In dev this is a warning like any other: a guard over a // cosmetic artifact must never be the reason a dev server // refuses to start, and the site is about to see the same // syntax error from Vite itself. On a build there is nothing // to recover to. if (!serve) throw error; failure = error; } inputs = read; // In dev the input files are registered so a later edit re-runs // the check; on a build there is no "later", so a difference is // the end of the build. if (serve) for (const input of read) this.addWatchFile(input); if (result === undefined) { this.warn(compileFailure(failure)); return; } if (result.ok) return; if (serve) this.warn(result.reason!); else this.error(result.reason!); }, configureServer(server) { server.watcher.on('change', (file) => { if (!inputs.has(resolve(file))) return; const read = new Set(); void checkUixBootArtifact(options, read) .then((result) => { inputs = new Set(result.inputs); if (result.ok) return; server.config.logger.warn(`[uix-boot-check] ${result.reason}`); }) // Without this the rejection is unhandled and Node 24 ENDS // THE PROCESS: one typo in a schema module and the session // is over. (The compile itself no longer throws from // outside a promise — see `inputCollector` in // `generate-boot.ts` — but a guard that watches a file the // user is editing has to survive every state that file // passes through.) .catch((error: unknown) => { inputs = read; server.config.logger.warn(`[uix-boot-check] ${compileFailure(error)}`); }); }); } }; }