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.
415 lines
18 KiB
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);
|
|
}
|
|
}
|