Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 **runs the binary** and checks what it reports — `process.platform` and `process.arch` must match the target (a macOS binary for a `linux` target, or an x64 binary for an `arm64` target, is rejected), and its major version must match the target's. This means **the custom base binary must be runnable on the build host** (pkg already runs it to read its version). 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 all report `process.platform` `linux` but clearly can't all be meant simultaneously). The glibc/musl/static flavor isn't reported by Node, 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
146 changes: 138 additions & 8 deletions lib/sea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,133 @@ 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;
}

/** Target-suffix -> the value Node reports as `process.platform`. */
const PROCESS_PLATFORM: Record<string, string> = {

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.

[Major] · Correctness

The comment claims alpine/linuxstatic "report their own name verbatim" — they don't: a musl/static Node binary reports process.platform === 'linux' ('alpine'/'linuxstatic' are pkg-fetch concepts, not Node process.platform values). So a single alpine/linuxstatic target hits expectedPlatform = 'alpine' (line 431) vs node.platform = 'linux' and is falsely rejected.

Why: the PR's own docs promise a single alpine/linuxstatic target works ("matching that to linux/alpine/linuxstatic remains your responsibility") — exactly the musl / old-glibc custom-runtime case this PR exists for. The guard runs before any getNodeOs, so nothing upstream filters it; a legit musl-on-musl build dies on a contradictory error.

Fix: map alpinelinux and linuxstaticlinux here. Cleanest as one total map over knownPlatforms so a newly-added target can't silently fall through ?? platform again.

macos: 'darwin',
win: 'win32',
// linux / alpine / linuxstatic / freebsd report their own name verbatim, so
// they need no mapping. (ELF can't reveal the glibc/musl/static flavor
// either, so that sub-distinction stays the user's responsibility.)
};

/** What a Node binary reports about itself when run. */
async function probeNode(

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.

[Minor] · Robustness

Returning platform/arch is justified — but narrower than it looks. arch is genuinely valuable (Rosetta: an arm64 host runs an x64 node, reports x64, catching an arm64-target mismatch that would otherwise embed the wrong binary). platform: since the binary must be runnable to probe, a successful probe necessarily reports the host's platform, so this effectively enforces "target platform == host platform" — reachable mainly via the useLocalNode/execPath short-circuit. For a genuinely foreign, non-runnable binary, execFileAsync throws ENOEXEC here before the friendly platform/arch error can fire.

Fix: keep the probe (it's free — you already exec for the version), but wrap the exec failure with context: "couldn't run the custom base Node on this host; it must be runnable here to validate platform/arch/version (got …)." Turns a cryptic ENOEXEC into a clear message.

binPath: string,
): Promise<{ version: string; platform: string; arch: string }> {
if (binPath === process.execPath) {
return {
version: process.version,
platform: process.platform,
arch: process.arch,
};
}
const { stdout } = await execFileAsync(binPath, [
'-p',
"process.version + ' ' + process.platform + ' ' + process.arch",
]);
const [version, platform, arch] = stdout.trim().split(' ');
return { version, platform, arch };
}

/**
* 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. Run the binary and reject if what it reports (`process.platform` /
* `process.arch`) disagrees with that single target (e.g. a macOS binary
* for a `linux` target, or x64 for `arm64`). We exec it for its version
* regardless (here and in {@link resolveTargetNodeVersion}), so asking it
* everything at once is simpler and stricter than sniffing its header — at
* the cost of requiring the base to be runnable on the build host, which is
* already true in practice. The glibc/musl/static flavor isn't reported, 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. Ask the binary what it actually is and reject any disagreement.
const node = await probeNode(binPath);

const expectedPlatform = PROCESS_PLATFORM[platform] ?? platform;
if (node.platform !== expectedPlatform) {
throw wasReported(
`Custom base Node binary is for "${node.platform}", but target ` +
`"${platform}" needs "${expectedPlatform}".`,
);
}
if (node.arch !== arch) {

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.

[Major] · Correctness

Same defect as the platform map, but arch has no mapping at all. pkg's armv7l target vs Node's process.arch === 'arm''arm' !== 'armv7l' → false reject of a single armv7l target with a matching custom node.

Why: blocks a valid 32-bit-ARM custom-runtime build with a confusing error.

Fix: add an arch map (armv7larm) alongside the platform one, total over knownArchs.

throw wasReported(
`Custom base Node binary is ${node.arch}, but target arch is "${arch}". ` +
`The binary must match the target's architecture.`,
);
}

// 3. Major version match (assertSingleTargetMajor only compares the targets
// to each other, never to the supplied binary).
const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10);
if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare
const binMajor = parseInt(node.version.replace(/^v/, ''), 10);
if (binMajor !== targetMajor) {
throw wasReported(
`Custom base Node binary is ${node.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,11 +468,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']);
return stdout.trim() as NodeVersion;
return (await probeNode(customNode)).version as NodeVersion;

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.

[FYI] · Performance

probeNode runs twice per build — once in assertCustomBaseNodeTarget, once here — each a child-process spawn of the custom node. Negligible for a build, but trivially memoizable if desired.

}
const os = getNodeOs(target.platform);
const arch = getNodeArch(target.arch);
Expand All @@ -357,15 +484,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 +893,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 +1012,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
Loading