From 408dfde5e8a69eb1b4a487de4c0700ed86070404 Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Wed, 10 Jun 2026 18:30:05 -0500 Subject: [PATCH 1/2] feat(sea): let users override the base Node binary SEA mode previously always downloaded a base Node binary from nodejs.org, so the resulting executable was pinned to an official build. That makes it impossible to ship a SEA on top of a custom runtime -- e.g. a Node built against an older glibc (to run on EL7 / older distros), or one configured differently. The internal SeaOptions / SeaEnhancedOptions already accepted `nodePath` / `useLocalNode`; this just exposes them: --sea-node-path embed a specific Node binary as the base --sea-use-local-node embed the Node running pkg (process.execPath) Both are also settable in the pkg config (seaNodePath / seaUseLocalNode) and via the programmatic exec() API. The two are mutually exclusive. The embedded binary's major version must match the target's (the existing version-skew checks still apply). This is also a prerequisite for adopting `node --build-sea` (Node >= 25.5): that flag is only meaningful once the base Node is no longer a fixed download. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/config.ts | 23 +++++++++++++++ lib/help.ts | 7 +++-- lib/index.ts | 14 +++++++++ lib/types.ts | 12 ++++++++ test/test-95-sea-local-node/index.js | 6 ++++ test/test-95-sea-local-node/main.js | 29 +++++++++++++++++++ test/test-95-sea-local-node/package.json | 6 ++++ test/test.js | 1 + test/unit/config-parse.test.ts | 37 ++++++++++++++++++++++++ 9 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 test/test-95-sea-local-node/index.js create mode 100644 test/test-95-sea-local-node/main.js create mode 100644 test/test-95-sea-local-node/package.json 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/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'] }); From 7e5634fd82414db822e9aa531b62cacfe939ffae Mon Sep 17 00:00:00 2001 From: Charles Strahan Date: Wed, 10 Jun 2026 19:38:03 -0500 Subject: [PATCH 2/2] feat(sea): use `node --build-sea` when the base Node supports it (>= 25.5) Node v25.5.0 added an in-core `node --build-sea ` that generates the SEA blob and injects it into a base binary (config `executable` -> `output`) in one step, using Node's *bundled, current* LIEF. That avoids the external `postject`, whose 3-year-old vendored LIEF (0.13.0) corrupts the dynamic symbol table of PIE (ET_DYN) binaries -- which silently breaks native (raw-V8) addons in the resulting SEA (`undefined symbol: _ZN2v8...`). The corruption was fixed upstream in LIEF 0.16.7; Node >= 25.5 ships a fixed LIEF. When the host-runnable generator Node reports >= 25.5, both SEA paths (enhanced and simple) inject via `--build-sea` instead of generating a prep blob and shelling out to postject. `assertSingleTargetMajor` guarantees every target shares the generator's major, so the generated blob is compatible across targets -- the multi-target concern that previously ruled `--build-sea` out does not actually apply (it reads the base from the config `executable` field, not the running binary). Older generators keep the postject path unchanged. Combined with --sea-node-path / --sea-use-local-node, this lets a custom Node build (e.g. one linked against an older glibc) produce SEAs with working native addons and no external injector. ARCHITECTURE.md is updated to reflect the new path and to correct the prior claim that `--build-sea` could not serve pkg. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ARCHITECTURE.md | 78 ++++++++++------ lib/sea.ts | 161 +++++++++++++++++++++++++------- test/unit/sea-build-sea.test.ts | 35 +++++++ 3 files changed, 214 insertions(+), 60 deletions(-) create mode 100644 test/unit/sea-build-sea.test.ts 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/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/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); + }); +});