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.
184 lines
7.3 KiB
184 lines
7.3 KiB
|
3 weeks ago
|
/**
|
||
|
|
* 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)}`);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|