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> = 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } async function readJson(file: string): Promise { 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; 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>): 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): Promise { 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; readonly copied?: ReadonlyMap; }): Promise> { if (component.copied !== undefined) { return component.copied; } const files = new Map(); 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; 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, ): 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 }, ): Promise { 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>; 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(); 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 { 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>; 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(); const backend = await bundleBackend( moduleDirectory, manifest, nodeRange, nativeTarget === undefined ? undefined : { externals, index }, ); let nativePackages: readonly NativePackage[] = []; let runtimeRequirements: Record = {}; 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>; 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; files: Set; copied?: ReadonlyMap; } >(); const incorporates = new Map(); const drizzleVersions = new Set(); 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(), uses: new Set(), }; 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(); 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(); 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/. 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, }); }