mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-26 09:57:49 -04:00
fix(exe): restore @pnpm/exe startup on Node.js v25.7+ (#11330)
## Summary `@pnpm/exe@11.0.0-rc.4` aborts on every invocation with: ``` node::sea::(anonymous namespace)::SeaDeserializer::Read() at ../src/node_sea.cc:174 Assertion failed: (format_value) <= (static_cast<uint8_t>(ModuleFormat::kModule)) ``` Two independent Node.js v25.7+ SEA regressions are responsible, both surfaced by the rc.4 bump of the embedded runtime from 25.6.1 to 25.9.0. This PR fixes both and adds a prepublish smoke test so a broken binary can't reach npm again. ## Root cause **1. SEA blob format changed in Node.js v25.7.0** ([nodejs/node#61813](https://github.com/nodejs/node/pull/61813) added ESM-entry-point support and inserted a new `ModuleFormat` byte into the blob header). SEA blobs carry no version marker, so a blob written by one Node.js version can only be deserialized by a matching one. In rc.4, the CI host Node.js (25.6.1, pre-change) wrote the blob and it was embedded in a 25.9.0 runtime (post-change) — the deserializer reads a misaligned byte as `format_value`, exceeds `kModule`, `CHECK_LE` fires, `SIGABRT`. `resolveBuilderBinary()` was preferring `process.execPath` whenever the running Node supported `--build-sea`, never checking that its version matched the embedded runtime. **2. Node.js v25.7+ replaces the ambient `require` and `import()` inside a CJS SEA entry with embedder hooks** that only resolve built-in module names. The `pnpm.cjs` shim loaded `dist/pnpm.mjs` via `await import(pathToFileURL(...).href)`, which after the fix to (1) reached the CJS entry and then blew up with: ``` ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: file:///.../dist/pnpm.mjs at loadBuiltinModuleForEmbedder at importModuleDynamicallyForEmbedder ``` ## Changes - **`releasing/commands/src/pack-app/packApp.ts`** — `resolveBuilderBinary` now takes the resolved target runtime version and only reuses `process.execPath` when `process.version` exactly matches; otherwise it downloads a host-arch Node of the target version via the existing `ensureNodeRuntime` path. Added `PACK_APP_RUNTIME_TOO_OLD` for runtimes older than v25.5 (no `--build-sea`). Removed the now-unused `DEFAULT_BUILDER_SPEC` and the stale `fetch`/`nodeDownloadMirrors` args on the builder resolver. Help text / examples refreshed to drop `node@22` / `node@lts` references that would now be rejected. - **`pnpm/pnpm.cjs`** — loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(fileURL)`. `createRequire` returns a regular CJS loader that bypasses the SEA embedder hooks, and the pnpm bundle has no top-level await so synchronous `require` of ESM (Node 22+) loads it cleanly. No build-time paths are baked in — `process.execPath` is evaluated at runtime, verified by relocation-testing the darwin-arm64 SEA under `/tmp/`. - **`pnpm/artifacts/verify-binary.mjs`** (new) + `prepublishOnly` on every platform artifact — replaces the existence-only `test -f pnpm` gate with: 1. A **relocation-sensitivity check**: run the binary without `dist/` staged and confirm the failure mentions a path derived from `process.execPath`, not a build-time constant. Catches any future regression of (2). 2. A **smoke test**: stage a `dist → ../exe/dist` symlink (using `symlink-dir` so Windows junctions are handled transparently), exec `./pnpm -v`, assert the output is a SemVer 2 string. - Cross-platform targets (darwin/win32 artifacts on a Linux CI, or a libc mismatch) skip the exec with a log line and fall back to existence-only, so a musl artifact published from a glibc host still goes through. - Real `dist/` dirs (developer layout) are preserved; stale symlinks from aborted runs are replaced; created symlinks are cleaned up on exit. - **`pnpm/artifacts/exe/test/setup.test.ts`** — new `pnpm -v` execution test gated on both the platform binary and the staged bundle being present, so ordinary `pn compile` test runs skip cleanly instead of failing on a missing `dist/`.
This commit is contained in:
10
.changeset/pack-app-builder-version-pin.md
Normal file
10
.changeset/pack-app-builder-version-pin.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
"@pnpm/releasing.commands": patch
|
||||
"pnpm": patch
|
||||
---
|
||||
|
||||
Fix the `@pnpm/exe` SEA executable crashing at startup on Node.js v25.7+. Two separate regressions in `@pnpm/exe@11.0.0-rc.4` are addressed:
|
||||
|
||||
1. `pnpm pack-app` now pins the Node.js used to write the SEA blob to the exact embedded runtime version. The SEA blob format changed in Node.js v25.7 (ESM entry-point support added a `ModuleFormat` header byte), so a blob produced by a pre-25.7 builder cannot be deserialized by a 25.7+ runtime and vice versa. In rc.4 the CI host Node.js (v25.6.1) built blobs embedded in a v25.9.0 runtime, tripping `SeaDeserializer::Read() ... format_value <= kModule` on every invocation. `pack-app` now downloads a host-arch builder Node.js of the target version when the running Node.js doesn't already match.
|
||||
|
||||
2. The pnpm CJS SEA entry shim now loads `dist/pnpm.mjs` through `Module.createRequire(process.execPath)` instead of `await import(pathToFileURL(...).href)`. In Node.js v25.7+, the ambient `require` and `import()` inside a CJS SEA entry are replaced with embedder hooks that only resolve built-in module names, causing external `file://` loads to fail with `ERR_UNKNOWN_BUILTIN_MODULE`. An explicit `createRequire()` bypasses those hooks.
|
||||
@@ -74,6 +74,7 @@
|
||||
"eisdir",
|
||||
"elifecycle",
|
||||
"elit",
|
||||
"embedder",
|
||||
"emfile",
|
||||
"enametoolong",
|
||||
"endregion",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs darwin arm64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/macos-arm64": "workspace:*"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs darwin x64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/macos-x64": "workspace:*"
|
||||
|
||||
@@ -17,6 +17,11 @@ const platformBin = path.join(
|
||||
isWindows ? 'pnpm.exe' : 'pnpm'
|
||||
)
|
||||
const hasPlatformBinary = fs.existsSync(platformBin)
|
||||
// dist/ is staged by the build-artifacts flow (not by `pn compile`), so
|
||||
// ordinary test runs don't have it. The hardlink test is fine without it
|
||||
// (existence + inode only), but the -v test actually executes the SEA, which
|
||||
// loads dist/pnpm.mjs from next to the binary and would fail here.
|
||||
const hasStagedBundle = fs.existsSync(path.join(exeDir, 'dist', 'pnpm.mjs'))
|
||||
|
||||
describe('exePlatformPkgName', () => {
|
||||
test('uses linuxstatic- prefix for linux + musl libc family', () => {
|
||||
@@ -68,4 +73,18 @@ test('prepare writes correct content for all bin files', () => {
|
||||
|
||||
const pnpmBin = path.join(exeDir, isWindows ? 'pnpm.exe' : 'pnpm')
|
||||
expect(fs.statSync(pnpmBin).ino).toBe(fs.statSync(platformBin).ino)
|
||||
});
|
||||
|
||||
// Actually execute the hardlinked pnpm binary. Existence and inode-match are
|
||||
// not enough — a SEA blob built by a Node.js version that differs from the
|
||||
// embedded runtime deserializes on startup with a native assertion and an
|
||||
// abort signal, not a clean error exit (see rc.4 regression). Running `-v`
|
||||
// verifies the SEA payload is actually readable by the embedded Node.
|
||||
(hasPlatformBinary && hasStagedBundle ? test : test.skip)('pnpm -v runs and prints a semver', () => {
|
||||
execFileSync(process.execPath, [path.join(exeDir, 'prepare.js')], { cwd: exeDir })
|
||||
execFileSync(process.execPath, [path.join(exeDir, 'setup.js')], { cwd: exeDir })
|
||||
|
||||
const pnpmBin = path.join(exeDir, isWindows ? 'pnpm.exe' : 'pnpm')
|
||||
const stdout = execFileSync(pnpmBin, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim()
|
||||
expect(stdout).toMatch(/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs linux arm64 musl"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/linuxstatic-arm64": "workspace:*"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs linux arm64 glibc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/linux-arm64": "workspace:*"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs linux x64 musl"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/linuxstatic-x64": "workspace:*"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm || (echo 'Error: pnpm is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs linux x64 glibc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/linux-x64": "workspace:*"
|
||||
|
||||
152
pnpm/artifacts/verify-binary.mjs
Normal file
152
pnpm/artifacts/verify-binary.mjs
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
// Prepublish gate for the @pnpm/<platform> artifact packages. Runs from the
|
||||
// package directory (cwd contains the built pnpm binary). Verifies:
|
||||
// 1. The binary exists with the expected filename for the target.
|
||||
// 2. If the host can execute the target, `pnpm -v` returns a semver.
|
||||
//
|
||||
// Existence alone is not sufficient — @pnpm/exe@11.0.0-rc.4 shipped a binary
|
||||
// that was present but crashed with a native SEA deserialization assertion on
|
||||
// any invocation. Executing -v would have caught it on the Linux CI host.
|
||||
//
|
||||
// Each platform package ships only the SEA binary (no dist/ or node_modules),
|
||||
// but the SEA's CJS entry (pnpm.cjs) loads dist/pnpm.mjs from
|
||||
// dirname(process.execPath). To run the binary in place we symlink
|
||||
// ./dist -> ../exe/dist (the sibling @pnpm/exe package's staged bundle) for
|
||||
// the duration of the test, then remove the symlink on exit. The platform
|
||||
// package's "files" whitelist is "pnpm" only, so a stale symlink would never
|
||||
// reach the published tarball, but we clean up anyway to leave the tree
|
||||
// untouched for subsequent tools.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
// Resolves via the pnpm CLI's own node_modules (which always contains
|
||||
// symlink-dir — the CLI depends on it directly). symlink-dir handles Windows
|
||||
// junctions internally, so the verifier doesn't need its own elevation /
|
||||
// link-type branching.
|
||||
import { symlinkDirSync } from 'symlink-dir'
|
||||
|
||||
const [targetOs, targetArch, targetLibc] = process.argv.slice(2)
|
||||
if (!targetOs || !targetArch) {
|
||||
console.error('Usage: verify-binary.mjs <os> <arch> [libc]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const binName = targetOs === 'win32' ? 'pnpm.exe' : 'pnpm'
|
||||
if (!fs.existsSync(binName)) {
|
||||
console.error(`Error: ${binName} is missing in ${process.cwd()}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Node populates header.glibcVersionRuntime only on glibc hosts, so its
|
||||
// presence is a reliable glibc/musl discriminator without shelling out.
|
||||
function detectHostLibc () {
|
||||
if (process.platform !== 'linux') return null
|
||||
const header = process.report.getReport().header
|
||||
return header.glibcVersionRuntime ? 'glibc' : 'musl'
|
||||
}
|
||||
const hostLibc = detectHostLibc()
|
||||
|
||||
// Cross-platform or cross-libc targets can't be executed from the publish
|
||||
// host. Existence is the best we can verify — skip the -v check instead of
|
||||
// failing, so a musl artifact published from a glibc CI still goes through.
|
||||
const osMatches = process.platform === targetOs
|
||||
const archMatches = process.arch === targetArch
|
||||
const libcMatches = targetOs !== 'linux' || !targetLibc || targetLibc === hostLibc
|
||||
|
||||
if (!osMatches || !archMatches || !libcMatches) {
|
||||
const targetLabel = [targetOs, targetArch, targetLibc].filter(Boolean).join('/')
|
||||
const hostLabel = [process.platform, process.arch, hostLibc].filter(Boolean).join('/')
|
||||
console.log(`Skipping ${binName} -v: host ${hostLabel} cannot execute target ${targetLabel}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const distLinkPath = path.resolve('dist')
|
||||
const distLinkTarget = path.join('..', 'exe', 'dist')
|
||||
let distLinkCreated = false
|
||||
// Remove a prior symlink from an aborted run so cleanup ownership is always
|
||||
// well-defined. A real dist/ directory (unlikely in a platform package, but
|
||||
// possible during development) is preserved — we treat it as external and
|
||||
// skip cleanup.
|
||||
try {
|
||||
if (fs.lstatSync(distLinkPath).isSymbolicLink()) fs.unlinkSync(distLinkPath)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err
|
||||
}
|
||||
const distPreexists = fs.existsSync(distLinkPath)
|
||||
|
||||
process.on('exit', () => {
|
||||
if (!distLinkCreated) return
|
||||
try { fs.unlinkSync(distLinkPath) } catch { /* nothing to clean up */ }
|
||||
})
|
||||
|
||||
// Relocation check: before staging dist/, confirm the binary reads its bundle
|
||||
// path from process.execPath at runtime and not from a build-time constant.
|
||||
// A pnpm.cjs shim that accidentally captured __filename or a cwd-relative
|
||||
// path during packaging would keep working on the build machine but break on
|
||||
// every end-user machine. Asserting the error references the *runtime* cwd
|
||||
// catches that regression here instead of after publish.
|
||||
//
|
||||
// Skipped when a real dist/ is already present (developer layout); in that
|
||||
// case we can't distinguish a correctly-resolved dist from a hardcoded one.
|
||||
if (!distPreexists) {
|
||||
const expectedRuntimeDist = path.join(fs.realpathSync(process.cwd()), 'dist', 'pnpm.mjs')
|
||||
let sansDistStdout
|
||||
try {
|
||||
sansDistStdout = execFileSync(`./${binName}`, ['-v'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
} catch (err) {
|
||||
const stderr = String(err?.stderr ?? '')
|
||||
// Expected: binary tried to require the runtime dist path and failed
|
||||
// because it isn't there. Anything else (a spawn error, crash signal,
|
||||
// timeout) is NOT evidence of a non-relocatable pnpm.cjs — it's an
|
||||
// unrelated failure that would hide the regression we actually care
|
||||
// about. Surface it with the raw diagnostic so the operator can tell
|
||||
// which one they're looking at.
|
||||
if (!stderr.includes(expectedRuntimeDist)) {
|
||||
const status = err?.status ?? 'none'
|
||||
const signal = err?.signal ?? 'none'
|
||||
const code = err?.code ?? 'none'
|
||||
console.error(`Error: ${binName} -v without dist/ did not fail with a missing-runtime-dist error. Either pnpm.cjs regressed to a non-relocatable form, or the binary failed for an unrelated reason. status=${status} signal=${signal} code=${code}\nstderr:\n${stderr}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
if (sansDistStdout !== undefined) {
|
||||
console.error(`Error: ${binName} -v unexpectedly succeeded without dist/ alongside the binary. Output: ${JSON.stringify(sansDistStdout.trim())}. pnpm.cjs is loading a bundle from somewhere other than dirname(process.execPath); the published binary would ignore the dist shipped in @pnpm/exe.`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Only stage the symlink when nothing's there already — symlink-dir will
|
||||
// atomically rename away any existing dir/file, which would silently drop a
|
||||
// developer's staged dist/ directory.
|
||||
if (!distPreexists) {
|
||||
try {
|
||||
symlinkDirSync(distLinkTarget, distLinkPath)
|
||||
distLinkCreated = true
|
||||
} catch (err) {
|
||||
console.error(`Error: could not stage dist/ symlink: ${String(err)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let stdout
|
||||
try {
|
||||
stdout = execFileSync(`./${binName}`, ['-v'], { encoding: 'utf8', timeout: 30_000 }).trim()
|
||||
} catch (err) {
|
||||
console.error(`Error: ${binName} -v failed: ${String(err)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Accept SemVer 2 with optional prerelease and build-metadata suffixes so a
|
||||
// future `11.0.0-rc.4+sha.<hash>` release doesn't fail this gate spuriously.
|
||||
if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(stdout)) {
|
||||
console.error(`Error: ${binName} -v produced unexpected output: ${JSON.stringify(stdout)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`${binName} -v OK (${stdout})`)
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm.exe"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm.exe || (echo 'Error: pnpm.exe is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs win32 arm64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/win-arm64": "workspace:*"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"pnpm.exe"
|
||||
],
|
||||
"scripts": {
|
||||
"prepublishOnly": "test -f pnpm.exe || (echo 'Error: pnpm.exe is missing' && exit 1)"
|
||||
"prepublishOnly": "node ../verify-binary.mjs win32 x64"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pnpm/win-x64": "workspace:*"
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
// In a SEA binary, relative import specifiers resolve against the *build-time*
|
||||
// path of the embedded script, not the runtime location of the executable.
|
||||
// We must resolve against process.execPath so the import works on any machine.
|
||||
//
|
||||
// Goes through Module.createRequire() rather than the ambient require() or
|
||||
// dynamic import(). In Node.js >=25.7, the ambient require() and import()
|
||||
// inside a CJS SEA entry are replaced with embedder hooks that only know how
|
||||
// to resolve built-in module names, so any attempt to load an external file
|
||||
// fails with ERR_UNKNOWN_BUILTIN_MODULE. A createRequire() bound to the
|
||||
// running binary returns a normal module loader that bypasses those hooks,
|
||||
// and the pnpm bundle has no top-level await so synchronous require() of it
|
||||
// (Node.js 22+ feature) loads cleanly.
|
||||
const { join, dirname } = require('path')
|
||||
const { pathToFileURL } = require('url')
|
||||
const { createRequire } = require('module')
|
||||
|
||||
;(async () => {
|
||||
const distPath = join(dirname(process.execPath), 'dist', 'pnpm.mjs')
|
||||
await import(pathToFileURL(distPath).href)
|
||||
})()
|
||||
const distPath = join(dirname(process.execPath), 'dist', 'pnpm.mjs')
|
||||
createRequire(process.execPath)(distPath)
|
||||
|
||||
@@ -20,12 +20,6 @@ import { renderHelp } from 'render-help'
|
||||
/** Minimum Node.js version that supports `node --build-sea`. */
|
||||
const MIN_BUILDER_VERSION = { major: 25, minor: 5 } as const
|
||||
|
||||
// Range to download when the running Node is too old. Constrained to the
|
||||
// current major so we don't silently jump majors across releases, and pinned
|
||||
// above MIN_BUILDER_VERSION.minor so older point releases (e.g. 25.0.x) that
|
||||
// don't support `--build-sea` aren't picked.
|
||||
const DEFAULT_BUILDER_SPEC = `>=${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0 <${MIN_BUILDER_VERSION.major + 1}.0.0`
|
||||
|
||||
// Target OS names match `process.platform`. That keeps the CLI surface
|
||||
// consistent with pnpm's own `--os` flag (which also takes platform constants)
|
||||
// and with `supportedArchitectures.os` in pnpm-workspace.yaml.
|
||||
@@ -61,16 +55,17 @@ export function help (): string {
|
||||
'Pack a CommonJS entry file into a standalone executable for one or more target platforms.\n\n' +
|
||||
'The executable embeds a Node.js binary via the Node.js Single Executable Applications API.\n' +
|
||||
`Requires Node.js v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}+ to perform ` +
|
||||
'the injection. The running Node.js is used when it is new enough; otherwise, the ' +
|
||||
`latest Node.js v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}+ in the ` +
|
||||
`v${MIN_BUILDER_VERSION.major}.x line is downloaded automatically.\n\n` +
|
||||
'the injection. SEA blobs are not compatible across Node.js minor releases, so the ' +
|
||||
'builder Node.js must match the embedded runtime version exactly. The running Node.js ' +
|
||||
'is used when it already matches; otherwise a host-arch Node.js of the embedded runtime ' +
|
||||
'version is downloaded automatically.\n\n' +
|
||||
'Defaults for --entry, --target, --runtime, --output-dir, and --output-name can be ' +
|
||||
'set in the package.json under "pnpm.app". CLI flags override the config; --target entirely ' +
|
||||
'replaces the configured list so you can narrow it at invocation time.',
|
||||
url: docsUrl('pack-app'),
|
||||
usages: [
|
||||
'pnpm pack-app --entry dist/index.cjs --target linux-x64 --target win32-x64',
|
||||
'pnpm pack-app --entry dist/index.cjs --target linux-x64-musl --runtime node@22',
|
||||
`pnpm pack-app --entry dist/index.cjs --target linux-x64-musl --runtime node@${MIN_BUILDER_VERSION.major}`,
|
||||
],
|
||||
descriptionLists: [
|
||||
{
|
||||
@@ -89,7 +84,8 @@ export function help (): string {
|
||||
{
|
||||
description:
|
||||
'Runtime to embed in the output executables, as a "<name>@<version>" spec ' +
|
||||
'(e.g. "node@22", "node@22.0.0", "node@lts"). Only "node" is supported today. ' +
|
||||
`(e.g. "node@${MIN_BUILDER_VERSION.major}", "node@${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0"). ` +
|
||||
`Only "node" is supported today, and the version must be >= v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor} (the minimum that supports --build-sea). ` +
|
||||
'Defaults to the running Node.js version.',
|
||||
name: '--runtime',
|
||||
},
|
||||
@@ -186,8 +182,17 @@ export async function handler (opts: PackAppOptions, params: string[]): Promise<
|
||||
const fetch = createFetchFromRegistry(opts)
|
||||
const buildRoot = path.join(opts.pnpmHomeDir, 'pack-app')
|
||||
|
||||
const builderBin = await resolveBuilderBinary({ fetch, nodeDownloadMirrors: opts.nodeDownloadMirrors, buildRoot })
|
||||
// Resolve the embedded target version first so the builder can be pinned to
|
||||
// the same version. SEA blobs carry no version header and the serialized
|
||||
// format has changed across Node.js minor releases (e.g. v25.7 added a
|
||||
// ModuleFormat byte for ESM entry points), so a blob produced by a builder
|
||||
// of a different version than the embedded runtime will fail deserialization
|
||||
// at startup with an opaque native assertion.
|
||||
const resolvedTargetVersion = await resolveVersion(fetch, requestedNodeSpec, opts.nodeDownloadMirrors)
|
||||
const builderBin = await resolveBuilderBinary({
|
||||
buildRoot,
|
||||
targetVersion: resolvedTargetVersion,
|
||||
})
|
||||
|
||||
const results: string[] = []
|
||||
for (const target of targets) {
|
||||
@@ -243,21 +248,32 @@ export async function handler (opts: PackAppOptions, params: string[]): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Node.js binary that supports `--build-sea`. Prefers the running
|
||||
* interpreter to avoid a download; falls back to downloading Node.js v25.
|
||||
* Returns a Node.js binary that supports `--build-sea` AND produces a SEA
|
||||
* blob the embedded runtime can deserialize. The second constraint forces the
|
||||
* builder to match the target runtime version exactly: blobs are versioned by
|
||||
* the writer's internal struct layout with no header, and Node bumps that
|
||||
* layout in minor releases (e.g. v25.7 added a ModuleFormat byte for ESM
|
||||
* entries), so a cross-version blob crashes at startup.
|
||||
*
|
||||
* Prefers the running interpreter when it already matches the target version;
|
||||
* otherwise downloads the target version for the host platform.
|
||||
*/
|
||||
async function resolveBuilderBinary (ctx: {
|
||||
fetch: ReturnType<typeof createFetchFromRegistry>
|
||||
nodeDownloadMirrors?: Record<string, string>
|
||||
buildRoot: string
|
||||
targetVersion: string
|
||||
}): Promise<string> {
|
||||
if (runningNodeCanBuildSea()) {
|
||||
if (runningNodeCanBuildSea() && process.version === `v${ctx.targetVersion}`) {
|
||||
return process.execPath
|
||||
}
|
||||
const version = await resolveVersion(ctx.fetch, DEFAULT_BUILDER_SPEC, ctx.nodeDownloadMirrors)
|
||||
if (!builderVersionCanBuildSea(ctx.targetVersion)) {
|
||||
throw new PnpmError('PACK_APP_RUNTIME_TOO_OLD',
|
||||
`The embedded runtime "node@${ctx.targetVersion}" is older than Node.js v${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}, which is the minimum version that supports --build-sea.`,
|
||||
{ hint: `Pass --runtime node@${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0 (or newer) or set "pnpm.app.runtime" in package.json.` }
|
||||
)
|
||||
}
|
||||
return ensureNodeRuntime({
|
||||
buildRoot: ctx.buildRoot,
|
||||
version,
|
||||
version: ctx.targetVersion,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
// Pin libc to the host's. Otherwise a caller that had set
|
||||
@@ -274,7 +290,11 @@ function hostLinuxLibc (): 'glibc' | 'musl' | undefined {
|
||||
}
|
||||
|
||||
function runningNodeCanBuildSea (): boolean {
|
||||
const [majorStr, minorStr] = process.version.slice(1).split('.')
|
||||
return builderVersionCanBuildSea(process.version.slice(1))
|
||||
}
|
||||
|
||||
function builderVersionCanBuildSea (version: string): boolean {
|
||||
const [majorStr, minorStr] = version.split('.')
|
||||
const major = Number(majorStr)
|
||||
const minor = Number(minorStr)
|
||||
return (
|
||||
@@ -393,7 +413,7 @@ function parseRuntime (spec: string): ParsedRuntime {
|
||||
const match = RUNTIME_PATTERN.exec(spec)
|
||||
if (!match) {
|
||||
throw new PnpmError('PACK_APP_INVALID_RUNTIME',
|
||||
`Invalid runtime "${spec}". Expected format: <name>@<version> (supported runtimes: ${SUPPORTED_RUNTIMES.join(', ')}; e.g. "node@22.0.0", "node@lts").`)
|
||||
`Invalid runtime "${spec}". Expected format: <name>@<version> (supported runtimes: ${SUPPORTED_RUNTIMES.join(', ')}; e.g. "node@${MIN_BUILDER_VERSION.major}.${MIN_BUILDER_VERSION.minor}.0").`)
|
||||
}
|
||||
return { name: match[1] as ParsedRuntime['name'], version: match[2] }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user