CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic .tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by Core before anything is installed. This slice implements the portable `any` target, with the module template as the end-to-end proof. ADR 0049 is accepted. @ekklesia/contracts: the Module Package v1 contract. The strict ekklesia.package.json schema (packageFormat 1, identity, target, runtime, provenance, incorporated Ekklesia packages, strict transitional facts, an exact byte-wise-sorted file list with size and SHA-256), target names, and the generated runtime package.json. @ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir> --target any --out <dir> --source-revision <commit>`. - Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle included; only Node built-ins remain imports. Any warning, unresolved import, external dependency, or native or platform-specific package fails. - Copies browser artifacts as built, after proving them current, and the migrations; generates package.json and ekklesia.package.json; never ships src, tests, declarations, source maps, or workspace ranges. - THIRD_PARTY_NOTICES: every third-party package traced from the bundles' own inputs, credited once with its declared licence and every licence, copying, copyright, and notice file it ships. A missing licence fails; a missing text fails unless its standard text is complete without copyright lines (Apache-2.0, vendored, needed by drizzle-orm). - Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime 0, 0755/0644, no links. Byte-identical across runs and machines. - Writes only the archive and its .sha256; never deploys, links, or installs, so the workspace's install state is untouched. Core: verifyModulePackage and `verify-module-package.js <archive> [--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe or escaping paths, and a second top-level directory; checks metadata, every file, identity, target, Node.js range, SDK, and the transitional Drizzle agreement; runs the platform's module and migration discovery; loads the backend once in a forked process with an empty environment; only then extracts, with normalized modes, into a new directory. Errors are sanitized. @ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs and whether the built file is current. The nested stylesheet build now works from the module directory, so artifacts no longer depend on the caller's working directory. The split deploy smoke builds the package with the canonical packager (scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm deploy packaging), verifies it with the same generic image, installs the verified tree, removes it, and reinstalls the exact same archive. Co-Authored-By: Claude Opus 5.5 <[email protected]>
510 lines
22 KiB
TypeScript
510 lines
22 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {
|
|
contributionLocalName,
|
|
moduleContributionDeclarationSchema,
|
|
moduleDisplayEntrypointSchema,
|
|
packageRelativePathSchema,
|
|
} from "@ekklesia/contracts";
|
|
import { type BuildOptions, build, type Plugin } from "esbuild";
|
|
|
|
/**
|
|
* The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts
|
|
* its manifest declares as `entrypoints.ui` and `entrypoints.display`.
|
|
*
|
|
* Module authors do not write bundler configuration. They put their UI at
|
|
* the fixed role-specific source locations below, declare either or both artifacts, and run one
|
|
* build; every option below is owned here so one decision applies to every module.
|
|
*/
|
|
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
|
export const MODULE_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
|
|
/** Where a declared browser contribution's source lives: `src/contributions/<local-name>/index.tsx`,
|
|
* where the local name is the part of the contribution id after the module's own id. Derived rather
|
|
* than configured, exactly like the two role entrypoints, so an author points the build nowhere. */
|
|
export const MODULE_CONTRIBUTION_SOURCE_DIRECTORY = "src/contributions";
|
|
|
|
export function moduleContributionSourceEntrypoint(contributionId: string): string {
|
|
return `${MODULE_CONTRIBUTION_SOURCE_DIRECTORY}/${contributionLocalName(contributionId)}/index.tsx`;
|
|
}
|
|
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
|
|
|
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
|
export const MODULE_UI_BUILD_TARGET = "es2022";
|
|
|
|
export type ModuleUiBuildErrorCode =
|
|
| "MODULE_UI_MANIFEST_UNREADABLE"
|
|
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
|
| "MODULE_UI_ENTRYPOINT_COLLISION"
|
|
| "MODULE_UI_SOURCE_MISSING"
|
|
| "MODULE_UI_STYLESHEET_INVALID"
|
|
| "MODULE_UI_STYLESHEET_UNDECLARED"
|
|
| "MODULE_UI_BUILD_FAILED";
|
|
|
|
export type ModuleUiArtifactKind = "ui" | "display" | "contribution";
|
|
|
|
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
|
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
|
MODULE_UI_ENTRYPOINT_UNSAFE:
|
|
"A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.",
|
|
MODULE_UI_ENTRYPOINT_COLLISION:
|
|
"A display UI entrypoint must not overwrite another declared artifact.",
|
|
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
|
|
MODULE_UI_STYLESHEET_INVALID:
|
|
"A module stylesheet may not define a global Ekklesia theme variable or keep an unresolved @import.",
|
|
MODULE_UI_STYLESHEET_UNDECLARED:
|
|
"A module UI build produced a stylesheet beside its artifact. Every style must be compiled into the declared artifact.",
|
|
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
|
|
});
|
|
|
|
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
|
|
* the CLI, where a developer wants them; the thrown error itself stays sanitized, matching every
|
|
* other Ekklesia error boundary. */
|
|
export class ModuleUiBuildError extends Error {
|
|
readonly code: ModuleUiBuildErrorCode;
|
|
/** Safe role vocabulary that lets a developer identify which declared artifact failed without
|
|
* exposing a filesystem path or raw manifest value. */
|
|
readonly kind?: ModuleUiArtifactKind;
|
|
|
|
constructor(code: ModuleUiBuildErrorCode, kind?: ModuleUiArtifactKind) {
|
|
super(errorMessages[code]);
|
|
this.name = "ModuleUiBuildError";
|
|
this.code = code;
|
|
if (kind !== undefined) {
|
|
this.kind = kind;
|
|
}
|
|
this.stack = `${this.name}: ${this.message}`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A CSS custom property in Ekklesia's own global theme namespace, being **declared** rather than
|
|
* referenced. `var(--ekklesia-x)` and `var(--ekklesia-x, fallback)` are reads and are fine; only a
|
|
* `--ekklesia-x:` declaration matches, because that is what would invent a global theme variable
|
|
* outside the registry a future theme editor enumerates (ADR 0039).
|
|
*/
|
|
const GLOBAL_THEME_DECLARATION = /--ekklesia-[a-z0-9-]*\s*:/iu;
|
|
|
|
/** An `@import` that survived compilation — a remote or otherwise unresolvable one. A local import
|
|
* is inlined by the nested bundle below and leaves nothing behind, so anything still here would make
|
|
* the artifact fetch a stylesheet at run time, which is exactly what "self-contained" excludes. */
|
|
const SURVIVING_IMPORT = /@import\s/iu;
|
|
|
|
/**
|
|
* Compiles each imported stylesheet into the artifact instead of emitting a sidecar.
|
|
*
|
|
* esbuild's default for `import "./styles.css"` from JavaScript is to collect every such stylesheet
|
|
* into an `index.css` output beside the artifact. Nothing declares that file, no host fetches it,
|
|
* and the manifest has no field for it — it would work in a local test and silently do nothing in a
|
|
* real host. ADR 0023 therefore rejected the import outright; ADR 0040 compiles it instead.
|
|
*
|
|
* Each `.css` import becomes a JavaScript module whose default export is that stylesheet's compiled
|
|
* text, produced by a nested esbuild run so local `@import`s are resolved and the result is one
|
|
* self-contained string. The author passes that string to `defineModuleUi({ styles })` and the host
|
|
* installs it into the artifact's isolated root. The declared `.js` artifact therefore remains the
|
|
* only file the platform publishes, exactly as before stylesheets existed.
|
|
*
|
|
* Other stylesheet dialects stay rejected. Adding one is a language and toolchain decision, not a
|
|
* detail of this pass, and would need its own ADR.
|
|
*/
|
|
const STYLESHEET_PLUGIN_NAME = "ekklesia-compile-stylesheet-imports";
|
|
|
|
function compileStylesheetImports(recordInputs?: (paths: readonly string[]) => void): Plugin {
|
|
const decoder = new TextDecoder();
|
|
return {
|
|
name: STYLESHEET_PLUGIN_NAME,
|
|
setup(pluginBuild) {
|
|
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
|
|
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). These patterns are
|
|
// pure ASCII, so the flag would change nothing anyway.
|
|
pluginBuild.onResolve({ filter: /\.(s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
|
errors: [
|
|
{
|
|
text:
|
|
"Module UI supports plain .css only. Other stylesheet dialects have no compilation " +
|
|
"contract in this platform (ADR 0040).",
|
|
location: { file: args.importer },
|
|
},
|
|
],
|
|
}));
|
|
|
|
// The nested build works from the module directory, exactly like the outer one. Without it
|
|
// esbuild names each stylesheet relative to whatever directory the caller ran from, and the
|
|
// artifact's bytes would depend on the caller (ADR 0049 requires a deterministic package).
|
|
const workingDirectory = pluginBuild.initialOptions.absWorkingDir ?? process.cwd();
|
|
pluginBuild.onLoad({ filter: /\.css$/i }, async (args) => {
|
|
const compiled = await build({
|
|
absWorkingDir: workingDirectory,
|
|
bundle: true,
|
|
entryPoints: [args.path],
|
|
logLevel: "silent",
|
|
metafile: recordInputs !== undefined,
|
|
minify: false,
|
|
target: MODULE_UI_BUILD_TARGET,
|
|
write: false,
|
|
});
|
|
// A stylesheet's own @imports are bundled by this nested build, so the outer build never
|
|
// sees them. An analysis must, because they ship inside the artifact.
|
|
if (recordInputs && compiled.metafile) {
|
|
recordInputs(
|
|
Object.keys(compiled.metafile.inputs).map((input) =>
|
|
path.resolve(workingDirectory, input),
|
|
),
|
|
);
|
|
}
|
|
const stylesheet = compiled.outputFiles
|
|
.map((outputFile) => decoder.decode(outputFile.contents))
|
|
.join("\n");
|
|
|
|
if (GLOBAL_THEME_DECLARATION.test(stylesheet)) {
|
|
return {
|
|
errors: [
|
|
{
|
|
text:
|
|
"A module stylesheet may reference Ekklesia's global theme variables but must " +
|
|
"never declare one. Derive a private alias under your own prefix instead " +
|
|
"(ADR 0039).",
|
|
location: { file: args.path },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
if (SURVIVING_IMPORT.test(stylesheet)) {
|
|
return {
|
|
errors: [
|
|
{
|
|
text:
|
|
"A module stylesheet left an unresolved @import. A declared artifact must be " +
|
|
"self-contained, so every import has to resolve to a local file (ADR 0040).",
|
|
location: { file: args.path },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return { contents: `export default ${JSON.stringify(stylesheet)};`, loader: "js" };
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface BuildModuleUiOptions {
|
|
/** The module package directory containing `ekklesia.module.json`. */
|
|
readonly moduleDirectory: string;
|
|
}
|
|
|
|
export interface ModuleUiBuildResult {
|
|
/** Built artifacts in stable role order: primary UI, then display, then each declared contribution
|
|
* in manifest order. Empty for backend-only modules, which makes the canonical command usable
|
|
* unchanged by every module package. */
|
|
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
|
|
}
|
|
|
|
export interface ModuleUiBuildArtifactResult {
|
|
readonly kind: ModuleUiArtifactKind;
|
|
/** For a contribution, the declared contribution id — so a multi-contribution build reports which
|
|
* artifact it produced. Absent for the two role entrypoints. */
|
|
readonly contributionId?: string;
|
|
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
|
|
readonly outputFile: string;
|
|
/** Absolute path of the fixed source entrypoint that produced it. */
|
|
readonly sourceFile: string;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult;
|
|
|
|
/** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */
|
|
async function resolveDeclaredArtifacts(
|
|
moduleDirectory: string,
|
|
): Promise<readonly DeclaredModuleUiArtifact[]> {
|
|
let manifest: unknown;
|
|
try {
|
|
manifest = JSON.parse(
|
|
await readFile(path.join(moduleDirectory, MODULE_MANIFEST_FILENAME), "utf8"),
|
|
) as unknown;
|
|
} catch {
|
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
|
|
}
|
|
|
|
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
|
|
if (!isRecord(entrypoints)) {
|
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
|
|
}
|
|
|
|
const display = entrypoints.display;
|
|
const manifestContributions = isRecord(manifest) ? manifest.contributions : undefined;
|
|
const declaredContributions = Array.isArray(manifestContributions) ? manifestContributions : [];
|
|
for (const contribution of declaredContributions) {
|
|
if (!moduleContributionDeclarationSchema.safeParse(contribution).success) {
|
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "contribution");
|
|
}
|
|
}
|
|
const declarations: readonly {
|
|
readonly contributionId?: string;
|
|
readonly declared: unknown;
|
|
readonly kind: ModuleUiArtifactKind;
|
|
readonly present: boolean;
|
|
readonly source: string;
|
|
}[] = [
|
|
{
|
|
declared: entrypoints.ui,
|
|
kind: "ui",
|
|
present: entrypoints.ui !== undefined,
|
|
source: MODULE_UI_SOURCE_ENTRYPOINT,
|
|
},
|
|
{
|
|
declared: isRecord(display) ? display.artifact : display,
|
|
kind: "display",
|
|
present: display !== undefined,
|
|
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
|
|
},
|
|
...(declaredContributions as { artifact: string; id: string }[]).map((contribution) => ({
|
|
contributionId: contribution.id,
|
|
declared: contribution.artifact,
|
|
kind: "contribution" as const,
|
|
present: true,
|
|
source: moduleContributionSourceEntrypoint(contribution.id),
|
|
})),
|
|
];
|
|
const artifacts: DeclaredModuleUiArtifact[] = [];
|
|
|
|
for (const declaration of declarations) {
|
|
if (!declaration.present) {
|
|
continue;
|
|
}
|
|
const declared = declaration.declared;
|
|
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
|
|
// never write somewhere discovery would later reject, even if that grammar changes.
|
|
if (
|
|
typeof declared !== "string" ||
|
|
!packageRelativePathSchema.safeParse(declared).success ||
|
|
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
|
|
) {
|
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
|
|
}
|
|
artifacts.push({
|
|
...(declaration.contributionId === undefined
|
|
? {}
|
|
: { contributionId: declaration.contributionId }),
|
|
kind: declaration.kind,
|
|
outputFile: path.resolve(moduleDirectory, declared),
|
|
sourceFile: path.join(moduleDirectory, declaration.source),
|
|
});
|
|
}
|
|
|
|
if (display !== undefined && !moduleDisplayEntrypointSchema.safeParse(display).success) {
|
|
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
|
|
}
|
|
|
|
// No declared artifact may overwrite another declared file — an entrypoint this build does not
|
|
// produce (`server`, `schema`, ...), or another artifact it does. Checked before esbuild runs, so
|
|
// a collision can never destroy output that was already correct.
|
|
const backendEntrypointPaths = new Set(
|
|
(["server", "schema", "migrations", "jobs"] as const)
|
|
.map((entrypointName) => entrypoints[entrypointName])
|
|
.filter((configured): configured is string => typeof configured === "string")
|
|
.map((configured) => path.resolve(moduleDirectory, configured)),
|
|
);
|
|
const claimedOutputs = new Set<string>();
|
|
for (const artifact of artifacts) {
|
|
if (
|
|
backendEntrypointPaths.has(artifact.outputFile) ||
|
|
claimedOutputs.has(artifact.outputFile)
|
|
) {
|
|
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", artifact.kind);
|
|
}
|
|
claimedOutputs.add(artifact.outputFile);
|
|
}
|
|
|
|
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
|
|
// output looking like a successful package build.
|
|
for (const artifact of artifacts) {
|
|
try {
|
|
await readFile(artifact.sourceFile);
|
|
} catch {
|
|
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING", artifact.kind);
|
|
}
|
|
}
|
|
|
|
return artifacts;
|
|
}
|
|
|
|
/**
|
|
* Bundles a module's UI into the artifact its manifest declares.
|
|
*
|
|
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
|
|
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
|
|
*/
|
|
/** The one esbuild configuration for a declared artifact, shared by building and analysing so the
|
|
* two can never describe different builds. */
|
|
function moduleUiBuildOptions(
|
|
moduleDirectory: string,
|
|
artifact: DeclaredModuleUiArtifact,
|
|
recordInputs?: (paths: readonly string[]) => void,
|
|
): BuildOptions & { write: false } {
|
|
return {
|
|
absWorkingDir: moduleDirectory,
|
|
// Every dependency, React included, is bundled. The artifact must run in any host, including
|
|
// one that uses no React at all, so nothing is externalized (ADR 0023).
|
|
bundle: true,
|
|
define: {
|
|
// React reads this at module scope. Without it the bundle would reference `process`, a
|
|
// Node global that does not exist in a browser, and crash on load.
|
|
"process.env.NODE_ENV": '"production"',
|
|
},
|
|
entryPoints: [artifact.sourceFile],
|
|
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
|
|
// requires a real default export.
|
|
format: "esm",
|
|
jsx: "automatic",
|
|
// Unminified deliberately: an operator is expected to review the artifact they install
|
|
// (see docs/development/security.md), and nothing else in this repository minifies.
|
|
minify: false,
|
|
outfile: artifact.outputFile,
|
|
platform: "browser",
|
|
plugins: [compileStylesheetImports(recordInputs)],
|
|
sourcemap: true,
|
|
target: MODULE_UI_BUILD_TARGET,
|
|
// Fail rather than silently shipping a partial bundle.
|
|
logLevel: "silent",
|
|
// Compile every role successfully before writing any role. This makes a two-artifact build
|
|
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
|
|
write: false,
|
|
};
|
|
}
|
|
|
|
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
|
|
const moduleDirectory = path.resolve(options.moduleDirectory);
|
|
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
|
|
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
|
|
|
|
for (const artifact of artifacts) {
|
|
try {
|
|
const result = await build(moduleUiBuildOptions(moduleDirectory, artifact));
|
|
// Belt and braces on the stylesheet contract. The plugin above turns every `.css` import into
|
|
// JavaScript, so esbuild has nothing left to emit a stylesheet from — but a future option or
|
|
// loader change could reintroduce a sidecar, and a sidecar is precisely the failure this
|
|
// design exists to make impossible: a file nothing declares, no host fetches, and every local
|
|
// test silently passes without. Producing one fails the build rather than shipping it.
|
|
const sidecar = result.outputFiles.find(
|
|
(outputFile) => path.extname(outputFile.path).toLowerCase() === ".css",
|
|
);
|
|
if (sidecar) {
|
|
throw new ModuleUiBuildError("MODULE_UI_STYLESHEET_UNDECLARED", artifact.kind);
|
|
}
|
|
pendingWrites.push(
|
|
result.outputFiles.map((outputFile) => ({
|
|
contents: outputFile.contents,
|
|
path: outputFile.path,
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ModuleUiBuildError) {
|
|
throw error;
|
|
}
|
|
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
|
// programmatic caller never receives raw paths or internals.
|
|
if (isRecord(error) && Array.isArray(error.errors)) {
|
|
// A stylesheet the module authored broke one of this platform's own rules, rather than
|
|
// failing to compile. Reported with its own code so a caller can tell "your CSS is not
|
|
// allowed to do that" from "your TSX does not compile".
|
|
const code = error.errors.some(
|
|
(diagnostic) => isRecord(diagnostic) && diagnostic.pluginName === STYLESHEET_PLUGIN_NAME,
|
|
)
|
|
? "MODULE_UI_STYLESHEET_INVALID"
|
|
: "MODULE_UI_BUILD_FAILED";
|
|
throw Object.assign(new ModuleUiBuildError(code, artifact.kind), {
|
|
diagnostics: error.errors,
|
|
});
|
|
}
|
|
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind);
|
|
}
|
|
}
|
|
|
|
for (const [index, outputs] of pendingWrites.entries()) {
|
|
try {
|
|
for (const output of outputs) {
|
|
await mkdir(path.dirname(output.path), { recursive: true });
|
|
await writeFile(output.path, output.contents);
|
|
}
|
|
} catch {
|
|
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifacts[index]?.kind);
|
|
}
|
|
}
|
|
|
|
return Object.freeze({
|
|
artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))),
|
|
});
|
|
}
|
|
|
|
export interface ModuleUiAnalysis {
|
|
/** Every declared artifact, in the same stable role order `buildModuleUi` produces them. */
|
|
readonly artifacts: readonly ModuleUiArtifactAnalysis[];
|
|
}
|
|
|
|
export interface ModuleUiArtifactAnalysis {
|
|
readonly kind: ModuleUiArtifactKind;
|
|
readonly contributionId?: string;
|
|
/** Absolute path of the artifact the manifest declares. */
|
|
readonly outputFile: string;
|
|
/** Absolute paths of every source file bundled into it, stylesheets and their imports included,
|
|
* sorted and unique. What a packager credits for the code it redistributes. */
|
|
readonly inputs: readonly string[];
|
|
/** Whether the artifact on disk is byte-for-byte what these sources build today. False when it
|
|
* was never built or is stale, so a caller never describes one artifact and ships another. */
|
|
readonly builtArtifactCurrent: boolean;
|
|
}
|
|
|
|
/**
|
|
* Runs the canonical module UI build in memory, exactly as `buildModuleUi` would, and reports what
|
|
* went into each declared artifact. Writes nothing. Used by the module packager (ADR 0049) to find
|
|
* the third-party code inside already-built browser artifacts, and to refuse a stale build.
|
|
*/
|
|
export async function analyzeModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiAnalysis> {
|
|
const moduleDirectory = path.resolve(options.moduleDirectory);
|
|
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
|
|
const analyses: ModuleUiArtifactAnalysis[] = [];
|
|
for (const artifact of artifacts) {
|
|
const inputs = new Set<string>();
|
|
let result: Awaited<ReturnType<typeof build>>;
|
|
try {
|
|
result = await build({
|
|
...moduleUiBuildOptions(moduleDirectory, artifact, (paths) => {
|
|
for (const input of paths) {
|
|
inputs.add(input);
|
|
}
|
|
}),
|
|
metafile: true,
|
|
});
|
|
} catch {
|
|
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind);
|
|
}
|
|
for (const input of Object.keys(result.metafile?.inputs ?? {})) {
|
|
inputs.add(path.resolve(moduleDirectory, input));
|
|
}
|
|
const built = result.outputFiles?.find((outputFile) => outputFile.path === artifact.outputFile);
|
|
let onDisk: Buffer | undefined;
|
|
try {
|
|
onDisk = await readFile(artifact.outputFile);
|
|
} catch {
|
|
onDisk = undefined;
|
|
}
|
|
analyses.push(
|
|
Object.freeze({
|
|
...(artifact.contributionId === undefined
|
|
? {}
|
|
: { contributionId: artifact.contributionId }),
|
|
builtArtifactCurrent:
|
|
built !== undefined && onDisk !== undefined && Buffer.from(built.contents).equals(onDisk),
|
|
inputs: Object.freeze([...inputs].sort()),
|
|
kind: artifact.kind,
|
|
outputFile: artifact.outputFile,
|
|
}),
|
|
);
|
|
}
|
|
return Object.freeze({ artifacts: Object.freeze(analyses) });
|
|
}
|