Build checked component artifacts and shared React session SDK (WI-09)
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) {
|
||||
const settings = config.components;
|
||||
if (!settings) {
|
||||
if (pkg.components?.length) throw Error("Declared components require checked component build settings");
|
||||
return [];
|
||||
}
|
||||
const declarations = pkg.components ?? [];
|
||||
const entries = Object.entries(settings.entries);
|
||||
if (
|
||||
entries.length !== declarations.length ||
|
||||
entries.some(([id]) => !declarations.some((component) => component.id === id))
|
||||
)
|
||||
throw Error("Component build entries differ from package declarations");
|
||||
const runtime = JSON.parse(await fs.readFile(path.join(settings.runtime, "runtime.json"), "utf8"));
|
||||
if (
|
||||
runtime.sdkCodeDigest !== digest(await fs.readFile(path.join(settings.sdk, "dist/index.js"))) ||
|
||||
runtime.sdkTypeDigest !== digest(await fs.readFile(path.join(settings.sdk, "dist/index.d.ts")))
|
||||
)
|
||||
throw Error("Component SDK differs from selected shared runtime");
|
||||
const sharedFiles = [];
|
||||
for (const file of runtime.files) {
|
||||
compiler.artifactPath(file.path);
|
||||
const bytes = await fs.readFile(path.join(settings.runtime, file.path));
|
||||
if (bytes.length !== file.bytes || digest(bytes) !== file.digest) throw Error("Modified component runtime");
|
||||
const target = "share/quixos/component-shared/" + file.path;
|
||||
await fs.mkdir(path.dirname(path.join(output, target)), { recursive: true });
|
||||
await fs.writeFile(path.join(output, target), bytes);
|
||||
sharedFiles.push(await recordFile(target, file.mediaType));
|
||||
}
|
||||
const sharedModules = Object.fromEntries(
|
||||
compiler.componentSharedModules.map((name) => {
|
||||
const file = sharedFiles.find((file) => file.path === "share/quixos/component-shared/" + runtime.modules[name]);
|
||||
if (!file) throw Error("Missing shared component module " + name);
|
||||
return [name, file.path];
|
||||
}),
|
||||
);
|
||||
const imports = Object.fromEntries(
|
||||
Object.entries(sharedModules).map(([name, file]) => [
|
||||
name,
|
||||
compiler.contentArtifactUrl(sharedFiles.find((value) => value.path === file)),
|
||||
]),
|
||||
);
|
||||
const core = runtime.files.find((file) => file.path === "core.mjs");
|
||||
const selected = JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8")).files.find(
|
||||
(file) => file.path === "core.mjs",
|
||||
);
|
||||
if (core?.digest !== selected?.digest) throw Error("Component runtime core differs from portable runtime core");
|
||||
const work = path.resolve("work-components");
|
||||
await fs.cp(config.source, work, { recursive: true, dereference: false });
|
||||
const writable = async (directory) => {
|
||||
await fs.chmod(directory, 0o755);
|
||||
for (const item of await fs.readdir(directory, { withFileTypes: true })) {
|
||||
const file = path.join(directory, item.name);
|
||||
if (item.isSymbolicLink()) throw Error("Authored component source cannot contain symlinks");
|
||||
if (item.isDirectory()) await writable(file);
|
||||
else await fs.chmod(file, 0o644);
|
||||
}
|
||||
};
|
||||
await writable(work);
|
||||
const modules = path.join(work, "node_modules");
|
||||
await fs.mkdir(path.join(modules, "@quixos"), { recursive: true });
|
||||
if (config.nodeModules)
|
||||
for (const item of await fs.readdir(config.nodeModules, { withFileTypes: true })) {
|
||||
if (["@quixos", "react", "react-dom", "scheduler", "@types", "csstype"].includes(item.name))
|
||||
throw Error("Application dependencies cannot substitute selected React/client types");
|
||||
await fs.symlink(path.join(config.nodeModules, item.name), path.join(modules, item.name));
|
||||
}
|
||||
for (const item of await fs.readdir(path.join(settings.dependencies, "node_modules"))) {
|
||||
await fs.symlink(path.join(settings.dependencies, "node_modules", item), path.join(modules, item));
|
||||
}
|
||||
await fs.symlink(settings.sdk, path.join(modules, "@quixos/camino-react"));
|
||||
await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" }));
|
||||
const bindingPath = compiler.artifactPath(settings.bindingOutput),
|
||||
bindings = compiler.generateComponentClientBindings(plan, pkg.revision),
|
||||
assets = compiler.componentAssetDeclarations();
|
||||
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
|
||||
await fs.writeFile(path.join(work, bindingPath), bindings);
|
||||
const assetPath = "component-assets.d.ts";
|
||||
await fs.writeFile(path.join(work, assetPath), assets);
|
||||
await fs.writeFile(path.join(output, "share/quixos/generated/client.ts"), bindings);
|
||||
await fs.writeFile(path.join(output, "share/quixos/generated/component-assets.d.ts"), assets);
|
||||
generated.set("component-client", { file: path.join(work, bindingPath), source: bindings });
|
||||
generated.set("component-assets", { file: path.join(work, assetPath), source: assets });
|
||||
const components = [];
|
||||
for (const [id, entry] of entries) {
|
||||
const declaration = declarations.find((component) => component.id === id),
|
||||
source = compiler.artifactPath(entry.entry);
|
||||
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name");
|
||||
const key = digest(Buffer.from(id)).slice(0, 24),
|
||||
directory = "share/quixos/components/" + key;
|
||||
const witness = path.join(work, "component-check-" + key + ".ts");
|
||||
await fs.writeFile(
|
||||
witness,
|
||||
`import type {Components} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Components[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n`,
|
||||
);
|
||||
execFileSync(
|
||||
config.node,
|
||||
[
|
||||
config.tsc,
|
||||
"--noEmit",
|
||||
"--strict",
|
||||
"--skipLibCheck",
|
||||
"false",
|
||||
"--target",
|
||||
"es2024",
|
||||
"--lib",
|
||||
"es2024,dom,dom.iterable,esnext.disposable",
|
||||
"--module",
|
||||
"nodenext",
|
||||
"--moduleResolution",
|
||||
"nodenext",
|
||||
"--jsx",
|
||||
"react-jsx",
|
||||
path.basename(witness),
|
||||
assetPath,
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
);
|
||||
const metadata = path.join(work, "component-meta-" + key + ".json");
|
||||
await fs.mkdir(path.join(output, directory), { recursive: true });
|
||||
execFileSync(
|
||||
config.esbuild,
|
||||
[
|
||||
source,
|
||||
"--bundle",
|
||||
"--platform=browser",
|
||||
"--format=esm",
|
||||
"--target=es2024",
|
||||
"--jsx=automatic",
|
||||
'--define:process.env.NODE_ENV="production"',
|
||||
"--metafile=" + metadata,
|
||||
"--outfile=" + path.join(output, directory, "module.mjs"),
|
||||
...Object.entries(imports).flatMap(([name, url]) => ["--alias:" + name + "=" + url, "--external:" + url]),
|
||||
...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map(
|
||||
(extension) => "--loader:." + extension + "=dataurl",
|
||||
),
|
||||
],
|
||||
{ cwd: work, stdio: "inherit" },
|
||||
);
|
||||
const meta = JSON.parse(await fs.readFile(metadata, "utf8")),
|
||||
outputs = Object.entries(meta.outputs);
|
||||
const main = outputs.find(([file]) => file.endsWith("/module.mjs"))?.[1];
|
||||
if (!main?.exports.includes(entry.export)) throw Error("Component bundle lacks declared export");
|
||||
if (
|
||||
Object.keys(meta.inputs).some((file) =>
|
||||
/camino-replica-engine|camino-replica-client|replicache|node_modules\/(?:react|react-dom|scheduler)(?:\/|$)/.test(
|
||||
file,
|
||||
),
|
||||
)
|
||||
)
|
||||
throw Error("Component bundled an internal engine/client or duplicate React");
|
||||
if (
|
||||
outputs.some(([, value]) =>
|
||||
value.imports.some((imported) => imported.external && !Object.values(imports).includes(imported.path)),
|
||||
)
|
||||
)
|
||||
throw Error("Component has an undeclared external module or asset");
|
||||
const files = [...sharedFiles];
|
||||
for (const [file] of outputs) {
|
||||
const absolute = path.resolve(work, file),
|
||||
relative = path.relative(output, absolute);
|
||||
compiler.artifactPath(relative);
|
||||
if (!relative.startsWith(directory + "/")) throw Error("Component output escaped declared artifact closure");
|
||||
files.push(await recordFile(relative, relative.endsWith(".css") ? "text/css" : "text/javascript"));
|
||||
}
|
||||
const body = {
|
||||
id,
|
||||
subject: declaration.subject,
|
||||
compatibility: "quixos-react-component-v1",
|
||||
path: directory + "/module.mjs",
|
||||
exportName: entry.export,
|
||||
sharedModules,
|
||||
files,
|
||||
};
|
||||
components.push({ ...body, contentDigest: compiler.componentArtifactDigest(body) });
|
||||
}
|
||||
return components;
|
||||
}
|
||||
Reference in New Issue
Block a user