Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
4 changes: 2 additions & 2 deletions docs-site/guide/custom-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ PKG_NODE_PATH=/path/to/node pkg app.js
## Caveats

- The binary must be a **compatible** Node.js version — `pkg` still injects its bootstrap prelude and payload, and depends on specific symbol offsets. Use a version supported by `pkg-fetch` unless you know what you're doing.
- `PKG_NODE_PATH` affects a **single target**. Multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run.
- SEA mode uses stock Node.js by its own mechanism and does **not** honour `PKG_NODE_PATH` in the same way. For SEA, use `process.execPath` of the Node.js you want directly.
- `PKG_NODE_PATH` affects a **single target**. In standard mode, multi-target builds still fetch the other platforms from `pkg-fetch` unless you set it per-run.
- SEA mode honours `PKG_NODE_PATH` too. Because SEA injects the payload into the supplied binary directly (there is no per-platform fetch fallback), a custom base binary there is restricted to a **single target**, and pkg validates the binary against it rather than baking a mismatched binary into the output: it sniffs the binary's format and architecture (a macOS binary for a `linux` target, or an x64 binary for an `arm64` target, is rejected) and checks its major version matches the target's. It also rejects targets that span more than one `platform`/`arch` (one binary can't be several at once — including `linux` vs `alpine` vs `linuxstatic`, which aren't distinguishable from the binary but clearly can't all be meant simultaneously). The glibc/musl/static flavor of an ELF binary isn't in the header, so matching that to `linux`/`alpine`/`linuxstatic` remains your responsibility. You can point at the binary with the `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key (both override `PKG_NODE_PATH`). To embed the Node.js you're currently running, pass `PKG_NODE_PATH="$(command -v node)"` (or `--sea-node-path "$(command -v node)"`).

## See also

Expand Down
14 changes: 14 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,18 @@ const FLAG_SPECS: readonly FlagSpec[] = [
default: false,
},
{ cli: 'sea', cfg: 'sea', resolved: 'sea', kind: 'bool', default: false },
{
// SEA mode: use a specific Node binary as the base for the executable
// instead of downloading one from nodejs.org. Lets you embed a custom
// build (e.g. one that runs on older glibc, or a differently-configured
// runtime). Equivalent to the PKG_NODE_PATH env var (which this overrides);
// the binary's major version must match the target's, and it applies to a
// single target. Also settable as the `seaNodePath` pkg-config key.
cli: 'sea-node-path',
cfg: 'seaNodePath',
resolved: 'seaNodePath',
kind: 'string',
},
{
cli: 'compress',
cfg: 'compress',
Expand Down Expand Up @@ -485,6 +497,8 @@ export interface ResolvedFlags {
fallbackToSource: boolean;
public: boolean;
sea: boolean;
/** SEA mode: path to a base Node binary to embed (overrides the download / PKG_NODE_PATH). */
seaNodePath: string | undefined;
publicPackages: string[] | undefined;
noDictionary: string[] | undefined;
bakeOptions: string[] | undefined;
Expand Down
5 changes: 3 additions & 2 deletions lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ export default function help() {
--signature enable macOS binary signing (default; use to override signature:false in config)
--no-signature skip macOS binary signing [default: sign]
--sea (Experimental) compile given file using node's SEA feature. Requires node v20.0.0 or higher and only single file is supported
--sea-node-path SEA mode: path to a base Node binary to embed instead of downloading one (overrides PKG_NODE_PATH; must match the target's platform/arch/major; single target only)

All build-shaping flags above (compress, fallback-to-source, public, public-packages,
options, bytecode, native-build, no-dict, debug, signature, sea) can also be set in
the pkg config file (camelCase keys). CLI flags override config values.
options, bytecode, native-build, no-dict, debug, signature, sea, sea-node-path) can also
be set in the pkg config file (camelCase keys). CLI flags override config values.

${pc.dim('Examples:')}

Expand Down
7 changes: 7 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ export async function exec(
}

if (flags.sea) {
// Base-node override: embed a custom Node binary instead of downloading one.
// From --sea-node-path / the seaNodePath config key, falling back to the
// PKG_NODE_PATH env var (resolved in lib/sea.ts). Lets you ship a SEA built
// on a custom runtime (e.g. one linked against an older glibc).
const seaBase = { nodePath: flags.seaNodePath };
if (inputJson || configJson) {
// Enhanced SEA mode — use walker pipeline.
// seaEnhanced validates the host Node version and minTargetMajor itself.
Expand All @@ -202,6 +207,7 @@ export async function exec(
params: { ...params, seaMode: true },
addition: isConfiguration(input) ? input : undefined,
doCompress: flags.compress,
...seaBase,
});
} else {
// Simple SEA mode — plain .js file without package.json.
Expand All @@ -216,6 +222,7 @@ export async function exec(
await sea(inputFin, {
targets,
signature: flags.signature,
...seaBase,
});
}
return;
Expand Down
214 changes: 207 additions & 7 deletions lib/sea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
mkdtemp,
stat,
readFile,
open,
} from 'fs/promises';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
Expand Down Expand Up @@ -329,6 +330,201 @@ async function getNodeVersion(
return latest.version as NodeVersion;
}

/**
* The custom base Node binary to embed for SEA, or `undefined` to download one.
*
* Precedence matches standard mode: an explicit `opts.nodePath` (from the
* `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key) wins over the
* `PKG_NODE_PATH` environment variable. `PKG_NODE_PATH` is the same env var
* pkg-fetch honours for the traditional build path (`localPlace()`); folding it
* in here makes it work for SEA too, instead of a separate SEA-only mechanism.
*/
function resolveCustomBaseNode(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] · Consistency

PKG_NODE_PATH is passed through resolve() (line 345) but opts.nodePath (the --sea-node-path flag) is returned verbatim — so a relative --sea-node-path is left un-normalized. Harmless today (exec/exists resolve against cwd) but inconsistent.

opts: GetNodejsExecutableOptions,
): string | undefined {
if (opts.nodePath) return opts.nodePath;
if (process.env.PKG_NODE_PATH) return resolve(process.env.PKG_NODE_PATH);
return undefined;
}

/** Executable container format sniffed from a binary's magic bytes. */
type BinaryFormat = 'elf' | 'macho' | 'pe';

const ELF_MACHINE: Record<number, NodeArch> = {
0x3e: 'x64', // EM_X86_64
0xb7: 'arm64', // EM_AARCH64
};
const MACHO_CPU: Record<number, NodeArch> = {
0x01000007: 'x64', // CPU_TYPE_X86_64
0x0100000c: 'arm64', // CPU_TYPE_ARM64
};
const PE_MACHINE: Record<number, NodeArch> = {
0x8664: 'x64', // IMAGE_FILE_MACHINE_AMD64
0xaa64: 'arm64', // IMAGE_FILE_MACHINE_ARM64
};

/**
* Expected container format for a pkg target platform (the raw suffix string).
* ELF covers the whole Linux/Alpine family (linux / alpine / linuxstatic /
* freebsd) — the glibc/musl/static flavor isn't in the header.
*/
const formatForPlatform = (platform: string): BinaryFormat =>
platform === 'macos' || platform === 'darwin'
? 'macho'
: platform === 'win'
? 'pe'
: 'elf';
const FORMAT_LABEL: Record<BinaryFormat, string> = {
elf: 'ELF (Linux/Alpine)',
macho: 'Mach-O (macOS)',
pe: 'PE (Windows)',
};

/**
* Sniff a binary's container format and CPU arch from its magic bytes, so we can
* tell whether a supplied base Node binary actually matches the requested
* target. Reads only the header. Returns `{}` for an unrecognised file (caller
* skips the format/arch checks rather than guessing). Note: ELF can't reveal the
* glibc/musl/static *flavor*, so `elf` maps to the whole Linux/Alpine family.
*/
export async function sniffBinaryTarget(
file: string,
): Promise<{ format?: BinaryFormat; arch?: NodeArch }> {
let fh;
try {
fh = await open(file, 'r');
} catch {
return {};
}
try {
const buf = Buffer.alloc(4096);
const { bytesRead } = await fh.read(buf, 0, 4096, 0);
const b = buf.subarray(0, bytesRead);
if (b.length < 20) return {};

// ELF: 0x7f 'E' 'L' 'F'; EI_DATA at [5] (1=LE,2=BE); e_machine at [18..20].
if (b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) {
const machine = b[5] === 2 ? b.readUInt16BE(18) : b.readUInt16LE(18);
return { format: 'elf', arch: ELF_MACHINE[machine] };
}

// Mach-O (thin): magic FEEDFACE/FEEDFACF; byte-swapped CEFAEDFE/CFFAEDFE are
// the little-endian on-disk forms. cputype is the next 4 bytes.
const be = b.readUInt32BE(0);
if (
be === 0xfeedface ||
be === 0xfeedfacf ||
be === 0xcefaedfe ||
be === 0xcffaedfe
) {
const le = be === 0xcefaedfe || be === 0xcffaedfe;
const cpu = le ? b.readUInt32LE(4) : b.readUInt32BE(4);
return { format: 'macho', arch: MACHO_CPU[cpu] };
}

// PE: 'MZ', e_lfanew (uint32 @0x3C) -> 'PE\0\0', COFF machine 2 bytes after.
if (b[0] === 0x4d && b[1] === 0x5a && b.length >= 0x40) {
const lfanew = b.readUInt32LE(0x3c);
if (
lfanew + 6 <= b.length &&
b[lfanew] === 0x50 &&
b[lfanew + 1] === 0x45 &&
b[lfanew + 2] === 0 &&
b[lfanew + 3] === 0
) {
return { format: 'pe', arch: PE_MACHINE[b.readUInt16LE(lfanew + 4)] };
}
return { format: 'pe' };
}

return {};
} finally {
await fh.close();
}
}

/**
* Guard a custom base Node binary against multi-target / wrong-platform /
* version-skew footguns.
*
* A supplied binary (via `--sea-node-path` / `seaNodePath` / `PKG_NODE_PATH`, or
* `useLocalNode`) is returned by {@link getNodejsExecutable} for *every* target,
* so a multi-target run would bake that one binary into outputs for other
* platforms/arches — silently producing broken artifacts. We:
*
* 1. Reject if the requested targets span more than one distinct
* `platform`+`arch` — one binary can't be several mutually-exclusive things
* at once (incl. linux vs alpine vs linuxstatic, which we can't tell apart
* from the binary but the user clearly can't have meant simultaneously).
* 2. Sniff the binary and reject a format/arch mismatch against that single
* target (e.g. a macOS binary for a `linux` target, or x64 for `arm64`).
* The glibc/musl/static flavor isn't in the header, so that sub-distinction
* stays the user's responsibility.
* 3. Verify the binary's major matches the requested `nodeRange`
* (`assertSingleTargetMajor` only compares the targets to each other).
*/
async function assertCustomBaseNodeTarget(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Nit] · Types

(NodeTarget & Partial<Target>)[] makes platform/arch optionally undefined; String(t.platform) would then silently key combos as 'undefined-undefined' rather than asserting. Targets are fully resolved by here, so only a type smell.

targets: (NodeTarget & Partial<Target>)[],
opts: GetNodejsExecutableOptions,
): Promise<void> {
const customNode = resolveCustomBaseNode(opts);
if (!customNode && !opts.useLocalNode) return;
const binPath = customNode ?? process.execPath;

// 1. A single binary maps to exactly one platform+arch. Key on the raw target
// suffix strings so linux / alpine / linuxstatic stay distinct (they collapse
// under getNodeOs, but they're mutually exclusive runtimes).
const combos = new Map<string, { platform: string; arch: string }>();
for (const t of targets) {
const platform = String(t.platform);
const arch = String(t.arch);
combos.set(`${platform}-${arch}`, { platform, arch });
}
if (combos.size > 1) {
throw wasReported(
`A custom base Node binary applies to a single platform/arch, but the ` +
`requested targets span ${combos.size}: ${[...combos.keys()].join(', ')}. ` +
`One binary can't be all of them — run pkg once per target with a ` +
`matching binary.`,
);
}
const { platform, arch } = [...combos.values()][0];

// 2. Format / arch match against that single target.
const sniff = await sniffBinaryTarget(binPath);
if (sniff.format) {
const expected = formatForPlatform(platform);
if (sniff.format !== expected) {
throw wasReported(
`Custom base Node binary is ${FORMAT_LABEL[sniff.format]}, but target ` +
`"${platform}" needs ${FORMAT_LABEL[expected]}.`,
);
}
if (sniff.arch && sniff.arch !== arch) {
throw wasReported(
`Custom base Node binary is ${sniff.arch}, but target arch is "${arch}". ` +
`The binary must match the target's architecture.`,
);
}
}

// 3. Major version match.
const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10);
if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare
const version =
binPath === process.execPath
? process.version
: (await execFileAsync(binPath, ['--version'])).stdout.trim();
const binMajor = parseInt(version.replace(/^v/, ''), 10);
if (binMajor !== targetMajor) {
throw wasReported(
`Custom base Node binary is ${version} (major ${binMajor}), but target ` +
`"${targets[0].nodeRange}" requests Node ${targetMajor}. The binary's ` +
`major version must match the target.`,
);
}
}

/**
* Resolve the concrete Node.js version (e.g. `v22.22.2`) pkg will use
* for `target` — mirrors the version selection done inside
Expand All @@ -341,10 +537,11 @@ async function resolveTargetNodeVersion(
opts: GetNodejsExecutableOptions,
): Promise<NodeVersion> {
if (opts.useLocalNode) return process.version as NodeVersion;
if (opts.nodePath) {
const customNode = resolveCustomBaseNode(opts);
if (customNode) {
// A user-supplied binary can be any version — don't assume it
// matches the host. Ask it directly.
const { stdout } = await execFileAsync(opts.nodePath, ['--version']);
const { stdout } = await execFileAsync(customNode, ['--version']);
return stdout.trim() as NodeVersion;
}
const os = getNodeOs(target.platform);
Expand All @@ -357,15 +554,16 @@ async function getNodejsExecutable(
target: NodeTarget,
opts: GetNodejsExecutableOptions,
): Promise<string> {
if (opts.nodePath) {
// check if the nodePath exists
if (!(await exists(opts.nodePath))) {
const customNode = resolveCustomBaseNode(opts);
if (customNode) {
// check if the custom base binary exists
if (!(await exists(customNode))) {
throw new Error(
`Priovided node executable path "${opts.nodePath}" does not exist`,
`Provided node executable path "${customNode}" does not exist`,
);
}

return opts.nodePath;
return customNode;
}

if (opts.useLocalNode) {
Expand Down Expand Up @@ -765,6 +963,7 @@ export async function seaEnhanced(
}

assertSingleTargetMajor(opts.targets);
await assertCustomBaseNodeTarget(opts.targets, opts);

const minTargetMajor = resolveMinTargetMajor(opts.targets);
if (minTargetMajor < 22) {
Expand Down Expand Up @@ -883,6 +1082,7 @@ export async function seaEnhanced(
export default async function sea(entryPoint: string, opts: SeaOptions) {
assertHostSeaNodeVersion();
assertSingleTargetMajor(opts.targets);
await assertCustomBaseNodeTarget(opts.targets, opts);

entryPoint = resolve(process.cwd(), entryPoint);

Expand Down
13 changes: 13 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ export interface PkgOptions {
debug?: boolean;
signature?: boolean;
sea?: boolean;
/**
* SEA mode: path to a base Node binary to embed instead of downloading one
* (overrides the PKG_NODE_PATH env var). Its major version must match the
* target's, and it applies to a single target.
*/
seaNodePath?: string;
}

export interface PackageJson {
Expand Down Expand Up @@ -200,4 +206,11 @@ export interface PkgExecOptions {
noDictionary?: string[];
/** Sign macOS binaries when applicable. Default `true`. */
signature?: boolean;
/**
* SEA mode: path to a base Node binary to embed instead of downloading one
* from nodejs.org. Use to embed a custom build (e.g. one linked against an
* older glibc). Overrides the PKG_NODE_PATH env var. Its major version must
* match the target's, and it applies to a single target.
*/
seaNodePath?: string;
}
6 changes: 6 additions & 0 deletions test/test-95-sea-custom-node/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
'use strict';

// Packaged into a SEA on top of a custom base Node binary (the host's own node,
// supplied via --sea-node-path or PKG_NODE_PATH). Output is asserted by main.js;
// process.pkg confirms the enhanced SEA bootstrap is active.
console.log('custom-node SEA OK:' + (process.pkg != null));
Loading