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/generate-boot.ts

415 lines
18 KiB

/**
* generate-boot — compile the pre-hydration boot into a checked-in artifact.
*
* The boot writes the preference attributes on `<html>` before first
* paint. It must agree, byte for byte of BEHAVIOUR, with what
* `createActiveUix` + `ActiveEidos` write after hydration — same
* dimensions, same resolver, same attribute names. The only way to
* guarantee that is to compile the runtime's own modules into the
* snippet instead of writing a second implementation of the cascade by
* hand. `src/uix/active-uix/boot/entry.ts` is the entry;
* `src/uix/active-uix/generated/boot.js` is the artifact, checked in so
* a consumer never needs this script, and guarded by
* `src/uix/active-uix/boot/generated-boot.test.ts` so it can never lag
* the source.
*
* **Two constants, not a bundle.** The artifact is an ES module that
* exports the exact text that goes INSIDE the tag and the CSP hash of
* that text. Two consequences, both load-bearing:
* - `render.ts` IMPORTS it, so no build has to find a file on disk
* next to a server chunk (`pack.test.ts` is the guard);
* - nothing wraps the compiled text by hand any more. The entry is
* self-executing and reads its parameters from its own
* `data-uix-boot` attribute, so the body is CONSTANT per framework
* version and `UIX_BOOT_CSP_HASH` is a constant a site can name in
* `svelte.config.js`. "Compiled, not written" now covers the
* wrapper too.
*
* The `.js` extension is deliberate, and the reason is the CONSUMER, not
* this repo. `svelte.config.js` imports `UIX_BOOT_CSP_HASH` from here and
* is loaded by plain Node, with no bundler in front of it. Measured on
* Node 24.9: a `.ts` twin inside the repo imports fine (type stripping is
* on by default since 23.6), the same file under `node_modules` throws
* `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` — which is exactly where
* this artifact lands once UIX is a dependency. `.js` is what makes the
* recipe portable, and it is what it already was.
*
* The text guard is NOT here, it is in
* `src/uix/active-uix/boot/script-guard.ts`: `render.ts` runs it too, on
* the artifact a site hands the `artifact` door, and it could not import
* a module that pulls esbuild in behind it.
*
* esbuild is SCAFFOLDING, not a shipped dependency: nothing at runtime
* imports it. Same posture as `generate-eidos-css.ts` and `tsx`. It is
* declared in `devDependencies` and pinned EXACT (`0.27.4`, no range) on
* purpose: the body is esbuild's minified output and `UIX_BOOT_CSP_HASH`
* is a digest of those bytes, so another esbuild version can emit other
* bytes from the same source — and an upgrade inside a `^` range would
* put the checked-in artifact out of sync with a fresh compile (the sync
* test and the staleness guard go red) without a line of source moving.
* `node:crypto` is scaffolding too — it computes the hash here and never
* ships.
*
* Aliases are EXTRACTED from `vite.config.ts` rather than copied — the
* same rule `docs-check.ts` invariant 8 follows, for the same reason: a
* copied table drifts and takes the guard's credibility with it.
*
* **And it RUNS what it writes.** The text guards are static, and a boot
* that throws on line one passes every one of them: measured, this
* compiler used to write a dead artifact with exit 0 and a valid hash,
* and the page it produced stamped nothing while reporting nothing
* (`assertBootRuns`). A compiler whose output does not run is not a
* compiler.
*
* **It is a COMPILER, and the site is a caller.** `--schema <module>`
* builds the boot around the site's own preference schema instead of
* UIX's default, and `--out <path>` writes the site's own artifact. Not a
* convenience: a boot compiled around the default schema knows ONE
* language, so on a multi-language site it stamps `dir="ltr"` for an
* Arabic user and the page paints in the wrong direction until hydration
* corrects it (`prefs-schema.ts`, `composeUixPrefsSchema`). Because the
* site is the caller, the guards
* below are COMPILER ERRORS with messages aimed at whoever wrote the
* schema, not assertions about framework code.
*
* **A schema module must import DEEP modules, never the `$prefs`
* barrel.** `$prefs/index.ts` re-exports `createActivePrefs` from a
* `.svelte.ts`, so the barrel drags `$state(` into a plain `<script>` and
* the boot dies on line one — before it stamps a single attribute, and
* silently, because the tag is wrapped in a mute `try`/`catch` by design.
* `src/uix/active-uix/prefs-schema.ts` is the worked example: it imports
* `$prefs/standard` and `$prefs/dimensions/*` one by one for exactly this
* reason. The rune guard is what turns that mistake into a failed build.
*/
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runInNewContext } from 'node:vm';
import { build, type Plugin } from 'esbuild';
import { assertBootScript } from '../src/uix/active-uix/boot/script-guard.ts';
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const BOOT_ENTRY = resolve(REPO, 'src/uix/active-uix/boot/entry.ts');
export const BOOT_OUTPUT = resolve(REPO, 'src/uix/active-uix/generated/boot.js');
export function readViteAliases(): Record<string, string> {
const source = readFileSync(resolve(REPO, 'vite.config.ts'), 'utf8');
const aliases: Record<string, string> = {};
for (const match of source.matchAll(
/['"]([$@][\w./-]*)['"]\s*:\s*resolve\(__dirname,\s*['"]([^'"]+)['"]\)/g
)) {
aliases[match[1]] = resolve(REPO, match[2]);
}
if (Object.keys(aliases).length < 10) {
throw new Error(
`alias extraction found only ${Object.keys(aliases).length} entries — the aliases const in vite.config.ts changed shape`
);
}
return aliases;
}
export interface BuildBootScriptOptions {
/**
* Absolute path of the module that exports `bootPrefsSchema` — the
* site's schema. Omitted, `./boot-schema.ts` resolves normally and the
* framework's default answers.
*/
readonly schema?: string;
}
/**
* Redirect the seam — `./boot-schema.ts`, the specifier `boot.ts` imports
* its preference schema through, fixed and relative because nothing in the
* boot may be dynamic. This is the one thing the compiler substitutes.
*
* esbuild's own `alias` option cannot do it: measured, a relative key is
* rejected outright (`Invalid alias name: "./boot-schema.ts"`). The
* alternative — giving the seam an alias-shaped specifier — would put a
* boot-only entry in the alias table that `vite.config.ts` and
* `svelte.config.js` both have to carry. An `onResolve` on the exact
* specifier costs less and says what it does.
*/
function schemaRedirect(schema: string): Plugin {
return {
name: 'uix-boot-schema',
setup(build) {
build.onResolve({ filter: /^\.\/boot-schema\.ts$/ }, () => ({ path: schema }));
}
};
}
/**
* Record every file the bundle READ. `uix-boot-check` watches these in
* dev — the artifact goes stale when any of them moves, not only when the
* entry does.
*
* Why a plugin and not `metafile: true`, which reports the same set:
* esbuild's own client does `if (response.metafile) parseJSON(…)` on the
* way back, and a FAILED build answers with an empty metafile buffer that
* is still truthy — so `JSON.parse('')` throws `SyntaxError: Unexpected
* end of JSON input` from inside a socket data handler, where it is an
* UNCAUGHT EXCEPTION and not a rejection of the build promise. Measured:
* with `metafile`, a schema module with a syntax error kills the process
* and no `try`/`catch` or `.catch()` around the compile can stop it —
* which is a dead dev server for the most ordinary mistake there is.
* Without it, the same build rejects with `Build failed with 1 error`
* and the caller decides. The two input sets were compared file by file:
* 58 and 58, zero difference.
*/
function inputCollector(into: Set<string>): Plugin {
return {
name: 'uix-boot-inputs',
setup(build) {
build.onLoad({ filter: /.*/ }, (args) => {
into.add(resolve(args.path));
return undefined;
});
}
};
}
/**
* Compile the boot entry: the text that goes inside the tag, plus every
* source file that text was built from.
*
* `inputs` fills as esbuild READS, so a caller that passes its own set
* still holds it when the build REJECTS: a failed compile read files too,
* the one that broke it among them. That is what `uix-boot-check` watches
* until a compile succeeds again.
*
* Normalised to LF: the hash is over these bytes, and a checkout with
* `core.autocrlf` must not change it.
*/
export async function compileBoot(
options: BuildBootScriptOptions = {},
inputs = new Set<string>()
): Promise<{ script: string; inputs: readonly string[] }> {
const result = await build({
entryPoints: [BOOT_ENTRY],
bundle: true,
write: false,
format: 'iife',
platform: 'browser',
target: 'es2020',
minify: true,
legalComments: 'none',
alias: readViteAliases(),
plugins:
options.schema === undefined
? [inputCollector(inputs)]
: [schemaRedirect(options.schema), inputCollector(inputs)]
});
return { script: result.outputFiles[0].text.replace(/\r\n/g, '\n'), inputs: [...inputs] };
}
/**
* The text that goes inside the tag. Used by the sync test, which
* compares it against the checked-in constant.
*/
export async function buildBootScript(options: BuildBootScriptOptions = {}): Promise<string> {
return (await compileBoot(options)).script;
}
/** The `script-src` source a site adds to its CSP for this exact text. */
export function bootCspHash(script: string): string {
return `sha256-${createHash('sha256').update(script, 'utf8').digest('base64')}`;
}
/** The checked-in module: two constants and the reason they exist. */
export function renderBootModule(script: string): string {
return [
'// GENERATED by scripts/generate-boot.ts — do not edit.',
'//',
'// The pre-hydration boot, as the two values a site needs: the exact text',
'// that goes INSIDE the <script> tag, and the CSP `script-src` hash of that',
"// text. The body carries no parameters (they ride the tag's",
'// `data-uix-boot` attribute), so the hash is constant per framework',
'// version and `svelte.config.js` can name it.',
`export const UIX_BOOT_SCRIPT = ${JSON.stringify(script)};`,
`export const UIX_BOOT_CSP_HASH = ${JSON.stringify(bootCspHash(script))};`,
''
].join('\n');
}
const USAGE = `usage: generate-boot [--schema <module>] [--out <file>]
--schema <module> module exporting \`bootPrefsSchema(defaultLocale)\` — the
SAME schema the app passes to createActiveUix. Omitted,
the boot is compiled around UIX's default schema, which
knows one language.
--out <file> where to write the artifact (default: the framework's own,
${BOOT_OUTPUT}).`;
/**
* Flags are parsed strictly and an unknown one is an error. A compiler
* that IGNORES a flag it does not know hands a site the default boot
* while the site believes it compiled its own — the same silent wrong
* answer this whole axis exists to remove.
*/
export function parseGenerateBootArgs(argv: readonly string[]): {
schema?: string;
out: string;
} {
let schema: string | undefined;
let out = BOOT_OUTPUT;
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
const value = argv[i + 1];
if (flag !== '--schema' && flag !== '--out') {
throw new Error(`unknown argument \`${flag}\`\n\n${USAGE}`);
}
if (value === undefined || value.startsWith('--')) {
throw new Error(`\`${flag}\` needs a value\n\n${USAGE}`);
}
if (flag === '--schema') schema = resolve(process.cwd(), value);
else out = resolve(process.cwd(), value);
i += 1;
}
return schema === undefined ? { out } : { schema, out };
}
/**
* The guards are the site's compiler errors, so they have to say what a
* SITE can act on. `assertBootScript` states the fact; this states what
* put it there, and the rune case is the one that actually happens — a
* schema module that reached for the `$prefs` barrel.
*/
function guardHint(message: string): string | undefined {
if (message.includes('$state(') || message.includes('$derived(')) {
return 'a Svelte rune reached the boot bundle. A schema module must import DEEP modules (`$prefs/standard`, `$prefs/dimensions/*`), never the `$prefs` barrel: it re-exports a `.svelte.ts` and the boot would die before stamping a single attribute.';
}
if (message.includes('svelte/internal')) {
return 'the boot bundle pulled in the Svelte runtime. Nothing the schema imports may be a `.svelte`/`.svelte.ts` module.';
}
if (message.includes('ceiling')) {
return 'the boot blocks the parser by design, so its size is latency every cold load pays. Narrow the schema, or import fewer modules from it.';
}
if (message.includes('not pure ASCII')) {
return 'the CSP hash is over bytes, so the tag must not depend on the document charset. A non-ASCII literal in the schema (a language name, a currency symbol) is the usual source.';
}
return undefined;
}
/**
* The locale the smoke below compiles against. Any one would do: a
* schema module's declared type is `(defaultLocale: SupportedLocale) =>
* PrefsSchema`, so a module that only answers for one hard-coded locale
* is lying about its own signature. The value is named in the error for
* exactly that reason.
*/
const SMOKE_LOCALE = 'en';
/**
* RUN what was just compiled, once, in a synthetic document, and require
* it to STAMP something — and nothing but strings.
*
* Every other guard here is static — it reads the text. None of them can
* tell a boot that works from one that dies on line one, and a boot that
* dies does it in SILENCE by design: `entry.ts` and `boot()` each wrap
* themselves in a mute `try`/`catch` because this runs in `<head>`, where
* nothing can report. Measured on the two mistakes a site actually makes
* — a `bootPrefsSchema` that throws, and one exported as an object
* instead of a function — the compiler wrote both with exit 0, a valid
* hash and a page that stamped nothing. That is WORSE than the defect
* `--schema` exists to close: the default boot at least painted something
* wrong, a dead one paints nothing and brings the whole flash back.
*
* The document is the smallest one the boot touches: the tag it reads its
* parameters from, and the root it reads the page's own `dir` from and
* writes to — a root that declares nothing. `navigator`, `matchMedia`
* and `localStorage` are absent on purpose — each probe behind them is
* already best-effort, so absence exercises the fallback path instead of
* asserting against a half-built fake of a browser.
*/
function assertBootRuns(script: string): void {
const stamped: Array<[name: string, value: unknown]> = [];
const document = {
currentScript: {
getAttribute: () => JSON.stringify({ defaultLocale: SMOKE_LOCALE, themeIds: [] })
},
documentElement: {
getAttribute: () => null,
setAttribute: (name: string, value: unknown) => {
stamped.push([name, value]);
}
}
};
runInNewContext(script, { document });
if (stamped.length === 0) {
throw new Error(
`it stamped NOTHING when it was run with defaultLocale '${SMOKE_LOCALE}'. The tag swallows its own failures by design, so a browser would say exactly this much: nothing. The usual causes are a \`bootPrefsSchema\` that throws, or one exported as something other than a function.`
);
}
// Counting attributes is not enough: a real browser turns ANY value into
// text, so a dimension that resolves to nothing reaches `<html>` as the
// word `undefined` and the page keeps it for the whole session. The boot
// already leaves out a prefs axis the schema does not declare, exactly
// as the runtime does; what is left here is a DECLARED dimension with no
// string to give — a redefined axis without a default, a dimension that
// resolves to a number or a boolean.
const invalid = stamped.filter(([, value]) => typeof value !== 'string');
if (invalid.length === 0) return;
const listed = invalid.map(([name, value]) => `\`${name}\` = ${String(value)}`).join(', ');
throw new Error(
`it would stamp ${listed} when it was run with defaultLocale '${SMOKE_LOCALE}'. Every attribute the boot stamps must be a string, and the dimension behind each of these resolves to something else. Give it a default, or make it resolve to one of its catalogue's strings.`
);
}
async function main(): Promise<void> {
const { schema, out } = parseGenerateBootArgs(process.argv.slice(2));
// Paths are resolved against the CURRENT DIRECTORY, which is the
// framework's when the command runs through `npm run generate:boot`.
// Said here rather than left to esbuild, whose answer is a resolve
// failure against `boot.ts` and reads like a framework bug.
if (schema !== undefined && !existsSync(schema)) {
throw new Error(
`no schema module at ${schema}\n \`--schema\` is resolved against the current directory (${process.cwd()}). Pass an absolute path, or run the compiler from the directory that path is relative to.`
);
}
const { script } = await compileBoot({ schema });
try {
assertBootScript(script);
assertBootRuns(script);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const hint = guardHint(message);
throw new Error(
[
`boot compile refused the artifact: ${message}`,
schema === undefined ? undefined : `schema module: ${schema}`,
hint
]
.filter((line) => line !== undefined)
.join('\n ')
);
}
const module = renderBootModule(script);
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, module);
console.log(
`Generated ${out} — script ${Buffer.byteLength(script, 'utf8')} bytes, ${bootCspHash(script)}`
);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main();
} catch (error) {
// A compiler talks to a CONSUMER. Node's default handler prints the
// message under a source banner and over six frames of the
// framework's own stack, which reads like a crash IN UIX rather than
// an error in the site's schema — and buries the `usage:` block it
// was carrying. The message is the whole product here.
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

Powered by TurnKey Linux.