From 3d932d05e54ec2fdeb67ba51a410d7dd49ce3a0e Mon Sep 17 00:00:00 2001 From: robertsLando Date: Fri, 17 Apr 2026 10:48:15 +0200 Subject: [PATCH 01/13] fix(sea): skip mach-O __LINKEDIT patch for SEA binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic pkg flow appends the VFS payload to the end of the binary and uses patchMachOExecutable to extend __LINKEDIT so codesign hashes cover the payload. For SEA, postject creates a dedicated NODE_SEA segment — patching __LINKEDIT on top of that corrupts the SEA blob on macOS arm64 once the payload is non-trivial (reported for NestJS apps in discussion #236, 9 MB enhanced-SEA blob → segfault at LoadSingleExecutableApplication). Split the ad-hoc-sign path used by SEA into signMacOSSeaIfNeeded, which matches the Node.js SEA docs: just codesign, no LINKEDIT patch. signMacOSIfNeeded still patches for the non-SEA producer path. Refs: https://github.com/yao-pkg/pkg/discussions/236 --- lib/sea.ts | 62 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index fab44034..6c5c2746 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -363,7 +363,25 @@ async function bake( }); } -/** Patch and sign macOS executable if needed */ +const UNSIGNED_MACOS_ARM64_WARNING = [ + 'Due to the mandatory code signing requirement, before the', + 'executable is distributed to end users, it must be signed.', + 'Otherwise, it will be immediately killed by kernel on launch.', + 'An ad-hoc signature is sufficient.', + 'To do that, run pkg on a Mac, or transfer the executable to a Mac', + 'and run "codesign --sign - ", or (if you use Linux)', + 'install "ldid" utility to PATH and then run pkg again', +]; + +/** + * Patch mach-O __LINKEDIT + ad-hoc sign. Used by pkg's non-SEA mode where the + * VFS payload is appended to the end of the binary: the patch extends + * __LINKEDIT to cover the payload so codesign includes it in the hash. + * + * Do NOT use for SEA — postject creates a dedicated NODE_SEA segment and + * patching __LINKEDIT on top of that has been observed to corrupt the SEA + * blob on macOS arm64 for non-trivial payloads (NestJS, etc.). + */ export async function signMacOSIfNeeded( output: string, target: NodeTarget & Partial, @@ -377,15 +395,35 @@ export async function signMacOSIfNeeded( signMachOExecutable(output); } catch { if (target.arch === 'arm64') { - log.warn('Unable to sign the macOS executable', [ - 'Due to the mandatory code signing requirement, before the', - 'executable is distributed to end users, it must be signed.', - 'Otherwise, it will be immediately killed by kernel on launch.', - 'An ad-hoc signature is sufficient.', - 'To do that, run pkg on a Mac, or transfer the executable to a Mac', - 'and run "codesign --sign - ", or (if you use Linux)', - 'install "ldid" utility to PATH and then run pkg again', - ]); + log.warn( + 'Unable to sign the macOS executable', + UNSIGNED_MACOS_ARM64_WARNING, + ); + } + } +} + +/** + * Ad-hoc sign a macOS SEA binary. Unlike {@link signMacOSIfNeeded}, this + * does NOT touch __LINKEDIT — postject already produces a valid mach-O + * layout with its own NODE_SEA segment (see Node.js SEA docs), so the only + * step left is the codesign call to satisfy the arm64 signing requirement. + */ +async function signMacOSSeaIfNeeded( + output: string, + target: NodeTarget & Partial, + signature?: boolean, +) { + if (!signature || target.platform !== 'macos') return; + + try { + signMachOExecutable(output); + } catch { + if (target.arch === 'arm64') { + log.warn( + 'Unable to sign the macOS executable', + UNSIGNED_MACOS_ARM64_WARNING, + ); } } } @@ -664,7 +702,7 @@ export async function seaEnhanced( nodePaths.map(async (nodePath, i) => { const target = opts.targets[i]; await bake(nodePath, target, blobData); - await signMacOSIfNeeded(target.output!, target, opts.signature); + await signMacOSSeaIfNeeded(target.output!, target, opts.signature); }), ); }); @@ -712,7 +750,7 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { nodePaths.map(async (nodePath, i) => { const target = opts.targets[i]; await bake(nodePath, target, blobData); - await signMacOSIfNeeded(target.output!, target, opts.signature); + await signMacOSSeaIfNeeded(target.output!, target, opts.signature); }), ); }); From 2952a911e4551bdf4c62fb7566addbc75c700c87 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Fri, 17 Apr 2026 11:10:06 +0200 Subject: [PATCH 02/13] refactor(sea): collapse signMacOS{,Sea}IfNeeded behind isSea flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #247 — a dedicated second function was more code than the branch deserved. Replace it with a single isSea parameter that skips the __LINKEDIT patch when true. Call sites read clearer too. --- lib/sea.ts | 73 +++++++++++++++++++----------------------------------- 1 file changed, 26 insertions(+), 47 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 6c5c2746..5edcdd11 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -363,67 +363,46 @@ async function bake( }); } -const UNSIGNED_MACOS_ARM64_WARNING = [ - 'Due to the mandatory code signing requirement, before the', - 'executable is distributed to end users, it must be signed.', - 'Otherwise, it will be immediately killed by kernel on launch.', - 'An ad-hoc signature is sufficient.', - 'To do that, run pkg on a Mac, or transfer the executable to a Mac', - 'and run "codesign --sign - ", or (if you use Linux)', - 'install "ldid" utility to PATH and then run pkg again', -]; - /** - * Patch mach-O __LINKEDIT + ad-hoc sign. Used by pkg's non-SEA mode where the - * VFS payload is appended to the end of the binary: the patch extends - * __LINKEDIT to cover the payload so codesign includes it in the hash. + * Patch mach-O __LINKEDIT (non-SEA only) and ad-hoc sign the binary. + * + * The __LINKEDIT patch exists for the classic pkg flow: pkg appends the + * VFS payload to the end of the binary, and codesign only hashes content + * covered by __LINKEDIT — so the segment must be extended to include the + * payload before signing. * - * Do NOT use for SEA — postject creates a dedicated NODE_SEA segment and - * patching __LINKEDIT on top of that has been observed to corrupt the SEA - * blob on macOS arm64 for non-trivial payloads (NestJS, etc.). + * Pass `isSea: true` to skip the patch. For SEA, postject creates a + * dedicated NODE_SEA segment with a proper LC_SEGMENT_64 (per the + * Node.js SEA docs), so __LINKEDIT doesn't need to grow. Patching it + * anyway has been observed to corrupt the SEA blob on macOS arm64 for + * non-trivial payloads (NestJS — see discussion #236). */ export async function signMacOSIfNeeded( output: string, target: NodeTarget & Partial, signature?: boolean, + isSea?: boolean, ) { if (!signature || target.platform !== 'macos') return; - const buf = patchMachOExecutable(await readFile(output)); - await writeFile(output, buf); - try { - signMachOExecutable(output); - } catch { - if (target.arch === 'arm64') { - log.warn( - 'Unable to sign the macOS executable', - UNSIGNED_MACOS_ARM64_WARNING, - ); - } + if (!isSea) { + const buf = patchMachOExecutable(await readFile(output)); + await writeFile(output, buf); } -} - -/** - * Ad-hoc sign a macOS SEA binary. Unlike {@link signMacOSIfNeeded}, this - * does NOT touch __LINKEDIT — postject already produces a valid mach-O - * layout with its own NODE_SEA segment (see Node.js SEA docs), so the only - * step left is the codesign call to satisfy the arm64 signing requirement. - */ -async function signMacOSSeaIfNeeded( - output: string, - target: NodeTarget & Partial, - signature?: boolean, -) { - if (!signature || target.platform !== 'macos') return; try { signMachOExecutable(output); } catch { if (target.arch === 'arm64') { - log.warn( - 'Unable to sign the macOS executable', - UNSIGNED_MACOS_ARM64_WARNING, - ); + log.warn('Unable to sign the macOS executable', [ + 'Due to the mandatory code signing requirement, before the', + 'executable is distributed to end users, it must be signed.', + 'Otherwise, it will be immediately killed by kernel on launch.', + 'An ad-hoc signature is sufficient.', + 'To do that, run pkg on a Mac, or transfer the executable to a Mac', + 'and run "codesign --sign - ", or (if you use Linux)', + 'install "ldid" utility to PATH and then run pkg again', + ]); } } } @@ -702,7 +681,7 @@ export async function seaEnhanced( nodePaths.map(async (nodePath, i) => { const target = opts.targets[i]; await bake(nodePath, target, blobData); - await signMacOSSeaIfNeeded(target.output!, target, opts.signature); + await signMacOSIfNeeded(target.output!, target, opts.signature, true); }), ); }); @@ -750,7 +729,7 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { nodePaths.map(async (nodePath, i) => { const target = opts.targets[i]; await bake(nodePath, target, blobData); - await signMacOSSeaIfNeeded(target.output!, target, opts.signature); + await signMacOSIfNeeded(target.output!, target, opts.signature, true); }), ); }); From a24854e5bf8e0ea586d211acf5bf63d9e31e5b04 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 20 Apr 2026 14:44:27 +0200 Subject: [PATCH 03/13] fix(sea): drop redundant macOS signature pre-strip codesign -f --sign - in signMacOSIfNeeded already force-replaces the existing signature after postject injection, so stripping it first is redundant. On macOS Tahoe 26.x with non-trivial SEA payloads (NestJS, ~9 MB blob) the pre-strip leaves the Mach-O in a state that crashes Node at load time with "v8::ToLocalChecked Empty MaybeLocal" inside node::sea::LoadSingleExecutableApplication. Cross-host Linux-to-macOS builds never pre-stripped and have been confirmed working on the same payload by the reporter, which pins the regression to this step. Refs: discussion #236 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 68b294b4..31c30047 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -19,11 +19,7 @@ import unzipper from 'unzipper'; import { extract as tarExtract } from 'tar'; import { log, wasReported } from './log'; import { NodeTarget, Target, SeaEnhancedOptions } from './types'; -import { - patchMachOExecutable, - removeMachOExecutableSignature, - signMachOExecutable, -} from './mach-o'; +import { patchMachOExecutable, signMachOExecutable } from './mach-o'; import walk from './walker'; import refine from './refiner'; import { generateSeaAssets } from './sea-assets'; @@ -344,13 +340,16 @@ async function bake( await copyFile(nodePath, outPath); log.info(`Injecting the blob into ${outPath}...`); - if (target.platform === 'macos') { - // codesign is only available on macOS — skip signature removal when - // cross-compiling from another platform - if (process.platform === 'darwin') { - removeMachOExecutableSignature(outPath); - } - } + // Do NOT pre-strip the downloaded node binary's signature on macOS hosts: + // signMacOSIfNeeded → codesign -f --sign - force-replaces the signature + // after postject injection, so the pre-strip is redundant. On macOS + // Tahoe 26.x with non-trivial SEA payloads (e.g. NestJS, ~9 MB), + // `codesign --remove-signature` before postject leaves the Mach-O in a + // state that crashes Node at load time with + // `v8::ToLocalChecked Empty MaybeLocal` inside + // `node::sea::LoadSingleExecutableApplication` (discussion #236). + // Cross-host Linux-to-macOS builds (which never pre-stripped) are + // known-good on the same payload, confirming this is the corrupter. // Use postject JS API directly instead of spawning npx. // This avoids two CI issues: From 6f456e2bd67fd4d525a7fbedd5d8aa4b1ebc76a9 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 21 Apr 2026 14:57:50 +0200 Subject: [PATCH 04/13] fix(sea): pick blob generator by host/target platform match, not major MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SEA prep-blob layout changes between Node patch releases within the same major line (e.g. 22.19/22.20 added fields that break the 22.22 reader), so generating the blob with a host Node whose patch release differs from the downloaded target binary crashes node::sea::FindSingleExecutableResource at startup with EXC_BAD_ACCESS inside BlobDeserializer::ReadArithmetic. Fix: pick a downloaded target binary whose platform+arch matches the host. That binary is the exact version we inject the blob into, so generator and reader are byte-for-byte version-matched. Fall back to process.execPath only when no target is runnable on the host (true cross-platform builds, e.g. Linux → Windows). Reproduced locally: host Node 22.12.0/22.15.0/22.17.1/22.18.0 → target node22-linux-x64 (downloaded 22.22.2) crashed with exit 139 and a stack trace matching the one reported by @julianpoemp on macOS arm64 in discussion #236. After this fix, every one of those host versions produces a working binary. Refs: discussion #236 Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 60 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 31c30047..31083b46 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -498,33 +498,49 @@ function assertSingleTargetMajor( /** * Pick the node binary used to generate the SEA prep blob. * - * The blob layout is node-version specific (e.g. Node 25.8 added an - * exec_argv_extension header field), so it must be generated by a node - * major that matches the target — otherwise the target cannot deserialize - * it. The blob itself is platform/arch-agnostic. + * The blob layout is node-version specific — not just major-version + * specific. Node occasionally changes the SEA header layout within a + * single major line (Node 22.19/22.20 added fields that break the 22.22 + * reader, Node 25.8 added `exec_argv_extension`, etc.), so using a host + * Node whose patch release differs from the downloaded target binary + * crashes `node::sea::FindSingleExecutableResource` at startup with + * `EXC_BAD_ACCESS` inside `BlobDeserializer::ReadArithmetic` — see + * discussion #236. * - * Rule: - * - host major === minTargetMajor → use process.execPath. Always - * executable regardless of target platform/arch, so this is the only - * path that works for cross-platform builds (e.g. Linux x64 host - * producing a Windows x64 SEA). - * - otherwise → use nodePaths[0], the downloaded - * target-platform binary. Matches the target major but requires host - * to be able to execute it (same platform/arch, or QEMU/Rosetta). A - * cross-major + cross-platform build will fail at spawn time — pkg - * has no way to produce a host-platform binary of the target major. + * Strategy: + * 1. Prefer a downloaded target binary whose platform & arch match the + * host. It's guaranteed to be the same version as the one we're + * injecting the blob into, and it's executable on the host — so the + * generator and reader are byte-for-byte version-matched. + * 2. Fall back to `process.execPath` only for true cross-platform + * builds (e.g. Linux host producing Windows SEA) where no downloaded + * target is runnable on the host. Patch-version skew within the + * same major is possible here, but there's no alternative — pkg has + * no way to produce a host-runnable binary of the exact target + * version for a foreign platform. * * All targets share a single node major (enforced by - * {@link assertSingleTargetMajor}), so inspecting only nodePaths[0] is - * sufficient. + * {@link assertSingleTargetMajor}). */ function pickBlobGeneratorBinary( - minTargetMajor: number, + targets: (NodeTarget & Partial)[], nodePaths: string[], ): string { - const hostMajor = parseInt(process.version.slice(1), 10); - if (hostMajor === minTargetMajor) return process.execPath; - return nodePaths[0]; + const hostPlatform = + process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'win' + : process.platform; + for (let i = 0; i < targets.length; i += 1) { + if ( + targets[i].platform === hostPlatform && + targets[i].arch === process.arch + ) { + return nodePaths[i]; + } + } + return process.execPath; } /** @@ -668,7 +684,7 @@ export async function seaEnhanced( await generateSeaBlob( seaConfigFilePath, - pickBlobGeneratorBinary(minTargetMajor, nodePaths), + pickBlobGeneratorBinary(opts.targets, nodePaths), ); // Read the blob once and share the buffer across all targets — avoids @@ -719,7 +735,7 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { await generateSeaBlob( seaConfigFilePath, - pickBlobGeneratorBinary(resolveMinTargetMajor(opts.targets), nodePaths), + pickBlobGeneratorBinary(opts.targets, nodePaths), ); const blobData = await readFile(blobPath); From f9a49b34d856d7869f907fae760ef75ad08de56f Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 21 Apr 2026 15:07:26 +0200 Subject: [PATCH 05/13] refactor(sea): reuse hostPlatform/hostArch from pkg-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pickBlobGeneratorBinary had an inline `process.platform === 'darwin' ? 'macos' : ...` branch and read `process.arch` directly. pkg-fetch's `system` module already exports `hostPlatform` / `hostArch` as the "fancy" values (macos/win/linux) that target descriptors use, and the rest of lib/ consumes them — same translation, one source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 31083b46..dc8f8af1 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -24,6 +24,9 @@ import walk from './walker'; import refine from './refiner'; import { generateSeaAssets } from './sea-assets'; import { inject as postjectInject } from 'postject'; +import { system } from '@yao-pkg/pkg-fetch'; + +const { hostPlatform, hostArch } = system; const execFileAsync = util.promisify(cExecFile); @@ -526,17 +529,8 @@ function pickBlobGeneratorBinary( targets: (NodeTarget & Partial)[], nodePaths: string[], ): string { - const hostPlatform = - process.platform === 'darwin' - ? 'macos' - : process.platform === 'win32' - ? 'win' - : process.platform; for (let i = 0; i < targets.length; i += 1) { - if ( - targets[i].platform === hostPlatform && - targets[i].arch === process.arch - ) { + if (targets[i].platform === hostPlatform && targets[i].arch === hostArch) { return nodePaths[i]; } } From e5ebe7d985004f9fcac1d7532e8744b40cca79df Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 21 Apr 2026 15:10:57 +0200 Subject: [PATCH 06/13] docs(sea): correct inline comments on macOS signing simplifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier commits in this PR (3d932d0 skip __LINKEDIT patch, a24854e drop pre-strip) landed while we believed the macOS arm64 crash in discussion #236 was caused by those steps corrupting the SEA blob. The actual cause turned out to be host/target Node patch-version skew in the blob generator (fixed in 6f456e2), not either of these. The code changes are still valid cleanups — patchMachOExecutable is a no-op on SEA binaries because __LINKEDIT already sits at the file tail, and the codesign pre-strip is redundant because codesign -f --sign - replaces any existing signature — but the inline comments claimed they were fixing corruption bugs that don't exist. Replaced those comments with accurate no-op / redundancy reasoning so future maintainers chasing mach-O or codesign bugs aren't led astray. Code behaviour is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index dc8f8af1..dcf32b5f 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -343,16 +343,10 @@ async function bake( await copyFile(nodePath, outPath); log.info(`Injecting the blob into ${outPath}...`); - // Do NOT pre-strip the downloaded node binary's signature on macOS hosts: - // signMacOSIfNeeded → codesign -f --sign - force-replaces the signature - // after postject injection, so the pre-strip is redundant. On macOS - // Tahoe 26.x with non-trivial SEA payloads (e.g. NestJS, ~9 MB), - // `codesign --remove-signature` before postject leaves the Mach-O in a - // state that crashes Node at load time with - // `v8::ToLocalChecked Empty MaybeLocal` inside - // `node::sea::LoadSingleExecutableApplication` (discussion #236). - // Cross-host Linux-to-macOS builds (which never pre-stripped) are - // known-good on the same payload, confirming this is the corrupter. + // No pre-strip of the downloaded node binary's signature on macOS: + // the final `codesign -f --sign -` in signMacOSIfNeeded force-replaces + // any existing signature after postject injection, so a preliminary + // `codesign --remove-signature` is redundant. // Use postject JS API directly instead of spawning npx. // This avoids two CI issues: @@ -373,11 +367,13 @@ async function bake( * covered by __LINKEDIT — so the segment must be extended to include the * payload before signing. * - * Pass `isSea: true` to skip the patch. For SEA, postject creates a - * dedicated NODE_SEA segment with a proper LC_SEGMENT_64 (per the - * Node.js SEA docs), so __LINKEDIT doesn't need to grow. Patching it - * anyway has been observed to corrupt the SEA blob on macOS arm64 for - * non-trivial payloads (NestJS — see discussion #236). + * Pass `isSea: true` to skip the patch. For SEA binaries postject + * already creates a dedicated NODE_SEA `LC_SEGMENT_64` (per the + * [Node.js SEA docs](https://nodejs.org/api/single-executable-applications.html)) + * and __LINKEDIT already sits at the file tail with + * `filesize = file.length - fileoff`, so the patch is a no-op on the + * resulting Mach-O. The docs call for just `codesign --sign -` after + * postject, which is what `signMachOExecutable` does. */ export async function signMacOSIfNeeded( output: string, From e237716a5522705ed6d372c18e73f360e0bd0534 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 21 Apr 2026 15:15:11 +0200 Subject: [PATCH 07/13] fix(sea): download host-platform node for pure cross-compile blob gen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When no target matches the host platform/arch (e.g. Linux host building only a macos-arm64 binary), the previous fallback to process.execPath re-opened the same SEA blob version-skew footgun that 6f456e2 closed for the host-matching case: if the user's local node is on a different patch release than the downloaded target binary, the generated blob crashes node::sea::FindSingleExecutableResource at startup with EXC_BAD_ACCESS (discussion #236). Now we download a host-platform/arch node binary at the same node range as the targets and use it purely as the blob generator — byte-for-byte version-matched with the reader baked into every target. process.execPath is only used as a last-resort fallback if the download itself fails (unsupported host platform such as alpine/musl, no network, etc.), and that path now emits a warning pointing at the exact symptom. Verified: - Linux host 22.12.0 → macos-arm64-only target: previously would silently bake a skewed blob; now logs "No target matches host" + downloads node-v22.22.2-linux-x64 for generation. NODE_SEA filesize matches the known-good build byte-for-byte. - Host-matching and mixed-target flows still take the fast path with no extra download. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 60 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index dcf32b5f..e024f9ac 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -506,31 +506,61 @@ function assertSingleTargetMajor( * `EXC_BAD_ACCESS` inside `BlobDeserializer::ReadArithmetic` — see * discussion #236. * - * Strategy: + * Strategy (all paths guarantee the generator is the same version as the + * reader, eliminating patch-version skew): + * * 1. Prefer a downloaded target binary whose platform & arch match the - * host. It's guaranteed to be the same version as the one we're - * injecting the blob into, and it's executable on the host — so the - * generator and reader are byte-for-byte version-matched. - * 2. Fall back to `process.execPath` only for true cross-platform - * builds (e.g. Linux host producing Windows SEA) where no downloaded - * target is runnable on the host. Patch-version skew within the - * same major is possible here, but there's no alternative — pkg has - * no way to produce a host-runnable binary of the exact target - * version for a foreign platform. + * host — already downloaded, guaranteed version-matched. + * 2. Otherwise (pure cross-platform build, e.g. Linux host producing + * only a macos-arm64 binary), download a host-platform/arch node + * binary at the same node range as the targets and use it purely + * as the generator. + * 3. Fall back to `process.execPath` only if the host-matching + * download fails (unsupported host platform such as alpine/musl, + * no network, etc.). This path still has the skew risk and emits + * a warning. * * All targets share a single node major (enforced by * {@link assertSingleTargetMajor}). */ -function pickBlobGeneratorBinary( +async function pickBlobGeneratorBinary( targets: (NodeTarget & Partial)[], nodePaths: string[], -): string { + opts: GetNodejsExecutableOptions, +): Promise { for (let i = 0; i < targets.length; i += 1) { if (targets[i].platform === hostPlatform && targets[i].arch === hostArch) { return nodePaths[i]; } } - return process.execPath; + + // No target is runnable on the host. Download a host-platform binary + // at the target's node range so the blob generator and the SEA reader + // baked into each target share the exact same version — otherwise we + // regress into the discussion #236 crash on any host/target patch skew. + log.info( + `No target matches host ${hostPlatform}-${hostArch}; downloading a ` + + `host-platform node binary to generate the SEA blob at the exact ` + + `target version (avoids SEA header version skew — see discussion #236).`, + ); + try { + const hostGeneratorTarget = { + platform: hostPlatform, + arch: hostArch, + nodeRange: targets[0].nodeRange, + } as NodeTarget; + return await getNodejsExecutable(hostGeneratorTarget, opts); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn( + `Could not download a host-platform node for SEA blob generation ` + + `(${msg}); falling back to process.execPath. If the resulting ` + + `binary crashes at startup with EXC_BAD_ACCESS in ` + + `node::sea::FindSingleExecutableResource, pin your local node to ` + + `the same patch release as the target.`, + ); + return process.execPath; + } } /** @@ -674,7 +704,7 @@ export async function seaEnhanced( await generateSeaBlob( seaConfigFilePath, - pickBlobGeneratorBinary(opts.targets, nodePaths), + await pickBlobGeneratorBinary(opts.targets, nodePaths, opts), ); // Read the blob once and share the buffer across all targets — avoids @@ -725,7 +755,7 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { await generateSeaBlob( seaConfigFilePath, - pickBlobGeneratorBinary(opts.targets, nodePaths), + await pickBlobGeneratorBinary(opts.targets, nodePaths, opts), ); const blobData = await readFile(blobPath); From 08b806902b4dce43764a6f65fdd0178cf2ecb2b8 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Tue, 21 Apr 2026 15:55:24 +0200 Subject: [PATCH 08/13] fix(sea): hard-fail blob generator when host/target versions skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the silent process.execPath fallback with wasReported when the host node version differs from the resolved target version — the old fallback silently reintroduced the very EXC_BAD_ACCESS crash this flow is meant to prevent (discussion #236). Add resolveTargetNodeVersion as the single source of truth for the resolved target version (reused by getNodejsExecutable): queries the user-provided binary via --version for opts.nodePath, uses process.version for opts.useLocalNode, and falls back to the public registry lookup otherwise. Extract pickMatchingHostTargetIndex as a testable helper and cover it with test-00-sea-picker (exact match, no match, cross-arch, alpine host, empty targets). Drop the now-unused removeMachOExecutableSignature from lib/mach-o.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/mach-o.ts | 12 +--- lib/sea.ts | 101 +++++++++++++++++++++++++------- test/test-00-sea-picker/main.js | 76 ++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 32 deletions(-) create mode 100644 test/test-00-sea-picker/main.js diff --git a/lib/mach-o.ts b/lib/mach-o.ts index 9993457b..50ee3136 100644 --- a/lib/mach-o.ts +++ b/lib/mach-o.ts @@ -72,14 +72,4 @@ function signMachOExecutable(executable: string) { } } -function removeMachOExecutableSignature(executable: string) { - execFileSync('codesign', ['--remove-signature', executable], { - stdio: 'inherit', - }); -} - -export { - patchMachOExecutable, - removeMachOExecutableSignature, - signMachOExecutable, -}; +export { patchMachOExecutable, signMachOExecutable }; diff --git a/lib/sea.ts b/lib/sea.ts index e024f9ac..000c478a 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -250,6 +250,29 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { return latestVersionAndFiles[0]; } +/** + * Resolve the concrete Node.js version (e.g. `v22.22.2`) pkg will use + * for `target` — mirrors the version selection done inside + * {@link getNodejsExecutable} without performing the download, so + * callers can reason about host/target version skew independently of + * the download itself. + */ +async function resolveTargetNodeVersion( + target: NodeTarget, + opts: GetNodejsExecutableOptions, +): Promise { + if (opts.useLocalNode) return process.version; + if (opts.nodePath) { + // 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(); + } + const os = getNodeOs(target.platform); + const arch = getNodeArch(target.arch); + return getNodeVersion(os, arch, target.nodeRange.replace('node', '')); +} + /** Fetch, validate and extract nodejs binary. Returns a path to it */ async function getNodejsExecutable( target: NodeTarget, @@ -273,11 +296,7 @@ async function getNodejsExecutable( const os = getNodeOs(target.platform); const arch = getNodeArch(target.arch); - const nodeVersion = await getNodeVersion( - os, - arch, - target.nodeRange.replace('node', ''), - ); + const nodeVersion = await resolveTargetNodeVersion(target, opts); const fileName = `node-${nodeVersion}-${os}-${arch}.${os === 'win' ? 'zip' : 'tar.gz'}`; @@ -515,23 +534,45 @@ function assertSingleTargetMajor( * only a macos-arm64 binary), download a host-platform/arch node * binary at the same node range as the targets and use it purely * as the generator. - * 3. Fall back to `process.execPath` only if the host-matching - * download fails (unsupported host platform such as alpine/musl, - * no network, etc.). This path still has the skew risk and emits - * a warning. + * 3. If the host-platform download fails (unsupported host such as + * alpine/musl, offline, checksum mismatch, …), fall back to + * `process.execPath` only when its version exactly matches the + * resolved target version. Otherwise hard-fail — silently running + * the generator with a skewed node would reintroduce the same + * EXC_BAD_ACCESS this function exists to prevent. * * All targets share a single node major (enforced by * {@link assertSingleTargetMajor}). */ +/** + * Index into `targets` of the first entry whose platform+arch match + * `host`, or -1 when no target is runnable on the host. Exported for + * unit testing step 1 of the SEA blob-generator selection without + * spinning up a full pkg invocation. + */ +export function pickMatchingHostTargetIndex( + host: { platform: string; arch: string }, + targets: readonly { platform: string; arch: string }[], +): number { + return targets.findIndex( + (t) => t.platform === host.platform && t.arch === host.arch, + ); +} + async function pickBlobGeneratorBinary( targets: (NodeTarget & Partial)[], nodePaths: string[], opts: GetNodejsExecutableOptions, ): Promise { - for (let i = 0; i < targets.length; i += 1) { - if (targets[i].platform === hostPlatform && targets[i].arch === hostArch) { - return nodePaths[i]; - } + const matchIdx = pickMatchingHostTargetIndex( + { platform: hostPlatform, arch: hostArch }, + targets, + ); + if (matchIdx !== -1) { + log.debug( + `SEA blob generator: host matches ${targets[matchIdx].platform}-${targets[matchIdx].arch} target, reusing its downloaded binary (${nodePaths[matchIdx]}).`, + ); + return nodePaths[matchIdx]; } // No target is runnable on the host. Download a host-platform binary @@ -551,15 +592,33 @@ async function pickBlobGeneratorBinary( } as NodeTarget; return await getNodejsExecutable(hostGeneratorTarget, opts); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.warn( - `Could not download a host-platform node for SEA blob generation ` + - `(${msg}); falling back to process.execPath. If the resulting ` + - `binary crashes at startup with EXC_BAD_ACCESS in ` + - `node::sea::FindSingleExecutableResource, pin your local node to ` + - `the same patch release as the target.`, + // Last-resort fallback: process.execPath is only safe when its + // version exactly equals the resolved target version. Otherwise we + // would silently re-enable the discussion #236 crash. + let targetVersion: string | undefined; + try { + targetVersion = await resolveTargetNodeVersion(targets[0], opts); + } catch { + // Target version resolution itself failed (e.g. alpine host + // resolving a `linuxstatic` target, offline). Treat as unknown — + // cannot prove the fallback is safe. + } + + if (targetVersion && targetVersion === process.version) { + return process.execPath; + } + + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `Cannot generate SEA blob: host node ${process.version} differs ` + + `from target ${targetVersion ?? ''} and the host-platform ` + + `download failed (${reason}). Running the generator with a skewed ` + + `node would crash the final binary at startup with EXC_BAD_ACCESS ` + + `in node::sea::FindSingleExecutableResource (see discussion #236). ` + + `Install node ${targetVersion ?? ''} locally (e.g. via nvm) ` + + `or pass nodePath pointing to a host-runnable node binary of that ` + + `version.`, ); - return process.execPath; } } diff --git a/test/test-00-sea-picker/main.js b/test/test-00-sea-picker/main.js new file mode 100644 index 00000000..c51bcf93 --- /dev/null +++ b/test/test-00-sea-picker/main.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +'use strict'; + +// Unit test for the SEA blob-generator selection logic introduced to +// fix discussion #236: the generator node binary must be version-matched +// to every target binary pkg will inject into, otherwise the final SEA +// crashes at startup in node::sea::FindSingleExecutableResource. +// +// The full pipeline is covered by test-00-sea; this test isolates +// step 1 (host-matching) so regressions surface without a full build. + +const assert = require('assert'); +const { pickMatchingHostTargetIndex } = require('../../lib-es5/sea'); + +// Exact platform+arch match → return that target's index so its already +// downloaded binary (version-identical to the one being injected into) +// is reused as the generator. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ + { platform: 'linux', arch: 'x64' }, + { platform: 'macos', arch: 'arm64' }, + ]), + 0, +); + +// Host matches the second target, not the first. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ + { platform: 'macos', arch: 'arm64' }, + { platform: 'linux', arch: 'x64' }, + { platform: 'win', arch: 'x64' }, + ]), + 1, +); + +// Pure cross-platform build (Linux host, macOS-only targets) — no match, +// forcing the host-platform download fallback in pickBlobGeneratorBinary. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ + { platform: 'macos', arch: 'arm64' }, + { platform: 'win', arch: 'x64' }, + ]), + -1, +); + +// Same platform, different arch — NOT a match. Historically pkg would +// have used nodePaths[0] here (a cross-arch binary) and failed to spawn. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'macos', arch: 'arm64' }, [ + { platform: 'macos', arch: 'x64' }, + ]), + -1, +); + +// Alpine hosts report hostPlatform='alpine' (from @yao-pkg/pkg-fetch), +// which never equals any user-visible target platform ('linux', +// 'linuxstatic', 'macos', 'win'). This drives alpine builds through the +// fallback path, where pickBlobGeneratorBinary's version-safety check +// either accepts process.execPath (same version) or throws. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'alpine', arch: 'x64' }, [ + { platform: 'linux', arch: 'x64' }, + { platform: 'linuxstatic', arch: 'x64' }, + ]), + -1, +); + +// Empty targets → -1. Defensive; pkg enforces at least one target +// elsewhere, but the helper must not throw on an empty list. +assert.strictEqual( + pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, []), + -1, +); + +console.log('sea-picker: ok'); From 532704ac30f5117c7b6ebfbb15fba64395591a27 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 22 Apr 2026 16:47:50 +0200 Subject: [PATCH 09/13] fix(sea): pin cross-compile blob generator to exact target version Address copilot review on #247: - pickBlobGeneratorBinary: resolve target's concrete patch version up front via resolveTargetNodeVersion, pass it as nodeRange for the host-platform download so host and target resolve to the same patch (unofficial builds / arch-specific availability could otherwise diverge and reintroduce the discussion #236 SEA header skew crash). - Short-circuit to process.execPath when process.version already matches the resolved target version (skip the download). - Drop user-supplied nodePath / useLocalNode when invoking the host download so neither can short-circuit the pinned fetch. - test-00-sea-picker: rewrite the "cross-platform" case comment to describe the real invariant (Linux host, no Linux target), since the target list contains both macos and win. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 63 +++++++++++++++++---------------- test/test-00-sea-picker/main.js | 6 ++-- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 000c478a..6c1c4c5d 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -575,49 +575,50 @@ async function pickBlobGeneratorBinary( return nodePaths[matchIdx]; } - // No target is runnable on the host. Download a host-platform binary - // at the target's node range so the blob generator and the SEA reader - // baked into each target share the exact same version — otherwise we - // regress into the discussion #236 crash on any host/target patch skew. + // No target is runnable on the host. Resolve the target's concrete + // patch version first, then pin a host-platform download to that exact + // version so the blob generator and the SEA reader baked into each + // target share the same patch level — otherwise we regress into the + // discussion #236 crash on any host/target patch skew. Resolving + // against target's platform/arch (not host's) is what pins the + // version: host and target could otherwise land on different latest + // patches (unofficial builds, arch-specific availability). + const targetVersion = await resolveTargetNodeVersion(targets[0], opts); + + if (targetVersion === process.version) { + // Host already runs the exact target version; no download needed. + return process.execPath; + } + log.info( `No target matches host ${hostPlatform}-${hostArch}; downloading a ` + - `host-platform node binary to generate the SEA blob at the exact ` + - `target version (avoids SEA header version skew — see discussion #236).`, + `host-platform node ${targetVersion} to generate the SEA blob ` + + `(avoids SEA header version skew — see discussion #236).`, ); try { const hostGeneratorTarget = { platform: hostPlatform, arch: hostArch, - nodeRange: targets[0].nodeRange, + nodeRange: targetVersion, } as NodeTarget; - return await getNodejsExecutable(hostGeneratorTarget, opts); + // Drop user-supplied nodePath / useLocalNode: they'd short-circuit + // the download in getNodejsExecutable and reintroduce version skew. + const downloadOpts: GetNodejsExecutableOptions = { + ...opts, + nodePath: undefined, + useLocalNode: false, + }; + return await getNodejsExecutable(hostGeneratorTarget, downloadOpts); } catch (err) { - // Last-resort fallback: process.execPath is only safe when its - // version exactly equals the resolved target version. Otherwise we - // would silently re-enable the discussion #236 crash. - let targetVersion: string | undefined; - try { - targetVersion = await resolveTargetNodeVersion(targets[0], opts); - } catch { - // Target version resolution itself failed (e.g. alpine host - // resolving a `linuxstatic` target, offline). Treat as unknown — - // cannot prove the fallback is safe. - } - - if (targetVersion && targetVersion === process.version) { - return process.execPath; - } - const reason = err instanceof Error ? err.message : String(err); throw wasReported( `Cannot generate SEA blob: host node ${process.version} differs ` + - `from target ${targetVersion ?? ''} and the host-platform ` + - `download failed (${reason}). Running the generator with a skewed ` + - `node would crash the final binary at startup with EXC_BAD_ACCESS ` + - `in node::sea::FindSingleExecutableResource (see discussion #236). ` + - `Install node ${targetVersion ?? ''} locally (e.g. via nvm) ` + - `or pass nodePath pointing to a host-runnable node binary of that ` + - `version.`, + `from target ${targetVersion} and the host-platform download ` + + `failed (${reason}). Running the generator with a skewed node ` + + `would crash the final binary at startup with EXC_BAD_ACCESS in ` + + `node::sea::FindSingleExecutableResource (see discussion #236). ` + + `Install node ${targetVersion} locally (e.g. via nvm) or pass ` + + `nodePath pointing to a host-runnable node binary of that version.`, ); } } diff --git a/test/test-00-sea-picker/main.js b/test/test-00-sea-picker/main.js index c51bcf93..d557e5e6 100644 --- a/test/test-00-sea-picker/main.js +++ b/test/test-00-sea-picker/main.js @@ -34,8 +34,10 @@ assert.strictEqual( 1, ); -// Pure cross-platform build (Linux host, macOS-only targets) — no match, -// forcing the host-platform download fallback in pickBlobGeneratorBinary. +// Pure cross-platform build (Linux host, no Linux target in the list) — +// no platform match, forcing the host-platform download fallback in +// pickBlobGeneratorBinary. Multiple non-host targets included to make +// sure none of them accidentally match. assert.strictEqual( pickMatchingHostTargetIndex({ platform: 'linux', arch: 'x64' }, [ { platform: 'macos', arch: 'arm64' }, From d197d50715ebdf2a47283acc208e41c3d282bf29 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 22 Apr 2026 17:11:40 +0200 Subject: [PATCH 10/13] fix(sea): canonicalize Node version to v-prefix everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getNodeVersion's 3-part branch returned bare `22.22.2` while every other producer (nodejs.org/dist, process.version, `node --version`) returns `v22.22.2`. Two concrete consequences: 1. pickBlobGeneratorBinary's `targetVersion === process.version` check would false-negative when the user pins the patch (e.g. node22.22.2 target), dropping into the download path unnecessarily. 2. getNodejsExecutable's archive filename was `node-22.22.2-linux-x64.tar.gz` instead of `node-v22.22.2-...`, so the download would 404 for any patch-pinned range. Fix: always return v-prefixed; strip the prefix in the one place that needs a bare semver (the cross-platform `nodeRange` construction, which is re-parsed by getNodeVersion's regex). Also wire test-00-sea-picker into the npmTests list for parity with test-00-sea — the picker test is a pure unit test that runs in every flavor except only-npm; adding it makes both SEA tests behave the same. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 7 +++++-- test/test.js | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 6c1c4c5d..7de8f2ff 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -212,7 +212,7 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { } if (parts.length === 3) { - return nodeVersion; + return `v${nodeVersion}`; } let url; @@ -596,10 +596,13 @@ async function pickBlobGeneratorBinary( `(avoids SEA header version skew — see discussion #236).`, ); try { + // nodeRange must be `node` so + // getNodejsExecutable → getNodeVersion's `replace('node','')` + regex + // sees a clean `22.22.2` (v-prefix would fail the validator). const hostGeneratorTarget = { platform: hostPlatform, arch: hostArch, - nodeRange: targetVersion, + nodeRange: `node${targetVersion.replace(/^v/, '')}`, } as NodeTarget; // Drop user-supplied nodePath / useLocalNode: they'd short-circuit // the download in getNodejsExecutable and reintroduce version skew. diff --git a/test/test.js b/test/test.js index ca7b732e..90dec82c 100644 --- a/test/test.js +++ b/test/test.js @@ -74,6 +74,7 @@ const npmTests = [ 'test-99-#1191', 'test-99-#1192', 'test-00-sea', + 'test-00-sea-picker', ]; if (testFilter) { From 1160ecd345e09464bf5e33c62d9ce655439d2cd1 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 22 Apr 2026 17:12:20 +0200 Subject: [PATCH 11/13] refactor(sea): narrow types and move pickBlobGeneratorBinary doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add NodeVersion (`v${number}.${number}.${number}`), NodeOs, NodeArch, NodeRange (`node${string}`) template-literal/union types encoding the invariants the runtime already enforces. - Route getNodeOs / getNodeArch / getNodeVersion / resolveTargetNodeVersion through the narrow types so downstream callers (filename construction, `=== process.version` check, cross-platform `nodeRange` build) carry compile-time guarantees instead of passing bare strings. - Simplify getNodeVersion's version search: drop the `.map → tuple → .find` acrobatics and inline parameter type annotations in favour of a single typed `.find` on the parsed response. - Add explicit `Promise` / `Promise` / `number` return annotations on getNodejsExecutable, bake, signMacOSIfNeeded, assertHostSeaNodeVersion, generateSeaBlob, extract. - Move the 30-line "Strategy" JSDoc back onto pickBlobGeneratorBinary (it had drifted above the pickMatchingHostTargetIndex helper after the helper extraction). No behaviour change — types track existing runtime invariants; the only functional adjustment is swapping `targetVersion.replace(/^v/, '')` for `targetVersion.slice(1)` in the cross-platform `nodeRange` construction, safe because NodeVersion guarantees the leading `v`. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 141 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 86 insertions(+), 55 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index 7de8f2ff..e52dd331 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -57,6 +57,35 @@ export type GetNodejsExecutableOptions = { nodePath?: string; }; +/** + * Canonical Node.js version string as produced by nodejs.org/dist and + * `process.version`: `v..`. Consistently v-prefixed + * across every producer in this file — downstream callsites rely on the + * prefix to build archive filenames + * (`node-v22.22.2-linux-x64.tar.gz`) and to compare against + * `process.version`. + */ +export type NodeVersion = `v${number}.${number}.${number}`; + +/** Node-side OS name (as used in nodejs.org archive URLs). */ +const NODE_OSES = ['darwin', 'linux', 'win'] as const; +type NodeOs = (typeof NODE_OSES)[number]; + +/** Node-side arch name (as used in nodejs.org archive URLs). */ +const NODE_ARCHS = [ + 'x64', + 'arm64', + 'armv7l', + 'ppc64', + 's390x', + 'riscv64', + 'loong64', +] as const; +type NodeArch = (typeof NODE_ARCHS)[number]; + +/** pkg convention: `nodeRange` is `node` (e.g. `node22`, `node22.22.2`). */ +type NodeRange = `node${string}`; + export type SeaConfig = { disableExperimentalSEAWarning: boolean; useSnapshot: boolean; // must be set to false when cross-compiling @@ -89,7 +118,7 @@ async function downloadFile(url: string, filePath: string): Promise { } /** Extract node executable from the archive */ -async function extract(os: string, archivePath: string): Promise { +async function extract(os: NodeOs, archivePath: string): Promise { const nodeDir = basename(archivePath, os === 'win' ? '.zip' : '.tar.gz'); const archiveDir = dirname(archivePath); let nodePath = ''; @@ -163,42 +192,39 @@ async function verifyChecksum( } /** Get the node os based on target platform */ -function getNodeOs(platform: string) { - const allowedOSs = ['darwin', 'linux', 'win']; +function getNodeOs(platform: string): NodeOs { const platformsMap: Record = { macos: 'darwin', }; const validatedPlatform = platformsMap[platform] || platform; - if (!allowedOSs.includes(validatedPlatform)) { + if (!(NODE_OSES as readonly string[]).includes(validatedPlatform)) { throw new Error(`Unsupported OS: ${platform}`); } - return validatedPlatform; + return validatedPlatform as NodeOs; } /** Get the node arch based on target arch */ -function getNodeArch(arch: string) { - const allowedArchs = [ - 'x64', - 'arm64', - 'armv7l', - 'ppc64', - 's390x', - 'riscv64', - 'loong64', - ]; - - if (!allowedArchs.includes(arch)) { +function getNodeArch(arch: string): NodeArch { + if (!(NODE_ARCHS as readonly string[]).includes(arch)) { throw new Error(`Unsupported architecture: ${arch}`); } - return arch; + return arch as NodeArch; } -/** Get latest node version based on the provided partial version */ -async function getNodeVersion(os: string, arch: string, nodeVersion: string) { +/** + * Get latest Node.js version covering a partial range. Accepts `22`, + * `22.22`, or `22.22.2`; returns the canonical v-prefixed triple the + * rest of the file expects. + */ +async function getNodeVersion( + os: NodeOs, + arch: NodeArch, + nodeVersion: string, +): Promise { // validate nodeVersion using regex. Allowed formats: 16, 16.0, 16.0.0 const regex = /^\d{1,2}(\.\d{1,2}){0,2}$/; if (!regex.test(nodeVersion)) { @@ -212,7 +238,7 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { } if (parts.length === 3) { - return `v${nodeVersion}`; + return `v${nodeVersion}` as NodeVersion; } let url; @@ -232,22 +258,23 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { throw new Error('Failed to fetch node versions'); } - const versions = await response.json(); + const versions = (await response.json()) as { + version: string; + files: string[]; + }[]; const nodeOS = os === 'darwin' ? 'osx' : os; - const latestVersionAndFiles = versions - .map((v: { version: string; files: string[] }) => [v.version, v.files]) - .find( - ([v, files]: [string, string[]]) => - v.startsWith(`v${nodeVersion}`) && - files.find((f: string) => f.startsWith(`${nodeOS}-${arch}`)), - ); + const latest = versions.find( + (v) => + v.version.startsWith(`v${nodeVersion}`) && + v.files.some((f) => f.startsWith(`${nodeOS}-${arch}`)), + ); - if (!latestVersionAndFiles) { + if (!latest) { throw new Error(`Node version ${nodeVersion} not found`); } - return latestVersionAndFiles[0]; + return latest.version as NodeVersion; } /** @@ -260,13 +287,13 @@ async function getNodeVersion(os: string, arch: string, nodeVersion: string) { async function resolveTargetNodeVersion( target: NodeTarget, opts: GetNodejsExecutableOptions, -): Promise { - if (opts.useLocalNode) return process.version; +): Promise { + if (opts.useLocalNode) return process.version as NodeVersion; if (opts.nodePath) { // 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(); + return stdout.trim() as NodeVersion; } const os = getNodeOs(target.platform); const arch = getNodeArch(target.arch); @@ -277,7 +304,7 @@ async function resolveTargetNodeVersion( async function getNodejsExecutable( target: NodeTarget, opts: GetNodejsExecutableOptions, -) { +): Promise { if (opts.nodePath) { // check if the nodePath exists if (!(await exists(opts.nodePath))) { @@ -343,7 +370,7 @@ async function bake( nodePath: string, target: NodeTarget & Partial, blobData: Buffer, -) { +): Promise { const outPath = resolve(process.cwd(), target.output as string); log.info( @@ -399,7 +426,7 @@ export async function signMacOSIfNeeded( target: NodeTarget & Partial, signature?: boolean, isSea?: boolean, -) { +): Promise { if (!signature || target.platform !== 'macos') return; if (!isSea) { @@ -460,7 +487,7 @@ async function withSeaTmpDir( * Host-only check — target Node majors are validated via * {@link resolveMinTargetMajor}. */ -function assertHostSeaNodeVersion() { +function assertHostSeaNodeVersion(): number { const nodeMajor = parseInt(process.version.slice(1).split('.')[0], 10); if (nodeMajor < 22) { throw new Error( @@ -513,6 +540,21 @@ function assertSingleTargetMajor( } } +/** + * Index into `targets` of the first entry whose platform+arch match + * `host`, or -1 when no target is runnable on the host. Exported for + * unit testing step 1 of the SEA blob-generator selection without + * spinning up a full pkg invocation. + */ +export function pickMatchingHostTargetIndex( + host: { platform: string; arch: string }, + targets: readonly { platform: string; arch: string }[], +): number { + return targets.findIndex( + (t) => t.platform === host.platform && t.arch === host.arch, + ); +} + /** * Pick the node binary used to generate the SEA prep blob. * @@ -544,21 +586,6 @@ function assertSingleTargetMajor( * All targets share a single node major (enforced by * {@link assertSingleTargetMajor}). */ -/** - * Index into `targets` of the first entry whose platform+arch match - * `host`, or -1 when no target is runnable on the host. Exported for - * unit testing step 1 of the SEA blob-generator selection without - * spinning up a full pkg invocation. - */ -export function pickMatchingHostTargetIndex( - host: { platform: string; arch: string }, - targets: readonly { platform: string; arch: string }[], -): number { - return targets.findIndex( - (t) => t.platform === host.platform && t.arch === host.arch, - ); -} - async function pickBlobGeneratorBinary( targets: (NodeTarget & Partial)[], nodePaths: string[], @@ -599,10 +626,14 @@ async function pickBlobGeneratorBinary( // nodeRange must be `node` so // getNodejsExecutable → getNodeVersion's `replace('node','')` + regex // sees a clean `22.22.2` (v-prefix would fail the validator). + // `hostPlatform` from pkg-fetch is wider than NodeTarget.platform + // (e.g. 'alpine', 'linuxstatic'); getNodejsExecutable only reads + // platform/arch to route the download, so the assertion is safe. + const nodeRange: NodeRange = `node${targetVersion.slice(1)}`; const hostGeneratorTarget = { platform: hostPlatform, arch: hostArch, - nodeRange: `node${targetVersion.replace(/^v/, '')}`, + nodeRange, } as NodeTarget; // Drop user-supplied nodePath / useLocalNode: they'd short-circuit // the download in getNodejsExecutable and reintroduce version skew. @@ -637,7 +668,7 @@ async function pickBlobGeneratorBinary( async function generateSeaBlob( seaConfigFilePath: string, generatorBinary: string, -) { +): Promise { log.info('Generating the blob...'); await execFileAsync(generatorBinary, [ '--experimental-sea-config', From fbf40ae169ae532c0d8fa0a29eb786d1929f9714 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 22 Apr 2026 17:15:07 +0200 Subject: [PATCH 12/13] refactor(types): hoist Node version/os/arch/range types to lib/types.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeVersion, NodeRange, NodeOs, NodeArch (+ their backing NODE_OSES / NODE_ARCHS const tuples) describe invariants that aren't sea-specific — `lib/index.ts` already builds `node` strings by hand, `lib/fabricator.ts` logs `${arch}` tuples, and `compress_type.ts` interpolates `process.version` into messages. Hoisting them next to `platform` in `lib/types.ts` makes them available for reuse without widening the NodeTarget signature (out of scope here — keeping downstream callers untouched). No behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/sea.ts | 41 +++++++++++------------------------------ lib/types.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/sea.ts b/lib/sea.ts index e52dd331..deca6d6e 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -18,7 +18,17 @@ import { homedir, tmpdir } from 'os'; import unzipper from 'unzipper'; import { extract as tarExtract } from 'tar'; import { log, wasReported } from './log'; -import { NodeTarget, Target, SeaEnhancedOptions } from './types'; +import { + NodeTarget, + Target, + SeaEnhancedOptions, + NodeVersion, + NodeRange, + NodeOs, + NodeArch, + NODE_OSES, + NODE_ARCHS, +} from './types'; import { patchMachOExecutable, signMachOExecutable } from './mach-o'; import walk from './walker'; import refine from './refiner'; @@ -57,35 +67,6 @@ export type GetNodejsExecutableOptions = { nodePath?: string; }; -/** - * Canonical Node.js version string as produced by nodejs.org/dist and - * `process.version`: `v..`. Consistently v-prefixed - * across every producer in this file — downstream callsites rely on the - * prefix to build archive filenames - * (`node-v22.22.2-linux-x64.tar.gz`) and to compare against - * `process.version`. - */ -export type NodeVersion = `v${number}.${number}.${number}`; - -/** Node-side OS name (as used in nodejs.org archive URLs). */ -const NODE_OSES = ['darwin', 'linux', 'win'] as const; -type NodeOs = (typeof NODE_OSES)[number]; - -/** Node-side arch name (as used in nodejs.org archive URLs). */ -const NODE_ARCHS = [ - 'x64', - 'arm64', - 'armv7l', - 'ppc64', - 's390x', - 'riscv64', - 'loong64', -] as const; -type NodeArch = (typeof NODE_ARCHS)[number]; - -/** pkg convention: `nodeRange` is `node` (e.g. `node22`, `node22.22.2`). */ -type NodeRange = `node${string}`; - export type SeaConfig = { disableExperimentalSEAWarning: boolean; useSnapshot: boolean; // must be set to false when cross-compiling diff --git a/lib/types.ts b/lib/types.ts index b65d0890..1ec49b8f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -61,6 +61,37 @@ export const platform = { linux: 'linux', }; +/** + * Canonical Node.js version string as produced by nodejs.org/dist and + * `process.version`: `v..`. Always v-prefixed — + * downstream consumers rely on the prefix to build archive filenames + * (`node-v22.22.2-linux-x64.tar.gz`) and to compare against + * `process.version`. + */ +export type NodeVersion = `v${number}.${number}.${number}`; + +/** + * pkg's `nodeRange` format: `node` (e.g. `node22`, + * `node22.22.2`). Matches `NodeTarget.nodeRange` by convention. + */ +export type NodeRange = `node${string}`; + +/** OS segment used in nodejs.org archive filenames. */ +export const NODE_OSES = ['darwin', 'linux', 'win'] as const; +export type NodeOs = (typeof NODE_OSES)[number]; + +/** Arch segment used in nodejs.org archive filenames. */ +export const NODE_ARCHS = [ + 'x64', + 'arm64', + 'armv7l', + 'ppc64', + 's390x', + 'riscv64', + 'loong64', +] as const; +export type NodeArch = (typeof NODE_ARCHS)[number]; + export interface NodeTarget { nodeRange: string; arch: string; From 9097e4e14c430af2e66ea68090e668735cec0e86 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Wed, 22 Apr 2026 17:31:27 +0200 Subject: [PATCH 13/13] fix(sea): optimize trailing slash removal in manifest key lookup --- prelude/sea-vfs-setup.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/prelude/sea-vfs-setup.js b/prelude/sea-vfs-setup.js index 6f8cc2b1..94a34ea5 100644 --- a/prelude/sea-vfs-setup.js +++ b/prelude/sea-vfs-setup.js @@ -195,13 +195,12 @@ perf.end('manifest parse'); // match the non-slashed manifest keys. The root '/' is preserved as-is. // Mirrors removeTrailingSlashes() in lib/common.ts, which handles the same // case for the classic (non-SEA) bootstrap. +// +// Only '/' is checked: the Windows branch below normalizes '\' to '/' before +// calling this, so by the time we reach here every separator is a '/'. function _stripTrailingSeps(p) { var i = p.length; - while (i > 1) { - var c = p.charCodeAt(i - 1); - if (c !== 47 /* / */ && c !== 92 /* \\ */) break; - i--; - } + while (i > 1 && p.charCodeAt(i - 1) === 47 /* / */) i--; return i === p.length ? p : p.slice(0, i); } var toManifestKey =