Files
Ekklesia/packages/module-pack/src/pack-module.ts
T
ekklesiaandClaude Opus 5.5 89d5d0f2da
CI / verify (pull_request) Successful in 1m2s
CI / infrastructure-smoke (pull_request) Successful in 28s
CI / migration-smoke (pull_request) Successful in 1m52s
CI / standalone-module (pull_request) Successful in 26s
CI / image-build (pull_request) Successful in 30s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m42s
feat: standalone module-build readiness for the public @ekklesia packages (ADR 0053)
Makes the public module-development package set usable from a module
repository outside the monorepo. Nothing is published.

- Curated @ekklesia/module-sdk: an explicit author API, no contracts
  barrel, no identity/authorization internals, no Drizzle and no legacy
  ModuleDatabase type. defineModuleRuntime returns the SDK's own named
  interfaces so a module's emitted declarations never reach into
  @ekklesia/contracts. defineModuleSchema is gone; createModuleTable and
  defineModuleSchemaMetadata live in @ekklesia/module-database.
- Drizzle: no longer a dependency of module-sdk or contracts. Core keeps
  the legacy Drizzle schema validator (moved to core/src/modules) and
  its legacy runtime support; drizzle-orm is an optional peer only.
- React is a peer (plus devDependency) of @ekklesia/ui; the boundary
  checker allows exactly that consumer-selected peer.
- @ekklesia/typescript-config is publishable; module-ui-host is private.
  Every public package carries "ekklesia.distribution":
  "module-development" and no monorepo-root prepack.
- module-pack reads the Node.js range from the module's own engines.node
  and recognizes Ekklesia packages by name plus that marker, so
  registry-installed packages are incorporated, SDK-compatibility
  checked, and never credited as third-party.
- scripts/pack-public-packages.ts (pnpm packages:pack) rebuilds, packs
  and inspects the nine public tarballs; scripts/test-standalone-module.sh
  (pnpm test:standalone-module, new CI job) installs them into a fixture
  module outside the repository and proves it typechecks, builds,
  packages, and verifies.
- Public-package boundary tests and module-development docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:28:54 -05:00

829 lines
32 KiB
TypeScript

import { createHash } from "node:crypto";
import { lstat, mkdir, readdir, readFile, realpath, rename, writeFile } from "node:fs/promises";
import { isBuiltin } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { type ModuleManifest, safeParseModuleManifest } from "@ekklesia/contracts";
import {
MODULE_PACKAGE_ANY_TARGET,
MODULE_PACKAGE_FORMAT,
MODULE_PACKAGE_LICENSE_FILENAME,
MODULE_PACKAGE_METADATA_FILENAME,
MODULE_PACKAGE_NOTICES_FILENAME,
modulePackageArchiveFileName,
modulePackageRuntimePackage,
safeParseModulePackageLicenseExpression,
safeParseModulePackageMetadata,
} from "@ekklesia/contracts/module-package";
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
import { build, type Message, type Plugin } from "esbuild";
import { minVersion, satisfies, subset, validRange } from "semver";
import {
NATIVE_TARGETS,
NativeError,
type NativeErrorCode,
type NativePackage,
type NativeTarget,
nativeClosure,
nativeRuntime,
} from "./native.js";
import { type NoticeComponent, NoticeError, type NoticeUse, thirdPartyNotices } from "./notices.js";
import {
type BundledPackage,
BundledPackageIndex,
isNativeOrPlatformSpecific,
} from "./packages.js";
import { loadLicenseSupplements, SupplementError } from "./supplements.js";
import { deterministicTarGz, TarPathError } from "./tar.js";
/**
* The canonical Ekklesia module packager (ADR 0049): turns a built module directory into one
* deterministic Module Package v1 archive and its `.sha256` line.
*
* It reads the module directory and the dependencies its workspace installed, and writes only the
* two output files. It never installs, links, or deploys anything, so the workspace it runs in is
* left exactly as it was. It produces the portable `any` target, and the native `linux-x64-glibc`
* target, whose native runtime dependencies are copied rather than bundled (see `./native.ts`).
*/
export type ModulePackErrorCode =
| NativeErrorCode
| "MODULE_PACK_BUNDLE_FAILED"
| "MODULE_PACK_DATABASE_RUNTIME_CONFLICT"
| "MODULE_PACK_ENTRYPOINT_MISSING"
| "MODULE_PACK_ENTRYPOINT_UNSUPPORTED"
| "MODULE_PACK_EXTERNAL_DEPENDENCY"
| "MODULE_PACK_FORBIDDEN_FILE"
| "MODULE_PACK_IDENTITY_MISMATCH"
| "MODULE_PACK_INCORPORATED_CONFLICT"
| "MODULE_PACK_LAYOUT_CONFLICT"
| "MODULE_PACK_MODULE_LICENSE_FILE_INVALID"
| "MODULE_PACK_MODULE_LICENSE_INVALID"
| "MODULE_PACK_LICENCE_SUPPLEMENT_INVALID"
| "MODULE_PACK_LICENCE_SUPPLEMENT_MISMATCH"
| "MODULE_PACK_LICENCE_TEXT_MISSING"
| "MODULE_PACK_LICENCE_UNKNOWN"
| "MODULE_PACK_MANIFEST_INVALID"
| "MODULE_PACK_METADATA_INVALID"
| "MODULE_PACK_MIGRATIONS_INVALID"
| "MODULE_PACK_NATIVE_DEPENDENCY"
| "MODULE_PACK_NATIVE_NODE_INCOMPATIBLE"
| "MODULE_PACK_NATIVE_TARGET_UNNEEDED"
| "MODULE_PACK_OUTPUT_FAILED"
| "MODULE_PACK_PATH_UNSUPPORTED"
| "MODULE_PACK_REVISION_INVALID"
| "MODULE_PACK_RUNTIME_UNKNOWN"
| "MODULE_PACK_SDK_INCOMPATIBLE"
| "MODULE_PACK_TARGET_UNSUPPORTED"
| "MODULE_PACK_UI_STALE";
const messages: Readonly<Record<ModulePackErrorCode, string>> = Object.freeze({
MODULE_PACK_BUNDLE_FAILED:
"The backend could not be bundled into self-contained files. Every import must resolve and bundle cleanly.",
MODULE_PACK_DATABASE_RUNTIME_CONFLICT: "The backend bundles more than one Drizzle version.",
MODULE_PACK_ENTRYPOINT_MISSING:
"A declared backend entrypoint does not exist. Build the module before packaging it.",
MODULE_PACK_ENTRYPOINT_UNSUPPORTED:
"Backend entrypoints must be compiled .js files to be bundled into a Module Package v1.",
MODULE_PACK_EXTERNAL_DEPENDENCY:
"The backend bundle would import a dependency at run time; an any-target package bundles everything.",
MODULE_PACK_FORBIDDEN_FILE:
"The package would contain a source, test, declaration, source-map, or dependency file.",
MODULE_PACK_IDENTITY_MISMATCH:
"package.json must declare a name and the same version as ekklesia.module.json.",
MODULE_PACK_INCORPORATED_CONFLICT:
"The package bundles more than one version of an Ekklesia package.",
MODULE_PACK_LAYOUT_CONFLICT: "Two package files would have the same path.",
MODULE_PACK_MODULE_LICENSE_FILE_INVALID:
"The module license text must be supplied as an explicit, non-empty, readable regular file.",
MODULE_PACK_MODULE_LICENSE_INVALID:
"The source package.json must declare a valid SPDX license expression as a string.",
MODULE_PACK_LICENCE_SUPPLEMENT_INVALID:
"A reviewed license supplement is malformed or does not match its reviewed files.",
MODULE_PACK_LICENCE_SUPPLEMENT_MISMATCH:
"A license supplement names this package, but not its exact version, content digest, and declared licence.",
MODULE_PACK_LICENCE_TEXT_MISSING:
"A bundled package ships no licence text, and its licence has no complete standard text to supply.",
MODULE_PACK_LICENCE_UNKNOWN: "A bundled package declares no licence that permits redistribution.",
MODULE_PACK_MANIFEST_INVALID: "ekklesia.module.json is missing or invalid.",
MODULE_PACK_METADATA_INVALID: "The generated package metadata failed its own contract.",
MODULE_PACK_MIGRATIONS_INVALID:
"The migrations directory must hold only regular files and directories.",
MODULE_PACK_NATIVE_DEPENDENCY:
"The backend bundles a native or platform-specific dependency, which an any-target package cannot contain.",
MODULE_PACK_NATIVE_DEPENDENCY_MISSING:
"A native runtime dependency requires a package that is not installed.",
MODULE_PACK_NATIVE_LAYOUT_CONFLICT:
"Two versions of one package would share the native node_modules layout.",
MODULE_PACK_NATIVE_NODE_INCOMPATIBLE:
"A native runtime dependency does not support every Node.js version the package targets.",
MODULE_PACK_NATIVE_PACKAGE_INVALID:
"A native runtime dependency could not be read, or contains a link or special file.",
MODULE_PACK_NATIVE_RUNTIME_UNKNOWN:
"A native addon's Node.js interface could not be established exactly.",
MODULE_PACK_NATIVE_TARGET_MISMATCH:
"A native runtime dependency, or a file in one, is not built for the package's target.",
MODULE_PACK_NATIVE_TARGET_UNNEEDED:
"A native target needs a native addon; a package without one uses the any target.",
MODULE_PACK_OUTPUT_FAILED: "The package could not be written to the output directory.",
MODULE_PACK_PATH_UNSUPPORTED:
"A package path is not portable printable ASCII that fits the archive format.",
MODULE_PACK_REVISION_INVALID: "The source revision must be a full 40- or 64-character commit id.",
MODULE_PACK_RUNTIME_UNKNOWN:
"The module's package.json must declare the Node.js versions it supports in engines.node.",
MODULE_PACK_SDK_INCOMPATIBLE:
"The bundled Module SDK is outside the manifest's own compatibility.moduleSdk range.",
MODULE_PACK_TARGET_UNSUPPORTED:
"This packager produces the any and linux-x64-glibc targets only.",
MODULE_PACK_UI_STALE:
"A declared browser artifact is missing or out of date. Build the module before packaging it.",
});
export class ModulePackError extends Error {
readonly code: ModulePackErrorCode;
/** The package or path the failure concerns, when there is one. A module author's own build
* input, never a secret. */
readonly detail?: string;
/** esbuild's structured diagnostics, for a command line to print. */
readonly diagnostics: readonly Message[];
constructor(code: ModulePackErrorCode, detail?: string, diagnostics: readonly Message[] = []) {
super(detail === undefined ? messages[code] : `${messages[code]} (${detail})`);
this.name = "ModulePackError";
this.code = code;
if (detail !== undefined) {
this.detail = detail;
}
this.diagnostics = diagnostics;
}
}
export interface PackModuleOptions {
/** A built module directory: its manifest, package.json, and compiled entrypoints. */
readonly moduleDirectory: string;
/** Explicit license-text input. Its bytes are copied unchanged to the package root as LICENSE. */
readonly licenseFile: string;
/** `any`, or a native target this packager supports (`linux-x64-glibc`). */
readonly target: string;
readonly outDirectory: string;
/** The full commit id the module was built from. */
readonly sourceRevision: string;
/** Informational only; recorded as given. */
readonly repository?: string;
/** The Node.js range the package is built for. Defaults to the module's own `engines.node`. */
readonly nodeRange?: string;
/** Reviewed license supplements (ADR 0052). Defaults to the ones this packager ships. */
readonly supplementsDirectory?: string;
}
export interface ModulePackResult {
readonly archivePath: string;
readonly sha256Path: string;
readonly sha256: string;
readonly archiveBytes: number;
readonly moduleId: string;
readonly version: string;
readonly target: string;
/** Payload files, the metadata file not included. */
readonly fileCount: number;
}
const NATIVE_GUARD = "ekklesia-native-addon-guard";
/** Refuses a compiled addon the moment the bundle reaches one. */
const nativeAddonGuard: Plugin = {
name: NATIVE_GUARD,
setup(pluginBuild) {
pluginBuild.onLoad({ filter: /\.node$/i }, () => ({
errors: [{ text: "A compiled Node.js addon cannot be bundled into an any-target package." }],
}));
},
};
/** Bundled CommonJS code that requires a Node built-in needs a `require` in module scope. Nothing
* else can be required at run time: every other dependency is inside the bundle, and the packager
* refuses a bundle that would import anything but a built-in. */
const REQUIRE_BANNER =
'import { createRequire as __ekklesiaCreateRequire } from "node:module";\nconst require = __ekklesiaCreateRequire(import.meta.url);';
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function sha256(data: Uint8Array): string {
return createHash("sha256").update(data).digest("hex");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function readJson(file: string): Promise<unknown> {
return JSON.parse(await readFile(file, "utf8")) as unknown;
}
const ownManifestPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"package.json",
);
async function packagerIdentity(): Promise<{ readonly name: string; readonly version: string }> {
const manifest = (await readJson(ownManifestPath)) as Record<string, unknown>;
return { name: String(manifest.name), version: String(manifest.version) };
}
/** The Node.js range a module declares for itself, in its own package.json `engines.node` (ADR
* 0053). Read from the module, never from an enclosing workspace: a standalone module repository has
* none. */
function moduleNodeRange(developmentPackage: Readonly<Record<string, unknown>>): string {
const engines = developmentPackage.engines;
const range = isRecord(engines) ? engines.node : undefined;
if (typeof range !== "string" || range.trim() === "") {
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
}
return range;
}
/** `./dist/server/index.js` → `dist/server/index.js`, inside the module directory. */
function moduleRelative(declared: string): string {
return path.posix.normalize(declared.replace(/^\.\//u, ""));
}
function portable(relative: string): boolean {
const segments = relative.split("/");
return (
/^[\x20-\x7e]+$/u.test(relative) &&
!relative.includes("\\") &&
segments.every((segment) => segment !== "" && segment !== "." && segment !== "..")
);
}
function forbidden(relative: string): boolean {
return (
/(^|\/)(src|tests?|node_modules)\//u.test(relative) ||
/\.(map|d\.ts|d\.mts|d\.cts)$/u.test(relative)
);
}
async function readTree(root: string, relative: string, files: Map<string, Buffer>): Promise<void> {
const directory = path.join(root, relative);
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) =>
compare(left.name, right.name),
)) {
const childRelative = `${relative}/${entry.name}`;
const stats = await lstat(path.join(directory, entry.name));
if (stats.isDirectory()) {
await readTree(root, childRelative, files);
} else if (stats.isFile()) {
files.set(childRelative, await readFile(path.join(directory, entry.name)));
} else {
throw new ModulePackError("MODULE_PACK_MIGRATIONS_INVALID", childRelative);
}
}
}
/** The files redistributed for a component, relative to its root: what it copies, or the source
* files of it that were bundled. The content digest a license supplement names is over these. */
async function redistributedFiles(component: {
readonly bundled: BundledPackage;
readonly files: ReadonlySet<string>;
readonly copied?: ReadonlyMap<string, Buffer>;
}): Promise<ReadonlyMap<string, Uint8Array>> {
if (component.copied !== undefined) {
return component.copied;
}
const files = new Map<string, Uint8Array>();
for (const file of component.files) {
const real = await realpath(file).catch(() => file);
files.set(
path.relative(component.bundled.root, real).split(path.sep).join("/"),
await readFile(real),
);
}
return files;
}
interface BackendBundle {
readonly outputs: ReadonlyMap<string, Buffer>;
readonly inputs: readonly string[];
}
const NATIVE_EXTERNALS = "ekklesia-native-externals";
/** The bare specifier's package name: `sharp/x` → `sharp`, `@scope/name/x` → `@scope/name`. */
function specifierPackage(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : (segments[0] ?? "");
}
/**
* For a native target: keeps every native or platform-specific third-party package out of the
* bundle, as an import of the package itself, and records it as a root of the copied runtime
* closure. Decided per resolved package, by the same rule that refuses one in an `any` package.
*/
function nativeExternals(
index: BundledPackageIndex,
externals: Map<string, BundledPackage>,
): Plugin {
return {
name: NATIVE_EXTERNALS,
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /^[^./]/ }, async (args) => {
if (
(args.pluginData as { ekklesiaNative?: boolean } | undefined)?.ekklesiaNative ||
isBuiltin(args.path)
) {
return undefined;
}
const resolved = await pluginBuild.resolve(args.path, {
importer: args.importer,
kind: args.kind,
pluginData: { ekklesiaNative: true },
resolveDir: args.resolveDir,
});
if (resolved.errors.length > 0 || resolved.external || resolved.path === "") {
return undefined;
}
const owner = await index.packageOf(resolved.path);
if (
owner === undefined ||
!owner.thirdParty ||
!(await isNativeOrPlatformSpecific(owner))
) {
return undefined;
}
if (specifierPackage(args.path) !== owner.name) {
return {
errors: [{ text: "A native package must be imported by its own package name." }],
};
}
externals.set(owner.name, owner);
return { external: true, path: args.path };
});
},
};
}
async function bundleBackend(
moduleDirectory: string,
manifest: ModuleManifest,
nodeRange: string,
native?: { readonly index: BundledPackageIndex; readonly externals: Map<string, BundledPackage> },
): Promise<BackendBundle> {
const declared = [
manifest.entrypoints.server,
manifest.entrypoints.schema,
manifest.entrypoints.jobs,
].filter((entrypoint): entrypoint is string => entrypoint !== undefined);
const entryPoints: { in: string; out: string }[] = [];
for (const entrypoint of declared) {
const relative = moduleRelative(entrypoint);
if (!relative.endsWith(".js")) {
throw new ModulePackError("MODULE_PACK_ENTRYPOINT_UNSUPPORTED", relative);
}
try {
if (!(await lstat(path.join(moduleDirectory, relative))).isFile()) {
throw new Error("not a file");
}
} catch {
throw new ModulePackError("MODULE_PACK_ENTRYPOINT_MISSING", relative);
}
entryPoints.push({ in: `./${relative}`, out: relative.slice(0, -".js".length) });
}
const minimum = minVersion(nodeRange);
let result: Awaited<ReturnType<typeof build>>;
try {
result = await build({
absWorkingDir: moduleDirectory,
banner: { js: REQUIRE_BANNER },
bundle: true,
chunkNames: "dist/chunks/[name]-[hash]",
entryPoints,
format: "esm",
// Licence comments stay in the code as well; THIRD_PARTY_NOTICES is what satisfies the
// licences, never these comments alone.
legalComments: "eof",
logLevel: "silent",
metafile: true,
minify: false,
outdir: moduleDirectory,
platform: "node",
plugins:
native === undefined
? [nativeAddonGuard]
: [nativeExternals(native.index, native.externals), nativeAddonGuard],
sourcemap: false,
splitting: true,
target: `node${minimum?.version ?? "24"}`,
write: false,
});
} catch (error) {
const errors =
isRecord(error) && Array.isArray(error.errors) ? (error.errors as Message[]) : [];
const native = errors.some((message) => message.pluginName === NATIVE_GUARD);
throw new ModulePackError(
native ? "MODULE_PACK_NATIVE_DEPENDENCY" : "MODULE_PACK_BUNDLE_FAILED",
undefined,
errors,
);
}
// A warning is a bundle that might not run: a require it could not follow, an import it could
// not see. An installed module has nothing to fall back on, so a warning fails packaging.
if (result.warnings.length > 0) {
throw new ModulePackError("MODULE_PACK_BUNDLE_FAILED", undefined, result.warnings);
}
const { metafile, outputFiles } = result;
if (metafile === undefined || outputFiles === undefined) {
throw new ModulePackError("MODULE_PACK_BUNDLE_FAILED");
}
for (const output of Object.values(metafile.outputs)) {
for (const imported of output.imports) {
if (
imported.external &&
!isBuiltin(imported.path) &&
!native?.externals.has(specifierPackage(imported.path))
) {
throw new ModulePackError("MODULE_PACK_EXTERNAL_DEPENDENCY", imported.path);
}
}
}
const outputs = new Map<string, Buffer>();
for (const outputFile of outputFiles) {
outputs.set(
path.relative(moduleDirectory, outputFile.path).split(path.sep).join("/"),
Buffer.from(outputFile.contents),
);
}
return {
inputs: Object.keys(metafile.inputs).map((input) => path.resolve(moduleDirectory, input)),
outputs,
};
}
export async function packModule(options: PackModuleOptions): Promise<ModulePackResult> {
const nativeTarget: NativeTarget | undefined = NATIVE_TARGETS.get(options.target);
if (options.target !== MODULE_PACKAGE_ANY_TARGET && nativeTarget === undefined) {
throw new ModulePackError("MODULE_PACK_TARGET_UNSUPPORTED", options.target);
}
const target = options.target;
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(options.sourceRevision)) {
throw new ModulePackError("MODULE_PACK_REVISION_INVALID");
}
const moduleDirectory = await realpath(path.resolve(options.moduleDirectory)).catch(() => {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
});
const manifestBytes = await readFile(path.join(moduleDirectory, "ekklesia.module.json")).catch(
() => {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
},
);
let manifestInput: unknown;
try {
manifestInput = JSON.parse(manifestBytes.toString("utf8"));
} catch {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
}
const parsedManifest = safeParseModuleManifest(manifestInput);
if (!parsedManifest.success) {
throw new ModulePackError("MODULE_PACK_MANIFEST_INVALID");
}
const manifest = parsedManifest.data;
const developmentPackage = await readJson(path.join(moduleDirectory, "package.json")).catch(
() => undefined,
);
if (
!isRecord(developmentPackage) ||
typeof developmentPackage.name !== "string" ||
developmentPackage.name === "" ||
developmentPackage.version !== manifest.version
) {
throw new ModulePackError("MODULE_PACK_IDENTITY_MISMATCH");
}
const parsedLicense = safeParseModulePackageLicenseExpression(developmentPackage.license);
if (!parsedLicense.success) {
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_INVALID");
}
const license = parsedLicense.data;
let licenseBytes: Buffer;
try {
if (
typeof options.licenseFile !== "string" ||
options.licenseFile === "" ||
!(await lstat(path.resolve(options.licenseFile))).isFile()
) {
throw new Error("not a regular file");
}
licenseBytes = await readFile(path.resolve(options.licenseFile));
if (licenseBytes.length === 0) {
// An empty file would satisfy the inventory while carrying no license material at all.
throw new Error("empty");
}
} catch {
throw new ModulePackError("MODULE_PACK_MODULE_LICENSE_FILE_INVALID");
}
const nodeRange = options.nodeRange ?? moduleNodeRange(developmentPackage);
if (validRange(nodeRange) === null) {
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
}
let supplements: Awaited<ReturnType<typeof loadLicenseSupplements>>;
try {
supplements = await loadLicenseSupplements(options.supplementsDirectory);
} catch (error) {
throw new ModulePackError(
"MODULE_PACK_LICENCE_SUPPLEMENT_INVALID",
error instanceof SupplementError ? error.supplement : undefined,
);
}
// 1. The backend, bundled with every pure-JavaScript dependency it uses. For a native target,
// native packages stay outside the bundle and are copied with their runtime closure instead.
const index = new BundledPackageIndex();
const externals = new Map<string, BundledPackage>();
const backend = await bundleBackend(
moduleDirectory,
manifest,
nodeRange,
nativeTarget === undefined ? undefined : { externals, index },
);
let nativePackages: readonly NativePackage[] = [];
let runtimeRequirements: Record<string, unknown> = {};
if (nativeTarget !== undefined) {
try {
nativePackages = await nativeClosure([...externals.values()], nativeTarget);
const { addons, ...requirements } = nativeRuntime(nativePackages, nativeTarget);
if (addons === 0) {
throw new ModulePackError("MODULE_PACK_NATIVE_TARGET_UNNEEDED", options.target);
}
runtimeRequirements = requirements;
} catch (error) {
if (error instanceof NativeError) {
throw new ModulePackError(error.code, error.detail);
}
throw error;
}
for (const nativePackage of nativePackages) {
const engines = nativePackage.manifest.engines;
const engine = isRecord(engines) ? engines.node : undefined;
if (typeof engine === "string" && !subset(nodeRange, engine)) {
throw new ModulePackError(
"MODULE_PACK_NATIVE_NODE_INCOMPATIBLE",
`${nativePackage.name}@${nativePackage.version}`,
);
}
}
}
// 2. The browser artifacts, exactly as built, with what went into each.
let ui: Awaited<ReturnType<typeof analyzeModuleUi>>;
try {
ui = await analyzeModuleUi({ moduleDirectory });
} catch (error) {
throw new ModulePackError(
"MODULE_PACK_UI_STALE",
error instanceof ModuleUiBuildError ? error.code : undefined,
);
}
// 3. Every bundled source file traced to its package, and every copied package.
const components = new Map<
string,
{
bundled: BundledPackage;
uses: Set<NoticeUse>;
files: Set<string>;
copied?: ReadonlyMap<string, Buffer>;
}
>();
const incorporates = new Map<string, string>();
const drizzleVersions = new Set<string>();
const sources: { readonly file: string; readonly use: NoticeUse }[] = [
...backend.inputs.map((file) => ({ file, use: "backend" as const })),
...ui.artifacts.flatMap((artifact) =>
artifact.inputs.map((file) => ({ file, use: "browser" as const })),
),
];
for (const { file, use } of sources) {
const bundled = await index.packageOf(file);
if (bundled === undefined || bundled.root === moduleDirectory) {
continue;
}
if (bundled.ekklesiaOwned) {
// An Ekklesia module-development package, recognized by its identity, not by where it was
// installed (ADR 0053): recorded under incorporates, never credited as third-party software.
const known = incorporates.get(bundled.name);
if (known !== undefined && known !== bundled.version) {
throw new ModulePackError("MODULE_PACK_INCORPORATED_CONFLICT", bundled.name);
}
incorporates.set(bundled.name, bundled.version);
continue;
}
if (!bundled.thirdParty) {
continue;
}
if (use === "backend" && bundled.name === "drizzle-orm") {
drizzleVersions.add(bundled.version);
}
const key = `${bundled.name}@${bundled.version}`;
const existing = components.get(key);
if (existing) {
existing.uses.add(use);
existing.files.add(file);
} else {
if (use === "backend" && (await isNativeOrPlatformSpecific(bundled))) {
throw new ModulePackError("MODULE_PACK_NATIVE_DEPENDENCY", key);
}
components.set(key, { bundled, files: new Set([file]), uses: new Set([use]) });
}
}
for (const nativePackage of nativePackages) {
const key = `${nativePackage.name}@${nativePackage.version}`;
const existing = components.get(key);
const entry = existing ?? {
bundled: {
manifest: nativePackage.manifest,
name: nativePackage.name,
root: nativePackage.root,
ekklesiaOwned: false,
thirdParty: true,
version: nativePackage.version,
},
files: new Set<string>(),
uses: new Set<NoticeUse>(),
};
entry.uses.add("node_modules");
entry.copied = nativePackage.files;
components.set(key, entry);
}
const usesDatabaseAbi = manifest.database?.abi === 1;
if (!usesDatabaseAbi && drizzleVersions.size > 1) {
throw new ModulePackError("MODULE_PACK_DATABASE_RUNTIME_CONFLICT");
}
const sdk = incorporates.get("@ekklesia/module-sdk");
if (sdk !== undefined && !satisfies(sdk, manifest.compatibility.moduleSdk)) {
throw new ModulePackError("MODULE_PACK_SDK_INCOMPATIBLE", sdk);
}
// 4. The payload.
const files = new Map<string, Buffer>();
const add = (relative: string, data: Buffer, copied = false): void => {
if (!portable(relative)) {
throw new ModulePackError("MODULE_PACK_PATH_UNSUPPORTED", relative);
}
if (!copied && forbidden(relative)) {
throw new ModulePackError("MODULE_PACK_FORBIDDEN_FILE", relative);
}
if (files.has(relative) || relative === MODULE_PACKAGE_METADATA_FILENAME) {
throw new ModulePackError("MODULE_PACK_LAYOUT_CONFLICT", relative);
}
files.set(relative, data);
};
for (const [relative, data] of backend.outputs) {
add(relative, data);
}
for (const artifact of ui.artifacts) {
if (!artifact.builtArtifactCurrent) {
throw new ModulePackError(
"MODULE_PACK_UI_STALE",
path.relative(moduleDirectory, artifact.outputFile),
);
}
add(
path.relative(moduleDirectory, artifact.outputFile).split(path.sep).join("/"),
await readFile(artifact.outputFile),
);
}
if (manifest.entrypoints.migrations !== undefined) {
const migrations = new Map<string, Buffer>();
try {
await readTree(moduleDirectory, moduleRelative(manifest.entrypoints.migrations), migrations);
} catch (error) {
if (error instanceof ModulePackError) {
throw error;
}
throw new ModulePackError("MODULE_PACK_MIGRATIONS_INVALID");
}
for (const [relative, data] of migrations) {
add(relative, data);
}
}
// A native target's runtime dependencies, as real files, flat under node_modules/<name>. They are
// third-party packages carried as published, so the module's own source and test path rules do
// not apply to their contents; declarations and source maps were already left out.
for (const nativePackage of nativePackages) {
for (const [relative, data] of nativePackage.files) {
add(`node_modules/${nativePackage.name}/${relative}`, data, true);
}
}
add("ekklesia.module.json", manifestBytes);
add(MODULE_PACKAGE_LICENSE_FILENAME, licenseBytes);
add(
"package.json",
Buffer.from(
`${JSON.stringify(
modulePackageRuntimePackage({ name: developmentPackage.name, version: manifest.version }),
null,
2,
)}\n`,
),
);
try {
add(
MODULE_PACKAGE_NOTICES_FILENAME,
Buffer.from(
await thirdPartyNotices(
new Map(
[...components].map(([key, component]): [string, NoticeComponent] => [
key,
{
bundled: component.bundled,
redistributedFiles: () => redistributedFiles(component),
uses: component.uses,
},
]),
),
supplements,
),
),
);
} catch (error) {
if (error instanceof NoticeError) {
throw new ModulePackError(error.code, error.component);
}
throw error;
}
// 5. The metadata, checked against its own contract before anything is written.
const listed = [...files.entries()]
.sort(([left], [right]) => compare(left, right))
.map(([relative, data]) => ({ path: relative, sha256: sha256(data), size: data.length }));
const metadata = {
packageFormat: MODULE_PACKAGE_FORMAT,
module: { id: manifest.id, version: manifest.version },
license,
target,
runtime: { node: nodeRange, ...runtimeRequirements },
provenance: {
revision: options.sourceRevision,
...(options.repository === undefined ? {} : { repository: options.repository }),
packager: await packagerIdentity(),
},
incorporates: Object.fromEntries(
[...incorporates].sort(([left], [right]) => compare(left, right)),
),
...(!usesDatabaseAbi && drizzleVersions.size === 1
? { transitional: { databaseRuntime: { "drizzle-orm": [...drizzleVersions][0] } } }
: {}),
files: listed.map((file) => ({ path: file.path, size: file.size, sha256: file.sha256 })),
};
if (!safeParseModulePackageMetadata(metadata).success) {
throw new ModulePackError("MODULE_PACK_METADATA_INVALID");
}
const payload = new Map(files);
payload.set(
MODULE_PACKAGE_METADATA_FILENAME,
Buffer.from(`${JSON.stringify(metadata, null, 2)}\n`),
);
// 6. The archive and its digest.
let archive: Buffer;
try {
archive = deterministicTarGz(manifest.id, payload);
} catch (error) {
if (error instanceof TarPathError) {
throw new ModulePackError("MODULE_PACK_PATH_UNSUPPORTED", error.path);
}
throw error;
}
const digest = sha256(archive);
const fileName = modulePackageArchiveFileName(manifest.id, manifest.version, target);
const outDirectory = path.resolve(options.outDirectory);
const archivePath = path.join(outDirectory, fileName);
const sha256Path = `${archivePath}.sha256`;
try {
await mkdir(outDirectory, { recursive: true });
const staging = `${archivePath}.partial-${process.pid}`;
await writeFile(staging, archive);
await rename(staging, archivePath);
await writeFile(`${sha256Path}.partial-${process.pid}`, `${digest} ${fileName}\n`);
await rename(`${sha256Path}.partial-${process.pid}`, sha256Path);
} catch {
throw new ModulePackError("MODULE_PACK_OUTPUT_FAILED");
}
return Object.freeze({
archiveBytes: archive.length,
archivePath,
fileCount: listed.length,
moduleId: manifest.id,
sha256: digest,
sha256Path,
target,
version: manifest.version,
});
}