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
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]>
829 lines
32 KiB
TypeScript
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,
|
|
});
|
|
}
|