You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/uix-boot-check.ts

184 lines
7.3 KiB

/**
* 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 `<head>`, 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<string>()
): Promise<UixBootCheckResult> {
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<string>();
return {
name: 'uix-boot-check',
configResolved(config) {
serve = config.command === 'serve';
},
async buildStart() {
const read = new Set<string>();
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<string>();
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)}`);
});
});
}
};
}

Powered by TurnKey Linux.