Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
multiplatform
  • Loading branch information
vovsemenv committed Jul 6, 2026
commit 57757f479dadf296dc47ca1c5dda3f9f99cada36
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"audit": "yarn npm audit --all --recursive",
"build": "yarn workspaces foreach -Avvpt run build",
"build:sea": "node ./scripts/sea/build-sea.mjs",
"build:sea:linux": "node ./scripts/sea/build-sea.mjs --platform linux --arch x64",
"clean": "yarn workspaces foreach -Avvpt run clean",
"format": "yarn format:write",
"report": "yarn allure generate --config=./allurerc.mjs",
Expand Down
193 changes: 174 additions & 19 deletions scripts/sea/build-sea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,39 @@
* 2. Embed runtime-resolved static files (web report bundles, package manifests)
* as SEA assets; they are extracted to a temp dir on startup by
* scripts/sea/import-meta-url-shim.mjs.
* 3. Generate the SEA preparation blob and inject it into a copy of the
* 3. Generate the SEA preparation blob and inject it into the target platform's
* Node.js binary with postject.
*
* Usage: yarn build && yarn build:sea
* Output: out/sea/allure (or allure.exe on Windows)
* The bundle and the blob are platform-independent (we keep useCodeCache /
* useSnapshot off), so a binary for any OS/arch can be produced from any host.
* When the target differs from the host, the matching official Node.js binary
* (same version as the running Node) is downloaded and cached under out/sea/.node-cache.
*
* Usage:
* yarn build && yarn build:sea # build for the host platform
* yarn build:sea --platform linux --arch x64 # cross-build for linux-x64
* yarn build:sea:linux # shorthand for linux-x64
*
* Output: out/sea/<platform>-<arch>/allure (or allure.exe for windows targets)
*/
import { execFileSync } from "node:child_process";
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, relative, resolve } from "node:path";
import process from "node:process";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { fileURLToPath } from "node:url";

const require = createRequire(import.meta.url);
Expand All @@ -28,19 +50,131 @@ const { inject } = require("postject");

const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const outDir = join(rootDir, "out", "sea");
const nodeCacheDir = join(outDir, ".node-cache");
const entryPoint = join(rootDir, "packages", "cli", "dist", "sea.js");
const shimPath = join(rootDir, "scripts", "sea", "import-meta-url-shim.mjs");
const bundlePath = join(outDir, "allure.cjs");
const blobPath = join(outDir, "sea-prep.blob");
const seaConfigPath = join(outDir, "sea-config.json");
const binaryName = process.platform === "win32" ? "allure.exe" : "allure";
const binaryPath = join(outDir, binaryName);

const SENTINEL_FUSE = "NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2";
const SUPPORTED_PLATFORMS = new Set(["linux", "darwin", "win32"]);
const SUPPORTED_ARCHES = new Set(["x64", "arm64"]);

// packages whose dist files are resolved with require.resolve()/readFile() at runtime
const WEB_PACKAGES = ["web-awesome", "web-classic", "web-dashboard", "web-summary"];

const parseTarget = () => {
const args = process.argv.slice(2);
let platform = process.platform;
let arch = process.arch;

for (let i = 0; i < args.length; i++) {
const arg = args[i];

if (arg === "--platform" || arg === "--os") {
platform = args[++i];
} else if (arg === "--arch") {
arch = args[++i];
} else if (arg.startsWith("--platform=")) {
platform = arg.slice("--platform=".length);
} else if (arg.startsWith("--arch=")) {
arch = arg.slice("--arch=".length);
} else if (arg === "--target" || arg.startsWith("--target=")) {
const value = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++i];
[platform, arch] = value.split("-");
}
}

if (!SUPPORTED_PLATFORMS.has(platform)) {
throw new Error(`unsupported target platform "${platform}" (expected one of ${[...SUPPORTED_PLATFORMS].join(", ")})`);
}

if (!SUPPORTED_ARCHES.has(arch)) {
throw new Error(`unsupported target arch "${arch}" (expected one of ${[...SUPPORTED_ARCHES].join(", ")})`);
}

return { platform, arch };
};

const downloadFile = async (url, dest) => {
const res = await fetch(url);

if (!res.ok) {
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
}

await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
};

const sha256 = async (path) => {
const hash = createHash("sha256");

await pipeline(createReadStream(path), hash);

return hash.digest("hex");
};

/**
* Returns the path to a Node.js binary for the given target platform/arch,
* matching the version of the Node.js running this script (a hard SEA
* requirement). Uses the host binary when the target matches, otherwise
* downloads and caches the official distribution.
*/
const resolveTargetNode = async (platform, arch) => {
if (platform === process.platform && arch === process.arch) {
return process.execPath;
}

const version = process.version; // e.g. "v24.17.0"
const isWin = platform === "win32";
const nodeOs = isWin ? "win" : platform; // linux | darwin | win
const ext = isWin ? "zip" : "tar.gz";
const dirName = `node-${version}-${nodeOs}-${arch}`;
const archiveName = `${dirName}.${ext}`;
const archivePath = join(nodeCacheDir, archiveName);
const nodeBinary = isWin ? join(nodeCacheDir, dirName, "node.exe") : join(nodeCacheDir, dirName, "bin", "node");

if (existsSync(nodeBinary)) {
return nodeBinary;
}

mkdirSync(nodeCacheDir, { recursive: true });

const baseUrl = `https://nodejs.org/dist/${version}`;

console.log(`downloading ${archiveName} (target Node.js binary)...`);
await downloadFile(`${baseUrl}/${archiveName}`, archivePath);

const shasums = await (await fetch(`${baseUrl}/SHASUMS256.txt`)).text();
const expected = shasums
.split("\n")
.map((line) => line.trim().split(/\s+/))
.find(([, name]) => name === archiveName)?.[0];

if (!expected) {
throw new Error(`could not find checksum for ${archiveName} in SHASUMS256.txt`);
}

const actual = await sha256(archivePath);

if (actual !== expected) {
throw new Error(`checksum mismatch for ${archiveName}: expected ${expected}, got ${actual}`);
}

if (isWin) {
execFileSync("unzip", ["-oq", archivePath, "-d", nodeCacheDir], { stdio: "inherit" });
} else {
execFileSync("tar", ["-xzf", archivePath, "-C", nodeCacheDir], { stdio: "inherit" });
}

if (!existsSync(nodeBinary)) {
throw new Error(`extracted archive but did not find ${nodeBinary}`);
}

return nodeBinary;
};

const collectFiles = (dir) => {
const files = [];

Expand Down Expand Up @@ -136,45 +270,66 @@ const bundle = async () => {
});
};

const buildExecutable = () => {
const generateBlob = () => {
const assets = collectAssets();
const seaConfig = {
main: bundlePath,
output: blobPath,
disableExperimentalSEAWarning: true,
// must stay false for a portable, cross-platform blob (code cache/snapshot
// are tied to the building machine's platform)
useCodeCache: false,
useSnapshot: false,
assets,
};

writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2));

console.log(`generating SEA blob (${Object.keys(assets).length} embedded assets)...`);
execFileSync(process.execPath, ["--experimental-sea-config", seaConfigPath], { stdio: "inherit" });
};

const buildExecutable = async (platform, arch) => {
const targetNode = await resolveTargetNode(platform, arch);
const isWin = platform === "win32";
const isDarwin = platform === "darwin";
const targetDir = join(outDir, `${platform}-${arch}`);
const binaryPath = join(targetDir, isWin ? "allure.exe" : "allure");

mkdirSync(targetDir, { recursive: true });
copyFileSync(targetNode, binaryPath);
chmodSync(binaryPath, 0o755);

copyFileSync(process.execPath, binaryPath);
// codesign manipulation only makes sense for a macOS binary on a macOS host
const canCodesign = isDarwin && process.platform === "darwin";

if (process.platform === "darwin") {
if (canCodesign) {
execFileSync("codesign", ["--remove-signature", binaryPath], { stdio: "inherit" });
}

return inject(binaryPath, "NODE_SEA_BLOB", readFileSync(blobPath), {
await inject(binaryPath, "NODE_SEA_BLOB", readFileSync(blobPath), {
sentinelFuse: SENTINEL_FUSE,
machoSegmentName: process.platform === "darwin" ? "NODE_SEA" : undefined,
}).then(() => {
if (process.platform === "darwin") {
execFileSync("codesign", ["--sign", "-", binaryPath], { stdio: "inherit" });
}

chmodSync(binaryPath, 0o755);
console.log(`SEA binary is ready: ${relative(process.cwd(), binaryPath)}`);
machoSegmentName: isDarwin ? "NODE_SEA" : undefined,
});

if (canCodesign) {
execFileSync("codesign", ["--sign", "-", binaryPath], { stdio: "inherit" });
} else if (isDarwin) {
console.warn("warning: building a macOS binary on a non-macOS host; it will need `codesign --sign -` before running");
}

chmodSync(binaryPath, 0o755);
console.log(`SEA binary is ready: ${relative(process.cwd(), binaryPath)} (${platform}-${arch})`);
};

const { platform, arch } = parseTarget();

mkdirSync(outDir, { recursive: true });

try {
await bundle();
await buildExecutable();
generateBlob();
await buildExecutable(platform, arch);
} catch (err) {
console.error(err);
process.exit(1);
Expand Down