mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Stack the libnode packs on the in-process loader (#1685) and use its pack assembler and manifest format instead of a second one: build-pack.mjs keeps its CLI and fields (schema, nodeVersion, moduleVersion, bridgeAbi, platform, arch, files), adds the libnode and bridge names, strips and (on macOS) re-signs the bridge and links it with -z defs, and exports buildPack(), which the platform packs call with their package, target and build inputs as extra manifest fields. libnodeRecord falls back to the installed @scriptc/libnode-<platform> pack, found from the entry's directory and then next to the compiler, when neither --libnode nor SCRIPTC_LIBNODE names one. The Linux test lanes restore the libnode build that the libnode-packs workflow cached, assemble the linux-x64 pack from it in seconds (build.mjs --from-cache never builds Node) and export SCRIPTC_LIBNODE, so the Node island corpus and harness tests run instead of skipping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
337 lines
12 KiB
JavaScript
337 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// Builds one libnode pack into its package directory:
|
|
//
|
|
// 1. libnode: the pinned Node.js release (libnode.json) from its official
|
|
// source tarball, SHA-256 verified, configured `--shared` with full ICU,
|
|
// only the `libnode` target built, stripped, staged with its headers
|
|
// and LICENSE in a cache directory keyed by libnodeCacheKey(). A cache
|
|
// hit skips the Node build entirely (about 26 minutes on 8 cores).
|
|
// 2. the bridge, compiled against those headers (seconds, every run);
|
|
// 3. the pack: libnode, the bridge, Node's LICENSE and manifest.json in
|
|
// packages/libnode-<platform>/, plus `--compress` copies (.zst) of both
|
|
// libraries in <cache>/compressed/ for size reports and embedding.
|
|
//
|
|
// This never runs in `pnpm -r build` or the test suites: each platform
|
|
// package runs it through its explicit `build:native` script, and the
|
|
// libnode-packs workflow runs that on matching hosts.
|
|
//
|
|
// Environment:
|
|
// SCRIPTC_LIBNODE_CACHE cache root (default node_modules/.cache/scriptc-libnode)
|
|
// SCRIPTC_LIBNODE_SOURCE an extracted source tree of the pinned release to
|
|
// build in (seeds the cache from an earlier build tree)
|
|
// SCRIPTC_LIBNODE_TOOLCHAIN a name for the toolchain, part of the cache key
|
|
// (CI: "manylinux_2_28-gcc14", "macos-15-clang")
|
|
// SCRIPTC_LIBNODE_JOBS ninja parallelism (default: available cores)
|
|
// CC, CXX the compilers for Node and the bridge
|
|
import { execFileSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
copyFileSync,
|
|
createReadStream,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { availableParallelism } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseArgs } from "node:util";
|
|
import { buildPack } from "../build-pack.mjs";
|
|
import {
|
|
bridgeAbi,
|
|
bridgeHash,
|
|
libnodeCacheKey,
|
|
packFileNames,
|
|
platformInfo,
|
|
readPin,
|
|
repoRoot,
|
|
} from "./pin.mjs";
|
|
|
|
function log(message) {
|
|
process.stdout.write(`libnode: ${message}\n`);
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
log(`$ ${command} ${args.join(" ")}`);
|
|
execFileSync(command, args, { stdio: "inherit", ...options });
|
|
}
|
|
|
|
async function sha256File(path) {
|
|
const hash = createHash("sha256");
|
|
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
/** Downloads and verifies the pinned source tarball, then extracts it. */
|
|
async function fetchSource(cacheRoot, pin) {
|
|
const name = `node-v${pin.nodeVersion}`;
|
|
const tree = join(cacheRoot, "src", name);
|
|
if (existsSync(join(tree, "configure.py"))) return tree;
|
|
const tarball = join(cacheRoot, "src", `${name}.tar.xz`);
|
|
mkdirSync(join(cacheRoot, "src"), { recursive: true });
|
|
if (!existsSync(tarball) || (await sha256File(tarball)) !== pin.tarballSha256) {
|
|
const url = `https://nodejs.org/dist/v${pin.nodeVersion}/${name}.tar.xz`;
|
|
run("curl", ["-fsSL", "--retry", "3", "-o", `${tarball}.part`, url]);
|
|
renameSync(`${tarball}.part`, tarball);
|
|
}
|
|
const actual = await sha256File(tarball);
|
|
if (actual !== pin.tarballSha256) {
|
|
rmSync(tarball, { force: true });
|
|
throw new Error(`${name}.tar.xz has SHA-256 ${actual}, expected ${pin.tarballSha256}`);
|
|
}
|
|
rmSync(tree, { recursive: true, force: true });
|
|
run("tar", ["-xJf", tarball, "-C", join(cacheRoot, "src")]);
|
|
return tree;
|
|
}
|
|
|
|
/** The version a source tree's node_version.h declares. */
|
|
function sourceVersion(tree) {
|
|
const text = readFileSync(join(tree, "src", "node_version.h"), "utf8");
|
|
const define = (name) => new RegExp(`#define ${name} (\\d+)`).exec(text)?.[1];
|
|
return `${define("NODE_MAJOR_VERSION")}.${define("NODE_MINOR_VERSION")}.${define("NODE_PATCH_VERSION")}`;
|
|
}
|
|
|
|
function moduleVersionOf(headers) {
|
|
const text = readFileSync(join(headers, "include", "node", "node_version.h"), "utf8");
|
|
return Number(/#define NODE_MODULE_VERSION (\d+)/.exec(text)?.[1]);
|
|
}
|
|
|
|
/** The highest GLIBC_x.y symbol version a Linux library needs. */
|
|
function minimumGlibc(library) {
|
|
const dynamic = execFileSync("objdump", ["-T", library], {
|
|
encoding: "utf8",
|
|
maxBuffer: 256 * 1024 * 1024,
|
|
});
|
|
const versions = [...new Set(dynamic.match(/GLIBC_\d+(\.\d+)+/g) ?? [])].map((v) =>
|
|
v.slice("GLIBC_".length),
|
|
);
|
|
versions.sort((a, b) => {
|
|
const x = a.split(".").map(Number);
|
|
const y = b.split(".").map(Number);
|
|
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
|
|
}
|
|
return 0;
|
|
});
|
|
return versions.at(-1) ?? null;
|
|
}
|
|
|
|
/** Configures and builds libnode in `tree`, then stages it into `stage`. */
|
|
function buildLibnode(tree, stage, platform, pin) {
|
|
const { os } = platformInfo(platform);
|
|
if (sourceVersion(tree) !== pin.nodeVersion) {
|
|
throw new Error(`${tree} is Node ${sourceVersion(tree)}, the pin is ${pin.nodeVersion}`);
|
|
}
|
|
const python = process.env.PYTHON ?? "python3";
|
|
// An earlier tree configured with exactly these flags is reused as is, so
|
|
// a seeded build tree is a ninja no-op instead of a reconfigure.
|
|
const status = join(tree, "config.status");
|
|
const configured =
|
|
existsSync(status) &&
|
|
existsSync(join(tree, "out", "Release", "build.ninja")) &&
|
|
readFileSync(status, "utf8")
|
|
.trimEnd()
|
|
.endsWith(`configure ${pin.configureFlags.join(" ")}`);
|
|
const started = Date.now();
|
|
if (!configured) run("./configure", pin.configureFlags, { cwd: tree });
|
|
const gypi = readFileSync(join(tree, "config.gypi"), "utf8");
|
|
for (const [setting, expected] of [
|
|
["node_shared", "true"],
|
|
["node_use_node_snapshot", "true"],
|
|
["icu_small", pin.icu === "small" ? "true" : "false"],
|
|
]) {
|
|
if (!gypi.includes(`"${setting}": "${expected}"`)) {
|
|
throw new Error(`config.gypi does not have ${setting}: ${expected}`);
|
|
}
|
|
}
|
|
const jobs = process.env.SCRIPTC_LIBNODE_JOBS ?? String(availableParallelism());
|
|
run("ninja", ["-C", join(tree, "out", "Release"), "-j", jobs, "libnode"]);
|
|
const buildSeconds = Math.round((Date.now() - started) / 1000);
|
|
|
|
const names = packFileNames(platform, pin.moduleVersion);
|
|
const built = [
|
|
join(tree, "out", "Release", "lib", names.libnode),
|
|
join(tree, "out", "Release", names.libnode),
|
|
].find((path) => existsSync(path));
|
|
if (built === undefined) throw new Error(`the Node build did not produce ${names.libnode}`);
|
|
const partial = `${stage}.partial`;
|
|
rmSync(partial, { recursive: true, force: true });
|
|
mkdirSync(join(partial, "lib"), { recursive: true });
|
|
const library = join(partial, "lib", names.libnode);
|
|
if (os === "darwin") {
|
|
copyFileSync(built, library);
|
|
run("install_name_tool", ["-id", `@rpath/${names.libnode}`, library]);
|
|
// -x keeps the global symbols addons and the bridge bind to.
|
|
run("strip", ["-x", library]);
|
|
// Editing the library invalidated the linker's ad-hoc signature, which
|
|
// arm64 macOS requires to map it.
|
|
run("codesign", ["--force", "--sign", "-", library]);
|
|
} else {
|
|
run("strip", ["--strip-unneeded", "-o", library, built]);
|
|
}
|
|
run(
|
|
python,
|
|
[
|
|
join(tree, "tools", "install.py"),
|
|
"install",
|
|
"--headers-only",
|
|
"--silent",
|
|
"--root-dir",
|
|
tree,
|
|
"--dest-dir",
|
|
join(partial, "headers"),
|
|
"--prefix",
|
|
"/",
|
|
],
|
|
{ cwd: tree },
|
|
);
|
|
const moduleVersion = moduleVersionOf(join(partial, "headers"));
|
|
if (moduleVersion !== pin.moduleVersion) {
|
|
throw new Error(
|
|
`Node ${pin.nodeVersion} has NODE_MODULE_VERSION ${moduleVersion}, the pin says ${pin.moduleVersion}`,
|
|
);
|
|
}
|
|
copyFileSync(join(tree, "LICENSE"), join(partial, "LICENSE"));
|
|
writeFileSync(
|
|
join(partial, "build.json"),
|
|
`${JSON.stringify(
|
|
{
|
|
nodeVersion: pin.nodeVersion,
|
|
moduleVersion,
|
|
configureFlags: pin.configureFlags,
|
|
icu: pin.icu,
|
|
toolchain: process.env.SCRIPTC_LIBNODE_TOOLCHAIN ?? "default",
|
|
buildSeconds,
|
|
builtBytes: statSync(built).size,
|
|
minimumGlibc: os === "linux" ? minimumGlibc(library) : null,
|
|
},
|
|
null,
|
|
2,
|
|
)}\n`,
|
|
);
|
|
rmSync(stage, { recursive: true, force: true });
|
|
renameSync(partial, stage);
|
|
}
|
|
|
|
/** zstd copies of the pack's libraries, for size reports and embedding. */
|
|
function compress(pack, names, out) {
|
|
mkdirSync(out, { recursive: true });
|
|
const sizes = {};
|
|
for (const name of [names.libnode, names.bridge]) {
|
|
const target = join(out, `${name}.zst`);
|
|
const started = Date.now();
|
|
run("zstd", ["-19", "-T0", "-q", "-f", join(pack, name), "-o", target]);
|
|
sizes[name] = { zstdBytes: statSync(target).size, seconds: (Date.now() - started) / 1000 };
|
|
}
|
|
return sizes;
|
|
}
|
|
|
|
/**
|
|
* @param {{ platform: string, packageRoot: string, compress?: boolean, skipLibnode?: boolean }} options
|
|
*/
|
|
export async function buildLibnodePack(options) {
|
|
const { platform, packageRoot } = options;
|
|
const { os, arch } = platformInfo(platform);
|
|
const pin = readPin();
|
|
const cacheRoot = resolve(
|
|
process.env.SCRIPTC_LIBNODE_CACHE ??
|
|
join(repoRoot, "node_modules", ".cache", "scriptc-libnode"),
|
|
);
|
|
const key = libnodeCacheKey(platform);
|
|
const stage = join(cacheRoot, `${platform}-${pin.nodeVersion}-${key.slice(0, 16)}`);
|
|
const names = packFileNames(platform, pin.moduleVersion);
|
|
if (existsSync(join(stage, "build.json"))) {
|
|
log(`cached libnode ${pin.nodeVersion} for ${platform} in ${stage}`);
|
|
} else {
|
|
if (options.skipLibnode) throw new Error(`no cached libnode build in ${stage}`);
|
|
const tree = process.env.SCRIPTC_LIBNODE_SOURCE
|
|
? resolve(process.env.SCRIPTC_LIBNODE_SOURCE)
|
|
: await fetchSource(cacheRoot, pin);
|
|
buildLibnode(tree, stage, platform, pin);
|
|
}
|
|
const build = JSON.parse(readFileSync(join(stage, "build.json"), "utf8"));
|
|
|
|
for (const name of [names.libnode, names.bridge, "LICENSE", "manifest.json"]) {
|
|
rmSync(join(packageRoot, name), { force: true });
|
|
}
|
|
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
const bridgeStarted = Date.now();
|
|
const manifest = buildPack({
|
|
libnode: join(stage, "lib", names.libnode),
|
|
headers: join(stage, "headers", "include", "node"),
|
|
out: packageRoot,
|
|
license: join(stage, "LICENSE"),
|
|
extra: {
|
|
package: packageJson.name,
|
|
version: packageJson.version,
|
|
target: platform,
|
|
license: "LICENSE",
|
|
build: {
|
|
libnodeKey: key,
|
|
bridgeHash: bridgeHash(),
|
|
configureFlags: build.configureFlags,
|
|
icu: build.icu,
|
|
toolchain: build.toolchain,
|
|
minimumGlibc: build.minimumGlibc,
|
|
},
|
|
},
|
|
});
|
|
const bridgeSeconds = (Date.now() - bridgeStarted) / 1000;
|
|
if (
|
|
manifest.nodeVersion !== pin.nodeVersion ||
|
|
manifest.moduleVersion !== pin.moduleVersion ||
|
|
manifest.bridgeAbi !== bridgeAbi() ||
|
|
manifest.platform !== os ||
|
|
manifest.arch !== arch ||
|
|
manifest.libnode !== names.libnode ||
|
|
manifest.bridge !== names.bridge
|
|
) {
|
|
throw new Error(`the assembled pack does not match ${platform}: ${JSON.stringify(manifest)}`);
|
|
}
|
|
const report = {
|
|
platform,
|
|
nodeVersion: pin.nodeVersion,
|
|
libnodeBuildSeconds: build.buildSeconds,
|
|
libnodeBuiltBytes: build.builtBytes,
|
|
libnodeStrippedBytes: manifest.files[names.libnode].size,
|
|
bridgeBytes: manifest.files[names.bridge].size,
|
|
bridgeSeconds,
|
|
minimumGlibc: manifest.build.minimumGlibc,
|
|
};
|
|
if (options.compress) {
|
|
report.compressed = compress(packageRoot, names, join(cacheRoot, "compressed", platform));
|
|
}
|
|
log(`pack ${packageJson.name} for Node ${pin.nodeVersion}: ${JSON.stringify(report)}`);
|
|
return report;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const { values } = parseArgs({
|
|
options: {
|
|
platform: { type: "string" },
|
|
"package-root": { type: "string" },
|
|
compress: { type: "boolean", default: false },
|
|
"skip-libnode": { type: "boolean", default: false },
|
|
report: { type: "string" },
|
|
},
|
|
});
|
|
if (!values.platform) {
|
|
console.error("usage: build.mjs --platform <platform> [--package-root <dir>] [--compress]");
|
|
process.exit(2);
|
|
}
|
|
const packageRoot = resolve(
|
|
values["package-root"] ?? join(repoRoot, "packages", `libnode-${values.platform}`),
|
|
);
|
|
const report = await buildLibnodePack({
|
|
platform: values.platform,
|
|
packageRoot,
|
|
compress: values.compress,
|
|
skipLibnode: values["skip-libnode"],
|
|
});
|
|
if (values.report) writeFileSync(values.report, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|