diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f697731..0104ae70 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -19,9 +19,11 @@ flowchart TD T4 --> TB["Patched Node.js binary
with custom VFS"] S1 --> S2["Asset blob + manifest"] - S2 --> S3["node --experimental-sea-config"] - S3 --> S4["postject inject"] - S4 --> SB["Stock Node.js binary
with NODE_SEA_BLOB"] + S2 --> S3{"generator Node
>= 25.5?"} + S3 -- ">= 25.5" --> S3a["node --build-sea
(in-core LIEF)"] + S3 -- "< 25.5" --> S3b["node --experimental-sea-config
+ postject inject"] + S3a --> SB["Node.js binary
with NODE_SEA_BLOB"] + S3b --> SB style P stroke:#e89b2c,stroke-width:2px style TB stroke:#ec7a96,stroke-width:2px @@ -204,17 +206,20 @@ flowchart TD SEA[sea.ts seaEnhanced] CONFIG[Build sea-config.json] BOOT[sea-bootstrap.bundle.js
pre-bundled by esbuild] + INJ{generator Node >= 25.5?} + BUILDSEA[node --build-sea
generate blob + inject in-core] BLOB[node --experimental-sea-config
produces prep blob] - INJ[postject
inject NODE_SEA_BLOB] - OUT[Stock Node.js executable
+ NODE_SEA_BLOB resource] + POST[postject
inject NODE_SEA_BLOB] + OUT[Node.js executable
+ NODE_SEA_BLOB resource] CLI --> DETECT DETECT -- enhanced --> WALK - DETECT -- simple single-file --> BLOB + DETECT -- simple single-file --> CONFIG WALK --> ASSETS --> SEA - SEA --> CONFIG --> BLOB - SEA --> BOOT --> BLOB - BLOB --> INJ --> OUT + SEA --> CONFIG --> INJ + SEA --> BOOT --> CONFIG + INJ -- ">= 25.5" --> BUILDSEA --> OUT + INJ -- "< 25.5" --> BLOB --> POST --> OUT style CLI stroke:#e89b2c,stroke-width:2px style OUT stroke:#66bb6a,stroke-width:2px @@ -256,18 +261,38 @@ CLI (lib/index.ts) ├─ Pick blob generator binary (host/target major): │ host major === target major → process.execPath │ otherwise → downloaded target binary - ├─ Generate blob: - │ node --experimental-sea-config sea-config.json - │ (--build-sea is intentionally NOT used — it produces a - │ finished executable and bypasses the prep-blob + postject - │ flow that pkg needs for multi-target injection) - ├─ For each target: - │ 1. Download Node.js binary (getNodejsExecutable) - │ 2. Inject blob via postject (bake) - │ 3. Sign macOS if needed (signMacOSIfNeeded) + ├─ injectAllTargets() — choose injection path by generator version: + │ + │ • Generator Node >= 25.5 → in-core `node --build-sea`: + │ For each target, write a per-target config adding + │ { executable: , output: } + │ then run `node --build-sea config.json`. Node generates the + │ blob AND injects it (its bundled, current LIEF) in one step. + │ `executable` lets us point at any per-target base binary, and + │ assertSingleTargetMajor() keeps every target on the generator's + │ major, so the generated blob is valid for all of them — i.e. the + │ multi-target concern that previously ruled --build-sea out does + │ not actually apply. This is preferred because Node's bundled LIEF + │ is current; postject's vendored LIEF (0.13) corrupts the dynamic + │ symbol table of PIE/ET_DYN bases and breaks native addons. + │ + │ • Generator Node < 25.5 → classic prep-blob + postject: + │ 1. node --experimental-sea-config sea-config.json (one blob) + │ 2. For each target: copy base binary, inject blob via postject + │ (bake), sign macOS if needed. └─ Cleanup tmpDir ``` +> **Note on `--build-sea`:** earlier versions of pkg deliberately avoided +> `node --build-sea`, on the assumption that it produced a finished executable +> from the _running_ Node and so couldn't serve pkg's prep-blob + per-target +> injection model. That turns out not to hold: `--build-sea` reads the base +> binary from the config's `executable` field (defaulting to the running Node +> only when omitted) and writes to `output`, so a single host-runnable Node +> +> > = 25.5 can inject into each target's base binary directly. pkg now uses it +> > whenever the generator supports it, and falls back to postject otherwise. + ### SEA Binary Format The SEA executable uses the official Node.js resource format: @@ -277,7 +302,7 @@ The SEA executable uses the official Node.js resource format: │ Node.js binary │ │ with NODE_SEA_FUSE activated │ ← Sentinel fuse flipped ├──────────────────────────────────┤ -│ NODE_SEA_BLOB resource: │ ← Injected via postject +│ NODE_SEA_BLOB resource: │ ← Injected via node --build-sea (>=25.5) or postject │ ┌──────────────────────────┐ │ │ │ main: sea-bootstrap.js │ │ ← Bundled bootstrap + VFS polyfill │ ├──────────────────────────┤ │ @@ -575,13 +600,14 @@ For users who require code protection with SEA mode: ### Current (April 2026) -| Dependency | Purpose | Status | -| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node:sea` API | Archive blob and manifest storage/retrieval in SEA executables | Stable, Node 20+ (pkg requires 22+, aligned with `engines.node`) | -| `@roberts_lando/vfs` | VFS polyfill — patches `fs`, `fs/promises`, and module loader | Published, Node 22+, maintained by Matteo Collina | -| `postject` | Injects `NODE_SEA_BLOB` resource into executables | Stable, used by Node.js project | -| `--experimental-sea-config` | Generates the prep blob consumed by postject | Stable, Node 22+. Used on every target — `--build-sea` is intentionally NOT used because it produces a finished executable and bypasses the prep-blob + postject flow pkg needs for multi-target injection | -| `mainFormat: "module"` | Native ESM SEA main in sea-config | Not used. Node 25.5+'s embedder `importModuleDynamicallyForEmbedder` callback only resolves builtins ([nodejs/node#62726](https://github.com/nodejs/node/issues/62726)), so a native ESM main cannot import the user entry | +| Dependency | Purpose | Status | +| --------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node:sea` API | Archive blob and manifest storage/retrieval in SEA executables | Stable, Node 20+ (pkg requires 22+, aligned with `engines.node`) | +| `@roberts_lando/vfs` | VFS polyfill — patches `fs`, `fs/promises`, and module loader | Published, Node 22+, maintained by Matteo Collina | +| `postject` | Injects `NODE_SEA_BLOB` resource into executables | Stable. Fallback injector when the generator Node is < 25.5. NB: postject's vendored LIEF (0.13) corrupts the dynamic symbol table of PIE/ET_DYN base binaries, breaking native addons — fixed in LIEF 0.16.7 / Node's in-core `--build-sea` | +| `--experimental-sea-config` | Generates the prep blob (postject fallback path only) | Stable, Node 22+. Used when the generator Node is < 25.5 | +| `--build-sea` | Generates the blob **and** injects it in-core (current LIEF) | Node 25.5+. Preferred path: reads the base binary from the config `executable` field and writes `output`, so one host-runnable Node injects each target's base directly | +| `mainFormat: "module"` | Native ESM SEA main in sea-config | Not used. Node 25.5+'s embedder `importModuleDynamicallyForEmbedder` callback only resolves builtins ([nodejs/node#62726](https://github.com/nodejs/node/issues/62726)), so a native ESM main cannot import the user entry | ### Future diff --git a/lib/config.ts b/lib/config.ts index 716febe0..2287adc1 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -138,6 +138,25 @@ 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). The binary's major version must match the target's. + cli: 'sea-node-path', + cfg: 'seaNodePath', + resolved: 'seaNodePath', + kind: 'string', + }, + { + // SEA mode: use the Node binary currently running pkg (process.execPath) + // as the base. Shorthand for `--sea-node-path "$(command -v node)"`. + cli: 'sea-use-local-node', + cfg: 'seaUseLocalNode', + resolved: 'seaUseLocalNode', + kind: 'bool', + default: false, + }, { cli: 'compress', cfg: 'compress', @@ -485,6 +504,10 @@ export interface ResolvedFlags { fallbackToSource: boolean; public: boolean; sea: boolean; + /** SEA mode: path to a base Node binary to embed (overrides the download). */ + seaNodePath: string | undefined; + /** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */ + seaUseLocalNode: boolean; publicPackages: string[] | undefined; noDictionary: string[] | undefined; bakeOptions: string[] | undefined; diff --git a/lib/help.ts b/lib/help.ts index 31f0e613..3886b7b1 100644 --- a/lib/help.ts +++ b/lib/help.ts @@ -26,10 +26,13 @@ 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 (must match the target's major version) + --sea-use-local-node SEA mode: embed the Node binary running pkg (process.execPath) as the base 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, + sea-use-local-node) can also be set in the pkg config file (camelCase keys). CLI flags + override config values. ${pc.dim('Examples:')} diff --git a/lib/index.ts b/lib/index.ts index 4b224def..e4287430 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -192,6 +192,18 @@ export async function exec( } if (flags.sea) { + // Base-node override (both forms are mutually exclusive). nodePath embeds a + // specific binary; useLocalNode embeds the Node running pkg. Either lets you + // ship a SEA built on a custom runtime (e.g. one linked against older glibc). + if (flags.seaNodePath && flags.seaUseLocalNode) { + throw wasReported( + "Specify either '--sea-node-path' or '--sea-use-local-node', not both", + ); + } + const seaBase = { + nodePath: flags.seaNodePath, + useLocalNode: flags.seaUseLocalNode, + }; if (inputJson || configJson) { // Enhanced SEA mode — use walker pipeline. // seaEnhanced validates the host Node version and minTargetMajor itself. @@ -202,6 +214,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. @@ -216,6 +229,7 @@ export async function exec( await sea(inputFin, { targets, signature: flags.signature, + ...seaBase, }); } return; diff --git a/lib/sea.ts b/lib/sea.ts index a2e976b2..cf369f85 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -721,12 +721,13 @@ async function pickBlobGeneratorBinary( } /** - * Generate the SEA prep blob from a sea-config.json file. + * Generate the SEA prep blob from a sea-config.json file (the postject + * fallback path, used when the generator Node is older than 25.5). * - * Uses --experimental-sea-config (not --build-sea): --build-sea produces - * a finished executable and bypasses the prep-blob + postject flow that - * pkg relies on for multi-target support and for injecting custom - * bootstraps into downloaded node binaries. + * Uses --experimental-sea-config, which only produces the prep blob; the + * separate postject inject step then bakes it into each target. Newer Node + * (>= 25.5) skips this and uses the in-core `--build-sea` instead -- see + * {@link injectAllTargets}. */ async function generateSeaBlob( seaConfigFilePath: string, @@ -739,6 +740,99 @@ async function generateSeaBlob( ]); } +/** + * Whether a Node version string (`vX.Y.Z` or `X.Y.Z`) supports the in-core + * `node --build-sea` flag, which landed in Node v25.5.0. `--build-sea` performs + * the blob generation AND the binary injection in one step using Node's bundled + * (current) LIEF -- so it bypasses the external `postject`, whose older vendored + * LIEF corrupts the dynamic symbol table of PIE (ET_DYN) binaries and breaks + * native-addon symbol resolution in the resulting SEA. + */ +export function supportsBuildSea(version: string): boolean { + const m = /v?(\d+)\.(\d+)\./.exec(version); + if (!m) return false; + const major = Number(m[1]); + const minor = Number(m[2]); + return major > 25 || (major === 25 && minor >= 5); +} + +/** + * Inject the SEA payload into every target binary. + * + * When the host-runnable generator Node supports `--build-sea` (>= 25.5) we use + * it: Node reads the base binary from the config's `executable` field, generates + * the blob, injects it with its bundled LIEF, and writes the finished executable + * to `output` -- no prep-blob + postject round-trip. `assertSingleTargetMajor` + * guarantees all targets share the generator's major, so the generated blob is + * compatible with every target. + * + * Otherwise we fall back to the classic path: generate one prep blob and inject + * it into each target copy with postject. + */ +async function injectAllTargets(args: { + targets: (NodeTarget & Partial)[]; + nodePaths: string[]; + generatorBinary: string; + signature?: boolean; + seaConfig: Record; + seaConfigFilePath: string; + blobPath: string; + tmpDir: string; +}): Promise { + const { + targets, + nodePaths, + generatorBinary, + signature, + seaConfig, + seaConfigFilePath, + blobPath, + tmpDir, + } = args; + + const generatorVersion = ( + await execFileAsync(generatorBinary, ['--version']) + ).stdout.trim(); + + if (supportsBuildSea(generatorVersion)) { + log.info( + `Injecting the blob with "node ${generatorVersion} --build-sea" (in-core LIEF; postject not used)...`, + ); + await Promise.all( + nodePaths.map(async (nodePath, i) => { + const target = targets[i]; + const outPath = resolve(process.cwd(), target.output!); + await mkdir(dirname(outPath), { recursive: true }); + // Per-target config: same payload, but point `executable` at this + // target's base binary and `output` at the finished executable. + const cfgPath = join(tmpDir, `sea-config-build-${i}.json`); + await writeFile( + cfgPath, + JSON.stringify({ + ...seaConfig, + executable: nodePath, + output: outPath, + }), + ); + await execFileAsync(generatorBinary, ['--build-sea', cfgPath]); + await signMacOSIfNeeded(target.output!, target, signature, true); + }), + ); + return; + } + + await generateSeaBlob(seaConfigFilePath, generatorBinary); + // Read the blob once and share the buffer across all targets. + const blobData = await readFile(blobPath); + await Promise.all( + nodePaths.map(async (nodePath, i) => { + const target = targets[i]; + await bake(nodePath, target, blobData); + await signMacOSIfNeeded(target.output!, target, signature, true); + }), + ); +} + /** Create NodeJS executable using the enhanced SEA pipeline (walker + refiner + assets) */ export async function seaEnhanced( entryPoint: string, @@ -859,23 +953,21 @@ export async function seaEnhanced( log.info('Creating sea-config.json file...'); await writeFile(seaConfigFilePath, JSON.stringify(seaConfig)); - await generateSeaBlob( - seaConfigFilePath, - await pickBlobGeneratorBinary(opts.targets, nodePaths, opts), - ); - - // Read the blob once and share the buffer across all targets — avoids - // N redundant disk reads and N peak buffer copies on multi-target builds. - const blobData = await readFile(blobPath); - - // Bake blob into each target executable - await Promise.all( - nodePaths.map(async (nodePath, i) => { - const target = opts.targets[i]; - await bake(nodePath, target, blobData); - await signMacOSIfNeeded(target.output!, target, opts.signature, true); - }), + const generatorBinary = await pickBlobGeneratorBinary( + opts.targets, + nodePaths, + opts, ); + await injectAllTargets({ + targets: opts.targets, + nodePaths, + generatorBinary, + signature: opts.signature, + seaConfig, + seaConfigFilePath, + blobPath, + tmpDir, + }); }); } @@ -910,19 +1002,20 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { log.info('Creating sea-config.json file...'); await writeFile(seaConfigFilePath, JSON.stringify(seaConfig)); - await generateSeaBlob( - seaConfigFilePath, - await pickBlobGeneratorBinary(opts.targets, nodePaths, opts), - ); - - const blobData = await readFile(blobPath); - - await Promise.all( - nodePaths.map(async (nodePath, i) => { - const target = opts.targets[i]; - await bake(nodePath, target, blobData); - await signMacOSIfNeeded(target.output!, target, opts.signature, true); - }), + const generatorBinary = await pickBlobGeneratorBinary( + opts.targets, + nodePaths, + opts, ); + await injectAllTargets({ + targets: opts.targets, + nodePaths, + generatorBinary, + signature: opts.signature, + seaConfig, + seaConfigFilePath, + blobPath, + tmpDir, + }); }); } diff --git a/lib/types.ts b/lib/types.ts index 8f1121f6..c1fcf798 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -66,6 +66,10 @@ export interface PkgOptions { debug?: boolean; signature?: boolean; sea?: boolean; + /** SEA mode: path to a base Node binary to embed (overrides the download). */ + seaNodePath?: string; + /** SEA mode: embed the Node binary running pkg (process.execPath) as the base. */ + seaUseLocalNode?: boolean; } export interface PackageJson { @@ -200,4 +204,12 @@ 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). Its major version must match the target's. + */ + seaNodePath?: string; + /** SEA mode: embed the Node binary running pkg (`process.execPath`) as the base. */ + seaUseLocalNode?: boolean; } diff --git a/test/test-95-sea-local-node/index.js b/test/test-95-sea-local-node/index.js new file mode 100644 index 00000000..d600887a --- /dev/null +++ b/test/test-95-sea-local-node/index.js @@ -0,0 +1,6 @@ +'use strict'; + +// Packaged into a SEA with `--sea-use-local-node`, so the embedded runtime is +// the same Node that ran pkg (rather than a downloaded one). Output is asserted +// by main.js. process.pkg confirms the enhanced SEA bootstrap is active. +console.log('local-node SEA OK:' + (process.pkg != null)); diff --git a/test/test-95-sea-local-node/main.js b/test/test-95-sea-local-node/main.js new file mode 100644 index 00000000..fee76f57 --- /dev/null +++ b/test/test-95-sea-local-node/main.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const utils = require('../utils.js'); + +// Enhanced SEA requires Node.js >= 22 +if (utils.getNodeMajorVersion() < 22) { + return; +} + +assert(__dirname === process.cwd()); + +const input = './package.json'; +const testName = 'test-95-sea-local-node'; + +const newcomers = utils.seaHostOutputs(testName); + +const before = utils.filesBefore(newcomers); + +// `--sea-use-local-node` embeds the Node binary running pkg as the SEA base +// instead of downloading one. Exercises the base-node override end to end (and, +// on Node >= 25.5 hosts, the in-core `--build-sea` injection path). +utils.runSeaHostOnly(input, testName, ['--sea-use-local-node']); + +utils.assertSeaOutput(testName, 'local-node SEA OK:true\n'); + +utils.filesAfter(before, newcomers, { tolerateWindowsEbusy: true }); diff --git a/test/test-95-sea-local-node/package.json b/test/test-95-sea-local-node/package.json new file mode 100644 index 00000000..4d8cec14 --- /dev/null +++ b/test/test-95-sea-local-node/package.json @@ -0,0 +1,6 @@ +{ + "name": "test-95-sea-local-node", + "version": "1.0.0", + "main": "index.js", + "bin": "index.js" +} diff --git a/test/test.js b/test/test.js index 88c701af..95532c79 100644 --- a/test/test.js +++ b/test/test.js @@ -84,6 +84,7 @@ const npmTests = [ 'test-91-sea-esm-entry', 'test-92-sea-tla', 'test-94-sea-esm-import-meta', + 'test-95-sea-local-node', ]; if (testFilter) { diff --git a/test/unit/config-parse.test.ts b/test/unit/config-parse.test.ts index 49b02293..09e19b8c 100644 --- a/test/unit/config-parse.test.ts +++ b/test/unit/config-parse.test.ts @@ -165,6 +165,24 @@ describe('parseInput — CLI argv', () => { it('--options "" preserves the explicit empty signal', () => { assert.equal(parseInput(['--options', '', 'a.js']).flags.options, ''); }); + + it('string flag --sea-node-path (kebab key preserved)', () => { + assert.equal( + parseInput(['--sea-node-path', '/opt/node/bin/node', 'a.js']).flags[ + 'sea-node-path' + ], + '/opt/node/bin/node', + ); + }); + + it('bool flag --sea-use-local-node (kebab key preserved)', () => { + assert.equal( + parseInput(['--sea-use-local-node', 'a.js']).flags[ + 'sea-use-local-node' + ], + true, + ); + }); }); describe('negation', () => { @@ -410,6 +428,25 @@ describe('resolveFlags — CLI > config > default', () => { assert.equal(f.bytecode, false); }); + it('SEA base-node override — defaults, config, CLI precedence', () => { + const def = resolveFlags({}, {}); + assert.equal(def.seaNodePath, undefined); + assert.equal(def.seaUseLocalNode, false); + + // config keys (cfg name) resolve when CLI is absent + const cfg = resolveFlags({}, { seaNodePath: '/n', seaUseLocalNode: true }); + assert.equal(cfg.seaNodePath, '/n'); + assert.equal(cfg.seaUseLocalNode, true); + + // CLI (kebab cli name) overrides config + const cli = resolveFlags( + { 'sea-node-path': '/cli', 'sea-use-local-node': true }, + { seaNodePath: '/cfg' }, + ); + assert.equal(cli.seaNodePath, '/cli'); + assert.equal(cli.seaUseLocalNode, true); + }); + describe('list handling', () => { it('CLI "" clears the configured list (empty wins)', () => { const f = resolveFlags({ options: '' }, { options: ['expose-gc'] }); diff --git a/test/unit/sea-build-sea.test.ts b/test/unit/sea-build-sea.test.ts new file mode 100644 index 00000000..e1b9c8cc --- /dev/null +++ b/test/unit/sea-build-sea.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { supportsBuildSea } from '../../lib/sea'; + +// `node --build-sea` (in-core SEA blob injection via Node's bundled, current +// LIEF) landed in Node v25.5.0. pkg prefers it over the external postject when +// the generator Node is new enough — postject's old vendored LIEF corrupts the +// dynamic symbol table of PIE binaries, breaking native addons in the SEA. This +// suite pins the version boundary; the full --build-sea path is covered by the +// SEA e2e tests when run on a >= 25.5 host. +describe('supportsBuildSea', () => { + it('is false below 25.5', () => { + assert.equal(supportsBuildSea('v24.16.0'), false); + assert.equal(supportsBuildSea('v25.0.0'), false); + assert.equal(supportsBuildSea('v25.4.9'), false); + }); + + it('is true at the 25.5.0 boundary and above', () => { + assert.equal(supportsBuildSea('v25.5.0'), true); + assert.equal(supportsBuildSea('v25.6.0'), true); + assert.equal(supportsBuildSea('v26.3.0'), true); + assert.equal(supportsBuildSea('v30.0.0'), true); + }); + + it('accepts versions with or without a leading "v"', () => { + assert.equal(supportsBuildSea('25.5.0'), true); + assert.equal(supportsBuildSea('24.16.0'), false); + }); + + it('is false for unparseable input', () => { + assert.equal(supportsBuildSea(''), false); + assert.equal(supportsBuildSea('not-a-version'), false); + }); +});