263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
import { installDependencies } from "./install-dependencies.mjs";
|
|
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 = config.group
|
|
? (pkg.components ?? []).filter((entry) => Object.hasOwn(settings.entries, entry.id))
|
|
: (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 });
|
|
await installDependencies({
|
|
destination: modules,
|
|
application: config.nodeModules,
|
|
defaults: [path.join(settings.dependencies, "node_modules")],
|
|
selected: { "@quixos/camino-react": settings.sdk },
|
|
reserved: [
|
|
"react",
|
|
"react-dom",
|
|
"scheduler",
|
|
"csstype",
|
|
"@types/react",
|
|
"@types/react-dom",
|
|
"@types/prop-types",
|
|
"@quixos/camino-react",
|
|
"@quixos/camino-replica-core",
|
|
"@quixos/camino-replica-client",
|
|
"@quixos/camino-replica-engine",
|
|
],
|
|
});
|
|
const packageFile = path.join(work, "package.json");
|
|
const packageMetadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => {
|
|
if (error.code === "ENOENT") return {};
|
|
throw error;
|
|
});
|
|
await fs.writeFile(packageFile, JSON.stringify({ ...packageMetadata, type: "module" }));
|
|
// CommonJS dependencies may require React. An ESM bridge lets esbuild lower
|
|
// that require while retaining the one host-owned runtime instance.
|
|
const sharedAliases = [];
|
|
for (const [name, url] of Object.entries(imports)) {
|
|
const shim = path.join(work, "shared-" + digest(Buffer.from(name)).slice(0, 16) + ".mjs");
|
|
await fs.writeFile(
|
|
shim,
|
|
`export * from ${JSON.stringify(url)};` +
|
|
(name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`),
|
|
);
|
|
sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url);
|
|
}
|
|
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 });
|
|
// Public props are type-only inputs owned by imported interface resources.
|
|
// Augment the generated nominal registry without importing an implementation.
|
|
const propsImports = [],
|
|
propsMembers = [],
|
|
seenProps = new Set();
|
|
for (const root of settings.contracts ?? []) {
|
|
const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8"));
|
|
if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion");
|
|
const contracts = plan.definition.workspace.interfaceImports.filter(
|
|
(iface) =>
|
|
iface.revisionId === companion.interfaceRevisionId ||
|
|
iface.application?.definitionId === companion.interfaceRevisionId,
|
|
);
|
|
if (!contracts.length) continue;
|
|
for (const [memberId, entry] of Object.entries(companion.members)) {
|
|
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid props type export");
|
|
const alias = `Props${propsImports.length}`;
|
|
propsImports.push(
|
|
`import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`,
|
|
);
|
|
for (const iface of contracts) {
|
|
if (
|
|
!(iface.template?.members ?? iface.members).some(
|
|
(member) => member.kind === "module" && member.id === memberId,
|
|
)
|
|
)
|
|
throw Error("Props companion names an unknown module member");
|
|
const key = JSON.stringify([iface.revisionId, memberId]);
|
|
if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key);
|
|
seenProps.add(key);
|
|
propsMembers.push(`${JSON.stringify(key)}: ${alias};`);
|
|
}
|
|
}
|
|
}
|
|
const propsPath = "component-props.d.ts";
|
|
await fs.writeFile(
|
|
path.join(work, propsPath),
|
|
propsImports.join("\n") +
|
|
`\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`,
|
|
);
|
|
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 + ".tsx");
|
|
const module = pkg.modules.find((module) => module.id === id);
|
|
await fs.writeFile(
|
|
witness,
|
|
`import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` +
|
|
(module.contract === "org.quixos.react.subject-only/1"
|
|
? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = <Component subject={subject}/>;\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,
|
|
propsPath,
|
|
],
|
|
{ cwd: work, stdio: "inherit" },
|
|
);
|
|
const metadata = path.join(work, "component-meta-" + key + ".json");
|
|
const facade = "component-entry-" + key + ".ts";
|
|
await fs.writeFile(
|
|
path.join(work, facade),
|
|
`export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`,
|
|
);
|
|
await fs.mkdir(path.join(output, directory), { recursive: true });
|
|
execFileSync(
|
|
config.esbuild,
|
|
[
|
|
facade,
|
|
"--bundle",
|
|
"--platform=browser",
|
|
"--format=esm",
|
|
"--target=es2024",
|
|
"--jsx=automatic",
|
|
'--define:process.env.NODE_ENV="production"',
|
|
"--metafile=" + metadata,
|
|
"--outfile=" + path.join(output, directory, "module.mjs"),
|
|
...sharedAliases,
|
|
...["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: "org.quixos.react.esm/1",
|
|
path: directory + "/module.mjs",
|
|
exportName: entry.export,
|
|
sharedModules,
|
|
files,
|
|
};
|
|
components.push({ ...body, contentDigest: compiler.componentArtifactDigest(body) });
|
|
}
|
|
return components;
|
|
}
|