feat: Ekklesia Module Package v1, the canonical packager and verifier (ADR 0049)
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
CI / verify (pull_request) Successful in 57s
CI / infrastructure-smoke (pull_request) Successful in 35s
CI / migration-smoke (pull_request) Successful in 1m42s
CI / image-build (pull_request) Successful in 29s
CI / deploy-smoke (same-origin) (pull_request) Successful in 51s
CI / deploy-smoke (split) (pull_request) Failing after 54s
A separately versioned module is now distributed as one deterministic .tar.gz and its SHA-256, produced by an Ekklesia-owned packager and checked by Core before anything is installed. This slice implements the portable `any` target, with the module template as the end-to-end proof. ADR 0049 is accepted. @ekklesia/contracts: the Module Package v1 contract. The strict ekklesia.package.json schema (packageFormat 1, identity, target, runtime, provenance, incorporated Ekklesia packages, strict transitional facts, an exact byte-wise-sorted file list with size and SHA-256), target names, and the generated runtime package.json. @ekklesia/module-pack (new, tooling): `ekklesia-pack-module --module <dir> --target any --out <dir> --source-revision <commit>`. - Bundles server, schema, and jobs with esbuild, SDK, contracts, and Drizzle included; only Node built-ins remain imports. Any warning, unresolved import, external dependency, or native or platform-specific package fails. - Copies browser artifacts as built, after proving them current, and the migrations; generates package.json and ekklesia.package.json; never ships src, tests, declarations, source maps, or workspace ranges. - THIRD_PARTY_NOTICES: every third-party package traced from the bundles' own inputs, credited once with its declared licence and every licence, copying, copyright, and notice file it ships. A missing licence fails; a missing text fails unless its standard text is complete without copyright lines (Apache-2.0, vendored, needed by drizzle-orm). - Deterministic ustar and gzip: sorted paths, uid/gid 0, empty names, mtime 0, 0755/0644, no links. Byte-identical across runs and machines. - Writes only the archive and its .sha256; never deploys, links, or installs, so the workspace's install state is untouched. Core: verifyModulePackage and `verify-module-package.js <archive> [--sha256 | --sha256-file] [--extract-to <new dir>]`. Reads the archive in memory and refuses links, special entries, setuid/setgid/sticky modes, unsafe or escaping paths, and a second top-level directory; checks metadata, every file, identity, target, Node.js range, SDK, and the transitional Drizzle agreement; runs the platform's module and migration discovery; loads the backend once in a forked process with an empty environment; only then extracts, with normalized modes, into a new directory. Errors are sanitized. @ekklesia/module-ui-build: analyzeModuleUi reports each artifact's inputs and whether the built file is current. The nested stylesheet build now works from the module directory, so artifacts no longer depend on the caller's working directory. The split deploy smoke builds the package with the canonical packager (scripts/lib/build-module-package.Dockerfile replaces the test-only pnpm deploy packaging), verifies it with the same generic image, installs the verified tree, removes it, and reinstalls the exact same archive. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
45d6506c10
commit
35ef31df9f
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { formatMessages } from "esbuild";
|
||||
import { ModulePackError, type PackModuleOptions, packModule } from "./pack-module.js";
|
||||
|
||||
/**
|
||||
* `ekklesia-pack-module` — the canonical Ekklesia module packager (ADR 0049).
|
||||
*
|
||||
* ekklesia-pack-module --module <built module directory> --target any --out <directory>
|
||||
* --source-revision <full commit id> [--repository <url>]
|
||||
*
|
||||
* Writes `<id>-<version>-any.tar.gz` and its `.sha256` line to the output directory, and nothing
|
||||
* else anywhere. Run it in the module's CI after building the module.
|
||||
*/
|
||||
|
||||
class PackArgumentsError extends Error {}
|
||||
|
||||
const USAGE =
|
||||
"Usage: ekklesia-pack-module --module <directory> --target any --out <directory> --source-revision <commit> [--repository <url>]";
|
||||
|
||||
const OPTIONS: Readonly<Record<string, keyof PackModuleOptions>> = Object.freeze({
|
||||
"--module": "moduleDirectory",
|
||||
"--out": "outDirectory",
|
||||
"--repository": "repository",
|
||||
"--source-revision": "sourceRevision",
|
||||
"--target": "target",
|
||||
});
|
||||
|
||||
export function parsePackModuleArguments(argv: readonly string[]): PackModuleOptions {
|
||||
const values = new Map<keyof PackModuleOptions, string>();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const option = OPTIONS[argv[index] ?? ""];
|
||||
const value = argv[index + 1];
|
||||
if (
|
||||
option === undefined ||
|
||||
value === undefined ||
|
||||
value.startsWith("--") ||
|
||||
values.has(option)
|
||||
) {
|
||||
throw new PackArgumentsError(USAGE);
|
||||
}
|
||||
values.set(option, value);
|
||||
}
|
||||
const required = ["moduleDirectory", "outDirectory", "sourceRevision", "target"] as const;
|
||||
if (required.some((option) => !values.has(option))) {
|
||||
throw new PackArgumentsError(USAGE);
|
||||
}
|
||||
const repository = values.get("repository");
|
||||
return {
|
||||
moduleDirectory: values.get("moduleDirectory") ?? "",
|
||||
outDirectory: values.get("outDirectory") ?? "",
|
||||
...(repository === undefined ? {} : { repository }),
|
||||
sourceRevision: values.get("sourceRevision") ?? "",
|
||||
target: values.get("target") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<number> {
|
||||
try {
|
||||
const result = await packModule(parsePackModuleArguments(process.argv.slice(2)));
|
||||
process.stdout.write(
|
||||
`Packed ${result.moduleId}@${result.version} (target ${result.target}): ${path.basename(result.archivePath)}, ${result.archiveBytes} bytes, ${result.fileCount} files\n`,
|
||||
);
|
||||
process.stdout.write(`sha256 ${result.sha256}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof PackArgumentsError) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
return 64;
|
||||
}
|
||||
if (error instanceof ModulePackError) {
|
||||
process.stderr.write(`[${error.code}] ${error.message}\n`);
|
||||
if (error.diagnostics.length > 0) {
|
||||
const formatted = await formatMessages([...error.diagnostics], {
|
||||
color: false,
|
||||
kind: "error",
|
||||
});
|
||||
process.stderr.write(formatted.join(""));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
process.stderr.write("Module packaging failed unexpectedly.\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectExecution(): boolean {
|
||||
const entrypoint = process.argv[1];
|
||||
// Run through the committed launcher, which imports this file, or directly.
|
||||
return (
|
||||
entrypoint !== undefined &&
|
||||
(import.meta.url === pathToFileURL(path.resolve(entrypoint)).href ||
|
||||
path.basename(entrypoint) === "ekklesia-pack-module.mjs" ||
|
||||
path.basename(entrypoint) === "ekklesia-pack-module")
|
||||
);
|
||||
}
|
||||
|
||||
if (isDirectExecution()) {
|
||||
process.exitCode = await main();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./pack-module.js";
|
||||
@@ -0,0 +1,176 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { BundledPackage } from "./packages.js";
|
||||
|
||||
/**
|
||||
* `THIRD_PARTY_NOTICES` (ADR 0049): the licence and notice text that redistributing a module
|
||||
* package requires, generated from what was actually bundled.
|
||||
*
|
||||
* - **Discovery.** Every source file in every bundle (the backend, and each browser artifact) is
|
||||
* traced to the package it belongs to. Packages installed under `node_modules` are third-party
|
||||
* software and are credited here. The Ekklesia platform's own packages, and the module itself,
|
||||
* are not third-party software: the platform packages appear in `ekklesia.package.json` under
|
||||
* `incorporates`.
|
||||
* - **Content.** For each third-party package: its name, version, declared licence, where in the
|
||||
* package it is used, and the complete text of every licence, copying, copyright, and notice file
|
||||
* it ships at its root, plus the file a `SEE LICENSE IN` declaration names.
|
||||
* - **Duplicates.** A package bundled into several artifacts is credited once, listing every
|
||||
* artifact kind it is used in. Different packages are never merged, even when their texts match,
|
||||
* so each component keeps its own notice.
|
||||
* - **Gaps fail.** A package that declares no licence, or whose licence cannot be established,
|
||||
* stops packaging. When a package ships no licence file, the standard text of its declared licence
|
||||
* is supplied only if that licence's standard text is complete without project-specific lines
|
||||
* (Apache-2.0). An MIT, ISC, or BSD package without its file is refused, because its copyright line
|
||||
* exists only in that file.
|
||||
*
|
||||
* Everything is sorted, and line endings are normalized, so the file is byte-identical for identical
|
||||
* inputs.
|
||||
*/
|
||||
|
||||
export type NoticeErrorCode = "MODULE_PACK_LICENCE_TEXT_MISSING" | "MODULE_PACK_LICENCE_UNKNOWN";
|
||||
|
||||
export class NoticeError extends Error {
|
||||
readonly code: NoticeErrorCode;
|
||||
readonly component: string;
|
||||
|
||||
constructor(code: NoticeErrorCode, component: string) {
|
||||
super(code);
|
||||
this.name = "NoticeError";
|
||||
this.code = code;
|
||||
this.component = component;
|
||||
}
|
||||
}
|
||||
|
||||
export type NoticeUse = "backend" | "browser";
|
||||
|
||||
export interface NoticeComponent {
|
||||
readonly bundled: BundledPackage;
|
||||
readonly uses: ReadonlySet<NoticeUse>;
|
||||
}
|
||||
|
||||
/** Licences whose standard text is complete without any project-specific line, so supplying it
|
||||
* for a package that ships none is faithful. Checked into this package (`licenses/`). */
|
||||
const STANDARD_TEXTS: Readonly<Record<string, string>> = Object.freeze({
|
||||
"Apache-2.0": "Apache-2.0.txt",
|
||||
});
|
||||
|
||||
const LICENCE_FILE =
|
||||
/^(?:licen[cs]e(?:[-._][a-z0-9.-]+)?|copying(?:[-._][a-z0-9.-]+)?|notice(?:[-._][a-z0-9.-]+)?|copyright(?:[-._][a-z0-9.-]+)?)$/iu;
|
||||
|
||||
const RULE = "-".repeat(80);
|
||||
|
||||
function licenceOf(bundled: BundledPackage): string | undefined {
|
||||
const declared = bundled.manifest.license;
|
||||
if (typeof declared === "string" && declared.trim() !== "") {
|
||||
return declared.trim();
|
||||
}
|
||||
if (typeof declared === "object" && declared !== null && "type" in declared) {
|
||||
const type = (declared as { type?: unknown }).type;
|
||||
return typeof type === "string" && type.trim() !== "" ? type.trim() : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalized(text: string): string {
|
||||
return `${text.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replace(/\s+$/u, "")}\n`;
|
||||
}
|
||||
|
||||
function compare(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
async function licenceTexts(
|
||||
bundled: BundledPackage,
|
||||
licence: string,
|
||||
): Promise<readonly { readonly title: string; readonly text: string }[]> {
|
||||
const names = (await readdir(bundled.root, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && LICENCE_FILE.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
.sort(compare);
|
||||
const seeFile = /^SEE LICEN[CS]E IN (.+)$/iu.exec(licence)?.[1]?.trim();
|
||||
if (seeFile && !names.includes(seeFile)) {
|
||||
const resolved = path.resolve(bundled.root, seeFile);
|
||||
if (resolved.startsWith(`${bundled.root}${path.sep}`)) {
|
||||
names.push(seeFile);
|
||||
}
|
||||
}
|
||||
const texts: { title: string; text: string }[] = [];
|
||||
for (const name of names) {
|
||||
texts.push({
|
||||
text: normalized(await readFile(path.join(bundled.root, name), "utf8")),
|
||||
title: name,
|
||||
});
|
||||
}
|
||||
return texts;
|
||||
}
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function standardText(licence: string): Promise<string | undefined> {
|
||||
const file = STANDARD_TEXTS[licence];
|
||||
return file === undefined
|
||||
? undefined
|
||||
: normalized(await readFile(path.join(packageRoot, "licenses", file), "utf8"));
|
||||
}
|
||||
|
||||
export async function thirdPartyNotices(
|
||||
components: ReadonlyMap<string, NoticeComponent>,
|
||||
): Promise<string> {
|
||||
const ordered = [...components.values()].sort(
|
||||
(left, right) =>
|
||||
compare(left.bundled.name, right.bundled.name) ||
|
||||
compare(left.bundled.version, right.bundled.version),
|
||||
);
|
||||
const sections: string[] = [];
|
||||
for (const { bundled, uses } of ordered) {
|
||||
const component = `${bundled.name}@${bundled.version}`;
|
||||
const licence = licenceOf(bundled);
|
||||
if (licence === undefined || /^UNLICEN[CS]ED$/iu.test(licence)) {
|
||||
throw new NoticeError("MODULE_PACK_LICENCE_UNKNOWN", component);
|
||||
}
|
||||
let texts = await licenceTexts(bundled, licence);
|
||||
// A NOTICE file alone is not the licence: the licence text must still come from somewhere.
|
||||
if (!texts.some((text) => !/^notice/iu.test(text.title))) {
|
||||
const standard = await standardText(licence);
|
||||
if (standard === undefined) {
|
||||
throw new NoticeError("MODULE_PACK_LICENCE_TEXT_MISSING", component);
|
||||
}
|
||||
texts = [
|
||||
...texts,
|
||||
{
|
||||
text: `The package ships no licence file. This is the standard text of the licence it declares (${licence}).\n\n${standard}`,
|
||||
title: `Standard ${licence} text`,
|
||||
},
|
||||
];
|
||||
}
|
||||
sections.push(
|
||||
[
|
||||
RULE,
|
||||
`${bundled.name} ${bundled.version}`,
|
||||
`License: ${licence}`,
|
||||
`Used in: ${[...uses].sort(compare).join(", ")}`,
|
||||
RULE,
|
||||
"",
|
||||
...texts.flatMap((text) => [`[${text.title}]`, "", text.text]),
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const header = [
|
||||
"THIRD-PARTY SOFTWARE NOTICES",
|
||||
"",
|
||||
"This module package includes the third-party software listed below. Each entry names the",
|
||||
"component, its version, its declared licence, where in the package it is used, and the licence",
|
||||
"and notice text that came with it.",
|
||||
"",
|
||||
"The Ekklesia platform's own packages are not third-party software. They are listed in",
|
||||
'ekklesia.package.json under "incorporates".',
|
||||
"",
|
||||
ordered.length === 0
|
||||
? "This module package includes no third-party software."
|
||||
: `${ordered.length} ${ordered.length === 1 ? "component" : "components"}.`,
|
||||
"",
|
||||
].join("\n");
|
||||
return `${[header, ...sections].join("\n").replace(/\n+$/u, "")}\n`;
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
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 {
|
||||
MODULE_PACKAGE_ANY_TARGET,
|
||||
MODULE_PACKAGE_FORMAT,
|
||||
MODULE_PACKAGE_METADATA_FILENAME,
|
||||
MODULE_PACKAGE_NOTICES_FILENAME,
|
||||
type ModuleManifest,
|
||||
modulePackageArchiveFileName,
|
||||
modulePackageRuntimePackage,
|
||||
safeParseModuleManifest,
|
||||
safeParseModulePackageMetadata,
|
||||
} from "@ekklesia/contracts";
|
||||
import { analyzeModuleUi, ModuleUiBuildError } from "@ekklesia/module-ui-build";
|
||||
import { build, type Message, type Plugin } from "esbuild";
|
||||
import { minVersion, satisfies, validRange } from "semver";
|
||||
import { NoticeError, type NoticeComponent, type NoticeUse, thirdPartyNotices } from "./notices.js";
|
||||
import { BundledPackageIndex, isNativeOrPlatformSpecific } from "./packages.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. In this release it produces the portable `any` target only.
|
||||
*/
|
||||
|
||||
export type ModulePackErrorCode =
|
||||
| "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_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_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_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_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 Node.js range could not be determined. Run inside an Ekklesia workspace, or pass one.",
|
||||
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 target 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;
|
||||
/** `any` only, in this release. */
|
||||
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 enclosing workspace's
|
||||
* `engines.node`, the toolchain the module was built and tested with. */
|
||||
readonly nodeRange?: 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) };
|
||||
}
|
||||
|
||||
async function workspaceNodeRange(moduleDirectory: string): Promise<string> {
|
||||
let directory = moduleDirectory;
|
||||
for (;;) {
|
||||
try {
|
||||
await lstat(path.join(directory, "pnpm-workspace.yaml"));
|
||||
const manifest = await readJson(path.join(directory, "package.json"));
|
||||
const engines = isRecord(manifest) ? manifest.engines : undefined;
|
||||
const range = isRecord(engines) ? engines.node : undefined;
|
||||
if (typeof range === "string") {
|
||||
return range;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) {
|
||||
break;
|
||||
}
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
|
||||
}
|
||||
|
||||
/** `./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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BackendBundle {
|
||||
readonly outputs: ReadonlyMap<string, Buffer>;
|
||||
readonly inputs: readonly string[];
|
||||
}
|
||||
|
||||
async function bundleBackend(
|
||||
moduleDirectory: string,
|
||||
manifest: ModuleManifest,
|
||||
nodeRange: string,
|
||||
): 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: [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)) {
|
||||
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> {
|
||||
if (options.target !== MODULE_PACKAGE_ANY_TARGET) {
|
||||
throw new ModulePackError("MODULE_PACK_TARGET_UNSUPPORTED", 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 nodeRange = options.nodeRange ?? (await workspaceNodeRange(moduleDirectory));
|
||||
if (validRange(nodeRange) === null) {
|
||||
throw new ModulePackError("MODULE_PACK_RUNTIME_UNKNOWN");
|
||||
}
|
||||
|
||||
// 1. The backend, bundled with every pure-JavaScript dependency it uses.
|
||||
const backend = await bundleBackend(moduleDirectory, manifest, nodeRange);
|
||||
|
||||
// 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.
|
||||
const index = new BundledPackageIndex();
|
||||
const components = new Map<
|
||||
string,
|
||||
{ bundled: Awaited<ReturnType<typeof index.packageOf>>; uses: Set<NoticeUse> }
|
||||
>();
|
||||
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.thirdParty) {
|
||||
if (bundled.name.startsWith("@ekklesia/")) {
|
||||
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 (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);
|
||||
} else {
|
||||
if (use === "backend" && (await isNativeOrPlatformSpecific(bundled))) {
|
||||
throw new ModulePackError("MODULE_PACK_NATIVE_DEPENDENCY", key);
|
||||
}
|
||||
components.set(key, { bundled, uses: new Set([use]) });
|
||||
}
|
||||
}
|
||||
if (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): void => {
|
||||
if (!portable(relative)) {
|
||||
throw new ModulePackError("MODULE_PACK_PATH_UNSUPPORTED", relative);
|
||||
}
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
add("ekklesia.module.json", manifestBytes);
|
||||
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(components as ReadonlyMap<string, NoticeComponent>)),
|
||||
);
|
||||
} 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 },
|
||||
target: MODULE_PACKAGE_ANY_TARGET,
|
||||
runtime: { node: nodeRange },
|
||||
provenance: {
|
||||
revision: options.sourceRevision,
|
||||
...(options.repository === undefined ? {} : { repository: options.repository }),
|
||||
packager: await packagerIdentity(),
|
||||
},
|
||||
incorporates: Object.fromEntries(
|
||||
[...incorporates].sort(([left], [right]) => compare(left, right)),
|
||||
),
|
||||
...(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,
|
||||
MODULE_PACKAGE_ANY_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: MODULE_PACKAGE_ANY_TARGET,
|
||||
version: manifest.version,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { access, readdir, readFile, realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* What the packager knows about a package whose files were bundled: where it is, what it is called,
|
||||
* and whether it is third-party software. A package installed under a `node_modules` directory is
|
||||
* third-party; one that is not (a workspace package, or the module itself) is the platform's or the
|
||||
* module's own code.
|
||||
*/
|
||||
export interface BundledPackage {
|
||||
readonly root: string;
|
||||
readonly name: string;
|
||||
readonly version: string;
|
||||
readonly thirdParty: boolean;
|
||||
readonly manifest: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function readManifest(directory: string): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(path.join(directory, "package.json"), "utf8"),
|
||||
) as unknown;
|
||||
return isRecord(parsed) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Finds the package a bundled file belongs to: the nearest enclosing directory whose package.json
|
||||
* names a package and its version. Nested `package.json` files that only set a module type, as
|
||||
* some libraries ship in subdirectories, are skipped. */
|
||||
export class BundledPackageIndex {
|
||||
readonly #byDirectory = new Map<string, BundledPackage | undefined>();
|
||||
|
||||
async packageOf(file: string): Promise<BundledPackage | undefined> {
|
||||
let directory = path.dirname(await realpath(file).catch(() => file));
|
||||
const visited: string[] = [];
|
||||
let found: BundledPackage | undefined;
|
||||
for (;;) {
|
||||
if (this.#byDirectory.has(directory)) {
|
||||
found = this.#byDirectory.get(directory);
|
||||
break;
|
||||
}
|
||||
visited.push(directory);
|
||||
const manifest = await readManifest(directory);
|
||||
if (manifest && typeof manifest.name === "string" && typeof manifest.version === "string") {
|
||||
found = Object.freeze({
|
||||
manifest: Object.freeze(manifest),
|
||||
name: manifest.name,
|
||||
root: directory,
|
||||
thirdParty: directory.split(path.sep).includes("node_modules"),
|
||||
version: manifest.version,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) {
|
||||
break;
|
||||
}
|
||||
directory = parent;
|
||||
}
|
||||
for (const seen of visited) {
|
||||
this.#byDirectory.set(seen, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function declaresPlatform(manifest: Readonly<Record<string, unknown>>): boolean {
|
||||
return ["os", "cpu", "libc"].some((field) => manifest[field] !== undefined);
|
||||
}
|
||||
|
||||
async function containsCompiledAddon(directory: string): Promise<boolean> {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (
|
||||
entry.name !== "node_modules" &&
|
||||
(await containsCompiledAddon(path.join(directory, entry.name)))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
} else if (entry.name.toLowerCase().endsWith(".node")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The installed package a dependency name resolves to from `root`, by Node's own directory walk. */
|
||||
async function installedDependency(
|
||||
root: string,
|
||||
name: string,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
let directory = root;
|
||||
for (;;) {
|
||||
if (path.basename(directory) !== "node_modules") {
|
||||
const manifest = await readManifest(path.join(directory, "node_modules", ...name.split("/")));
|
||||
if (manifest) {
|
||||
return manifest;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) {
|
||||
return undefined;
|
||||
}
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bundled package is native or platform-specific, which an `any` package may never
|
||||
* contain (ADR 0049): it declares an operating system, CPU, or C library; it is built with node-gyp;
|
||||
* it ships a compiled `.node` addon; or one of its installed dependencies declares a platform, as
|
||||
* packages that load a per-platform binary do.
|
||||
*/
|
||||
export async function isNativeOrPlatformSpecific(bundled: BundledPackage): Promise<boolean> {
|
||||
const manifest = bundled.manifest;
|
||||
if (declaresPlatform(manifest) || manifest.gypfile === true) {
|
||||
return true;
|
||||
}
|
||||
if (await exists(path.join(bundled.root, "binding.gyp"))) {
|
||||
return true;
|
||||
}
|
||||
if (await containsCompiledAddon(bundled.root)) {
|
||||
return true;
|
||||
}
|
||||
for (const field of ["dependencies", "optionalDependencies"]) {
|
||||
const dependencies = manifest[field];
|
||||
if (!isRecord(dependencies)) {
|
||||
continue;
|
||||
}
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
const dependency = await installedDependency(bundled.root, name);
|
||||
if (dependency && declaresPlatform(dependency)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
/**
|
||||
* The deterministic archive encoding of Ekklesia Module Package v1 (ADR 0049).
|
||||
*
|
||||
* POSIX ustar, written by hand rather than by a general tar library, so every byte is decided here:
|
||||
* - one top-level directory, then every directory and regular file in byte-wise path order, which
|
||||
* puts every directory before what it contains;
|
||||
* - uid and gid 0, empty user and group names, modification time 0, device fields 0;
|
||||
* - directories 0755 and files 0644; no links, no special files, no extended or long-name headers;
|
||||
* - exactly two zero blocks at the end;
|
||||
* - gzip at a fixed level, with Node's header: no file name and a modification time of 0.
|
||||
*
|
||||
* The same packager version and the same input bytes therefore always produce the same archive.
|
||||
*/
|
||||
|
||||
export class TarPathError extends Error {
|
||||
readonly path: string;
|
||||
|
||||
constructor(entryPath: string) {
|
||||
super("A path does not fit a portable ustar header.");
|
||||
this.name = "TarPathError";
|
||||
this.path = entryPath;
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCK = 512;
|
||||
const GZIP_LEVEL = 9;
|
||||
|
||||
function octal(value: number, width: number): string {
|
||||
return `${value.toString(8).padStart(width - 1, "0")}\u0000`;
|
||||
}
|
||||
|
||||
/** Splits a path into ustar's 155-byte prefix and 100-byte name, at a `/`. */
|
||||
function splitPath(entryPath: string): { readonly name: string; readonly prefix: string } {
|
||||
if (entryPath.length <= 100) {
|
||||
return { name: entryPath, prefix: "" };
|
||||
}
|
||||
for (let index = entryPath.length - 1; index > 0; index -= 1) {
|
||||
if (entryPath[index] === "/") {
|
||||
const prefix = entryPath.slice(0, index);
|
||||
const name = entryPath.slice(index + 1);
|
||||
if (prefix.length <= 155 && name.length > 0 && name.length <= 100) {
|
||||
return { name, prefix };
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new TarPathError(entryPath);
|
||||
}
|
||||
|
||||
function header(entryPath: string, directory: boolean, size: number): Buffer {
|
||||
const block = Buffer.alloc(BLOCK);
|
||||
const { name, prefix } = splitPath(directory ? `${entryPath}/` : entryPath);
|
||||
block.write(name, 0, 100, "latin1");
|
||||
block.write(octal(directory ? 0o755 : 0o644, 8), 100, "latin1");
|
||||
block.write(octal(0, 8), 108, "latin1");
|
||||
block.write(octal(0, 8), 116, "latin1");
|
||||
block.write(octal(size, 12), 124, "latin1");
|
||||
block.write(octal(0, 12), 136, "latin1");
|
||||
block.write(" ", 148, "latin1");
|
||||
block.write(directory ? "5" : "0", 156, "latin1");
|
||||
block.write("ustar\u000000", 257, "latin1");
|
||||
block.write(octal(0, 8), 329, "latin1");
|
||||
block.write(octal(0, 8), 337, "latin1");
|
||||
block.write(prefix, 345, 155, "latin1");
|
||||
let checksum = 0;
|
||||
for (const byte of block) {
|
||||
checksum += byte;
|
||||
}
|
||||
block.write(`${checksum.toString(8).padStart(6, "0")}\u0000 `, 148, "latin1");
|
||||
return block;
|
||||
}
|
||||
|
||||
function compareBytes(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives `files` (paths relative to the module directory, printable ASCII) under `topLevel`.
|
||||
* Directories are implied by the file paths.
|
||||
*/
|
||||
export function deterministicTarGz(topLevel: string, files: ReadonlyMap<string, Buffer>): Buffer {
|
||||
const directories = new Set<string>([topLevel]);
|
||||
for (const filePath of files.keys()) {
|
||||
const segments = filePath.split("/");
|
||||
for (let index = 1; index < segments.length; index += 1) {
|
||||
directories.add(`${topLevel}/${segments.slice(0, index).join("/")}`);
|
||||
}
|
||||
}
|
||||
const entries: { readonly path: string; readonly data?: Buffer }[] = [
|
||||
...[...directories].map((directory) => ({ path: directory })),
|
||||
...[...files].map(([filePath, data]) => ({ data, path: `${topLevel}/${filePath}` })),
|
||||
].sort((left, right) => compareBytes(left.path, right.path));
|
||||
|
||||
const parts: Buffer[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.data === undefined) {
|
||||
parts.push(header(entry.path, true, 0));
|
||||
} else {
|
||||
parts.push(header(entry.path, false, entry.data.length), entry.data);
|
||||
const padding = (BLOCK - (entry.data.length % BLOCK)) % BLOCK;
|
||||
if (padding > 0) {
|
||||
parts.push(Buffer.alloc(padding));
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.push(Buffer.alloc(BLOCK * 2));
|
||||
return gzipSync(Buffer.concat(parts), { level: GZIP_LEVEL });
|
||||
}
|
||||
Reference in New Issue
Block a user