ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]>
231 lines
8.0 KiB
TypeScript
231 lines
8.0 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { buildModuleUi, ModuleUiBuildError } from "../src/index.js";
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories
|
|
.splice(0)
|
|
.map((directory) => rm(directory, { recursive: true, force: true })),
|
|
);
|
|
});
|
|
|
|
interface ModuleFixtureOptions {
|
|
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
|
|
readonly ui?: string;
|
|
readonly manifest?: string;
|
|
readonly files?: Record<string, string>;
|
|
}
|
|
|
|
async function createModule(options: ModuleFixtureOptions = {}): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-build-"));
|
|
temporaryDirectories.push(directory);
|
|
|
|
const manifest =
|
|
options.manifest ??
|
|
JSON.stringify({
|
|
manifestVersion: 4,
|
|
id: "example",
|
|
name: "Example",
|
|
version: "0.1.0",
|
|
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
...(options.ui === undefined ? {} : { ui: options.ui }),
|
|
},
|
|
});
|
|
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
|
|
|
|
for (const [relativePath, contents] of Object.entries(options.files ?? {})) {
|
|
const target = path.join(directory, relativePath);
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, contents);
|
|
}
|
|
return directory;
|
|
}
|
|
|
|
/** A UI entrypoint with no React, so the build tool's own tests exercise the pipeline mechanics
|
|
* without depending on a UI framework. The real React path is proven end to end by the module
|
|
* template's own test suite. */
|
|
const plainUiSource = `
|
|
const definition = {
|
|
id: "example",
|
|
version: "0.1.0",
|
|
mount(container) {
|
|
container.textContent = "example";
|
|
return () => {
|
|
container.textContent = "";
|
|
};
|
|
},
|
|
};
|
|
export default definition;
|
|
`;
|
|
|
|
describe("module UI build", () => {
|
|
it("bundles the declared entrypoint into the exact path the manifest names", async () => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: { "src/ui/index.tsx": plainUiSource },
|
|
});
|
|
|
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
|
|
|
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
|
|
const artifact = await readFile(result.outputFile, "utf8");
|
|
expect(artifact).toContain("export {");
|
|
expect(artifact).toContain("as default");
|
|
});
|
|
|
|
it("honours an alternative declared artifact path", async () => {
|
|
const directory = await createModule({
|
|
ui: "./build/browser/ui.mjs",
|
|
files: { "src/ui/index.tsx": plainUiSource },
|
|
});
|
|
|
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
|
|
|
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
|
|
});
|
|
|
|
it("emits a source map alongside the artifact", async () => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: { "src/ui/index.tsx": plainUiSource },
|
|
});
|
|
|
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
|
|
|
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
|
|
});
|
|
|
|
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
|
|
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
|
|
|
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
|
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
|
|
});
|
|
});
|
|
|
|
it("fails when the declared UI has no source entrypoint", async () => {
|
|
const directory = await createModule({ ui: "./dist/ui/index.js" });
|
|
|
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
|
code: "MODULE_UI_SOURCE_MISSING",
|
|
});
|
|
});
|
|
|
|
it("fails when the manifest cannot be read", async () => {
|
|
const directory = await createModule({ manifest: "{ not json" });
|
|
|
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
|
code: "MODULE_UI_MANIFEST_UNREADABLE",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["escapes the package", "../outside/ui.js"],
|
|
["is absolute", "/tmp/ui.js"],
|
|
["traverses upward", "./dist/../../ui.js"],
|
|
["is source TypeScript", "./src/ui/index.tsx"],
|
|
["carries a query", "./dist/ui/index.js?raw"],
|
|
["uses backslashes", ".\\dist\\ui\\index.js"],
|
|
])("refuses to write an artifact whose declared path %s", async (_label, ui) => {
|
|
const directory = await createModule({ ui, files: { "src/ui/index.tsx": plainUiSource } });
|
|
|
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
|
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
|
|
});
|
|
});
|
|
|
|
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: { "src/ui/index.tsx": "export default { id: 'example', " },
|
|
});
|
|
|
|
try {
|
|
await buildModuleUi({ moduleDirectory: directory });
|
|
expect.unreachable("expected the build to fail");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ModuleUiBuildError);
|
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
|
|
// The sanitized message carries no path; the diagnostics a developer needs are attached.
|
|
expect((error as ModuleUiBuildError).message).not.toContain(directory);
|
|
expect((error as { diagnostics?: unknown[] }).diagnostics?.length).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it("fails on an unresolvable import instead of emitting a partial bundle", async () => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: { "src/ui/index.tsx": 'import "totally-not-installed";\nexport default {};' },
|
|
});
|
|
|
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
|
|
code: "MODULE_UI_BUILD_FAILED",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
"styles.css",
|
|
"theme.scss",
|
|
"layout.less",
|
|
])("rejects a %s import rather than emitting an undeclared sidecar asset", async (stylesheet) => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: {
|
|
"src/ui/index.tsx": `import "./${stylesheet}";\nexport default {};`,
|
|
[`src/ui/${stylesheet}`]: ".example { color: red; }",
|
|
},
|
|
});
|
|
|
|
try {
|
|
await buildModuleUi({ moduleDirectory: directory });
|
|
expect.unreachable("expected the stylesheet import to be rejected");
|
|
} catch (error) {
|
|
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
|
|
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
|
|
expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe(
|
|
true,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("never leaves a stylesheet sidecar next to the artifact", async () => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: {
|
|
"src/ui/index.tsx": 'import "./styles.css";\nexport default {};',
|
|
"src/ui/styles.css": ".example { color: red; }",
|
|
},
|
|
});
|
|
|
|
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf(
|
|
ModuleUiBuildError,
|
|
);
|
|
await expect(
|
|
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
|
|
const directory = await createModule({
|
|
ui: "./dist/ui/index.js",
|
|
files: {
|
|
"src/ui/index.tsx":
|
|
'const mode = process.env.NODE_ENV;\nexport default { id: "example", version: "0.1.0", mount: () => { void mode; } };',
|
|
},
|
|
});
|
|
|
|
const result = await buildModuleUi({ moduleDirectory: directory });
|
|
const artifact = await readFile(result.outputFile, "utf8");
|
|
|
|
expect(artifact).toContain('"production"');
|
|
expect(artifact).not.toContain("process.env.NODE_ENV");
|
|
});
|
|
});
|