From 9065f491f0159bf3081dc14f72587d0d51a49b8e Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sat, 21 Feb 2026 21:29:05 +0100 Subject: [PATCH] feat: add musl support to node runtime (#10664) The lockfile now includes musl Linux builds (sourced from unofficial-builds.nodejs.org) alongside the standard glibc variants, so that `node@runtime:` works out of the box on Alpine Linux and other musl-based distributions. `env use` can download node.js artifacts for systems that use musl. --- .changeset/env-cross-platform-musl.md | 7 ++ .changeset/node-runtime-musl-support.md | 6 ++ env/node.fetcher/package.json | 1 - env/node.fetcher/src/index.ts | 67 +++++++++++-------- env/node.fetcher/test/node.test.ts | 39 ++++++++--- env/node.fetcher/tsconfig.json | 3 - .../src/getNodeArtifactAddress.ts | 5 +- env/node.resolver/src/index.ts | 50 ++++++++++++-- .../test/getNodeArtifactAddress.test.ts | 14 ++++ env/plugin-commands-env/test/node.test.ts | 1 + pkg-manager/core/test/install/nodeRuntime.ts | 24 +++++-- pnpm-lock.yaml | 3 - 12 files changed, 165 insertions(+), 55 deletions(-) create mode 100644 .changeset/env-cross-platform-musl.md create mode 100644 .changeset/node-runtime-musl-support.md diff --git a/.changeset/env-cross-platform-musl.md b/.changeset/env-cross-platform-musl.md new file mode 100644 index 0000000000..72706efc15 --- /dev/null +++ b/.changeset/env-cross-platform-musl.md @@ -0,0 +1,7 @@ +--- +"@pnpm/node.fetcher": minor +"@pnpm/plugin-commands-env": minor +"pnpm": minor +--- + +On systems using the musl C library (e.g. Alpine Linux), `pnpm env` now automatically downloads the musl variant of Node.js from [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org). diff --git a/.changeset/node-runtime-musl-support.md b/.changeset/node-runtime-musl-support.md new file mode 100644 index 0000000000..3e61682a9e --- /dev/null +++ b/.changeset/node-runtime-musl-support.md @@ -0,0 +1,6 @@ +--- +"@pnpm/node.resolver": patch +"pnpm": patch +--- + +Include musl Linux variants when resolving `node@runtime:` dependencies. The lockfile now includes musl builds (from `unofficial-builds.nodejs.org`) alongside the standard glibc variants, so that `node@runtime:` works out of the box on Alpine Linux and other musl-based distributions. diff --git a/env/node.fetcher/package.json b/env/node.fetcher/package.json index df75a24b2d..ce3d636941 100644 --- a/env/node.fetcher/package.json +++ b/env/node.fetcher/package.json @@ -35,7 +35,6 @@ "dependencies": { "@pnpm/create-cafs-store": "workspace:*", "@pnpm/crypto.shasums-file": "workspace:*", - "@pnpm/error": "workspace:*", "@pnpm/fetching-types": "workspace:*", "@pnpm/fetching.binary-fetcher": "workspace:*", "@pnpm/node.resolver": "workspace:*", diff --git a/env/node.fetcher/src/index.ts b/env/node.fetcher/src/index.ts index 60caaafd9b..d69e76ef8d 100644 --- a/env/node.fetcher/src/index.ts +++ b/env/node.fetcher/src/index.ts @@ -1,5 +1,4 @@ import path from 'path' -import { PnpmError } from '@pnpm/error' import { fetchShasumsFileRaw, pickFileChecksumFromShasumsFile } from '@pnpm/crypto.shasums-file' import { type FetchFromRegistry, @@ -8,18 +7,22 @@ import { import { createCafsStore } from '@pnpm/create-cafs-store' import { type Cafs } from '@pnpm/cafs-types' import { createTarballFetcher } from '@pnpm/tarball-fetcher' -import { getNodeArtifactAddress } from '@pnpm/node.resolver' +import { + getNodeArtifactAddress, + DEFAULT_NODE_MIRROR_BASE_URL, + UNOFFICIAL_NODE_MIRROR_BASE_URL, +} from '@pnpm/node.resolver' import { downloadAndUnpackZip } from '@pnpm/fetching.binary-fetcher' import { isNonGlibcLinux } from 'detect-libc' -// Constants -const DEFAULT_NODE_MIRROR_BASE_URL = 'https://nodejs.org/download/release/' - export interface FetchNodeOptionsToDir { storeDir: string fetchTimeout?: number nodeMirrorBaseUrl?: string retry?: RetryTimeoutOptions + // Overrides for testing + platform?: string + arch?: string } export interface FetchNodeOptions { @@ -44,7 +47,7 @@ interface NodeArtifactInfo { * @param version - Node.js version to install * @param targetDir - Directory where Node.js should be installed * @param opts - Configuration options for the fetch operation - * @throws {PnpmError} When system uses MUSL libc, integrity verification fails, or download fails + * @throws {PnpmError} When integrity verification fails or download fails */ export async function fetchNode ( fetch: FetchFromRegistry, @@ -52,10 +55,26 @@ export async function fetchNode ( targetDir: string, opts: FetchNodeOptionsToDir ): Promise { - await validateSystemCompatibility() + const platform = opts.platform ?? process.platform + const arch = opts.arch ?? process.arch + // On a native musl Linux system, automatically use the musl variant so that + // pnpm env works out of the box on Alpine Linux and similar distributions. + let libc: string | undefined + if (platform === 'linux' && await isNonGlibcLinux()) { + libc = 'musl' + } - const nodeMirrorBaseUrl = opts.nodeMirrorBaseUrl ?? DEFAULT_NODE_MIRROR_BASE_URL - const artifactInfo = await getNodeArtifactInfo(fetch, version, { nodeMirrorBaseUrl }) + const isMusl = libc === 'musl' + const nodeMirrorBaseUrl = opts.nodeMirrorBaseUrl ?? (isMusl + ? UNOFFICIAL_NODE_MIRROR_BASE_URL + : DEFAULT_NODE_MIRROR_BASE_URL) + + const artifactInfo = await getNodeArtifactInfo(fetch, version, { + nodeMirrorBaseUrl, + platform, + arch, + libc, + }) if (artifactInfo.isZip) { await downloadAndUnpackZip(fetch, artifactInfo, targetDir) @@ -65,26 +84,12 @@ export async function fetchNode ( await downloadAndUnpackTarballToDir(fetch, artifactInfo, targetDir, opts) } -/** - * Validates that the current system is compatible with Node.js installation. - * - * @throws {PnpmError} When system uses MUSL libc - */ -async function validateSystemCompatibility (): Promise { - if (await isNonGlibcLinux()) { - throw new PnpmError( - 'MUSL', - 'The current system uses the "MUSL" C standard library. Node.js currently has prebuilt artifacts only for the "glibc" libc, so we can install Node.js only for glibc' - ) - } -} - /** * Gets Node.js artifact information including URL, integrity, and file type. * * @param fetch - Function to fetch resources from registry * @param version - Node.js version - * @param nodeMirrorBaseUrl - Base URL for Node.js mirror + * @param opts - Options including nodeMirrorBaseUrl, platform, arch, and libc * @returns Promise resolving to artifact information * @throws {PnpmError} When integrity file cannot be fetched or parsed */ @@ -94,21 +99,28 @@ async function getNodeArtifactInfo ( opts: { nodeMirrorBaseUrl: string integrities?: Record + platform: string + arch: string + libc?: string } ): Promise { + const isMusl = opts.libc === 'musl' + const tarball = getNodeArtifactAddress({ version, baseUrl: opts.nodeMirrorBaseUrl, - platform: process.platform, - arch: process.arch, + platform: opts.platform, + arch: opts.arch, + libc: opts.libc, }) const tarballFileName = `${tarball.basename}${tarball.extname}` const shasumsFileUrl = `${tarball.dirname}/SHASUMS256.txt` const url = `${tarball.dirname}/${tarballFileName}` + const integrityKey = isMusl ? `${opts.platform}-${opts.arch}-musl` : `${opts.platform}-${opts.arch}` const integrity = opts.integrities - ? opts.integrities[`${process.platform}-${process.arch}`] + ? opts.integrities[integrityKey] : await loadArtifactIntegrity(fetch, tarballFileName, shasumsFileUrl) return { @@ -125,7 +137,6 @@ async function getNodeArtifactInfo ( * @param fetch - Function to fetch resources from registry * @param fileName - Name of the file to find integrity for * @param shasumsUrl - URL of the SHASUMS256.txt file - * @param options - Optional configuration for integrity verification * @returns Promise resolving to the integrity hash in base64 format * @throws {PnpmError} When integrity file cannot be fetched or parsed */ diff --git a/env/node.fetcher/test/node.test.ts b/env/node.fetcher/test/node.test.ts index 80dcb3019a..1b15daafb4 100644 --- a/env/node.fetcher/test/node.test.ts +++ b/env/node.fetcher/test/node.test.ts @@ -13,7 +13,18 @@ jest.unstable_mockModule('detect-libc', () => ({ const { fetchNode } = await import('@pnpm/node.fetcher') const { isNonGlibcLinux } = await import('detect-libc') +// A stable fake hex digest used as placeholder sha256 in mock SHASUMS256.txt files. +// Any non-zero value works; the tarball content won't match, so integrity will +// fail — but all URL assertions run before that happens. +const FAKE_SHA256 = '5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef' + const fetchMock = jest.fn(async (url: string) => { + if (url.endsWith('SHASUMS256.txt')) { + // Return a minimal SHASUMS file covering the artifacts used in tests. + return new Response( + `${FAKE_SHA256} node-v22.0.0-linux-x64-musl.tar.gz\n` + ) + } if (url.endsWith('.zip')) { // The Windows code path for pnpm's node bootstrapping expects a subdir // within the .zip file. @@ -62,15 +73,27 @@ test.skip('install Node using the default node mirror', async () => { } }) -test('install Node using a custom node mirror #2', async () => { + +test('auto-detects musl on non-glibc Linux and uses unofficial-builds mirror', async () => { jest.mocked(isNonGlibcLinux).mockReturnValue(Promise.resolve(true)) tempDir() - const opts: FetchNodeOptions = { - storeDir: path.resolve('store'), - } - + // The function will throw because the downloaded tarball content won't match + // the fake sha256 we put in the SHASUMS256.txt mock, but all fetch calls are + // recorded before the integrity check, so we can assert the correct URLs. await expect( - fetchNode(fetchMock, '16.4.0', path.resolve('node'), opts) - ).rejects.toThrow('The current system uses the "MUSL" C standard library. Node.js currently has prebuilt artifacts only for the "glibc" libc, so we can install Node.js only for glibc') -}) + fetchNode(fetchMock, '22.0.0', path.resolve('node'), { + storeDir: path.resolve('store'), + platform: 'linux', + arch: 'x64', + retry: { retries: 0 }, + }) + ).rejects.toThrow() + + const shasumsUrl = fetchMock.mock.calls[0][0] as string + expect(shasumsUrl).toContain('unofficial-builds.nodejs.org') + + const tarballUrl = fetchMock.mock.calls[1][0] as string + expect(tarballUrl).toContain('unofficial-builds.nodejs.org') + expect(tarballUrl).toContain('node-v22.0.0-linux-x64-musl.tar.gz') +}) \ No newline at end of file diff --git a/env/node.fetcher/tsconfig.json b/env/node.fetcher/tsconfig.json index 10166d59e8..5b4756dd1e 100644 --- a/env/node.fetcher/tsconfig.json +++ b/env/node.fetcher/tsconfig.json @@ -24,9 +24,6 @@ { "path": "../../network/fetching-types" }, - { - "path": "../../packages/error" - }, { "path": "../../store/cafs-types" }, diff --git a/env/node.resolver/src/getNodeArtifactAddress.ts b/env/node.resolver/src/getNodeArtifactAddress.ts index bfecb143f5..7e0d1db506 100644 --- a/env/node.resolver/src/getNodeArtifactAddress.ts +++ b/env/node.resolver/src/getNodeArtifactAddress.ts @@ -11,6 +11,7 @@ export interface GetNodeArtifactAddressOptions { baseUrl: string platform: string arch: string + libc?: string } export function getNodeArtifactAddress ({ @@ -18,13 +19,15 @@ export function getNodeArtifactAddress ({ baseUrl, platform, arch, + libc, }: GetNodeArtifactAddressOptions): NodeArtifactAddress { const isWindowsPlatform = platform === 'win32' const normalizedPlatform = isWindowsPlatform ? 'win' : platform const normalizedArch = getNormalizedArch(platform, arch, version) + const archSuffix = libc === 'musl' ? '-musl' : '' return { dirname: `${baseUrl}v${version}`, - basename: `node-v${version}-${normalizedPlatform}-${normalizedArch}`, + basename: `node-v${version}-${normalizedPlatform}-${normalizedArch}${archSuffix}`, extname: isWindowsPlatform ? '.zip' : '.tar.gz', } } diff --git a/env/node.resolver/src/index.ts b/env/node.resolver/src/index.ts index 46129a59f2..7616395fe5 100644 --- a/env/node.resolver/src/index.ts +++ b/env/node.resolver/src/index.ts @@ -5,6 +5,7 @@ import { type FetchFromRegistry } from '@pnpm/fetching-types' import { type BinaryResolution, type PlatformAssetResolution, + type PlatformAssetTarget, type ResolveOptions, type ResolveResult, type VariationsResolution, @@ -19,6 +20,9 @@ import { getNodeArtifactAddress } from './getNodeArtifactAddress.js' export { getNodeMirror, parseEnvSpecifier, getNodeArtifactAddress } +export const DEFAULT_NODE_MIRROR_BASE_URL = 'https://nodejs.org/download/release/' +export const UNOFFICIAL_NODE_MIRROR_BASE_URL = 'https://unofficial-builds.nodejs.org/download/release/' + export interface NodeRuntimeResolveResult extends ResolveResult { resolution: VariationsResolution resolvedVia: 'nodejs.org' @@ -70,24 +74,56 @@ export async function resolveNodeRuntime ( } async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string): Promise { + const assets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl, version, muslOnly: false }) + + // When using the default mirror, also fetch musl variants from unofficial-builds.nodejs.org, + // since musl builds are not available on the official mirror. + if (nodeMirrorBaseUrl === DEFAULT_NODE_MIRROR_BASE_URL) { + try { + const muslAssets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl: UNOFFICIAL_NODE_MIRROR_BASE_URL, version, muslOnly: true }) + assets.push(...muslAssets) + } catch { + // Musl variants may not be available for all Node.js versions (e.g. very old ones) + } + } + + return assets +} + +async function readNodeAssetsFromMirror ( + fetch: FetchFromRegistry, + opts: { + nodeMirrorBaseUrl: string + version: string + muslOnly: boolean + } +): Promise { + const { nodeMirrorBaseUrl, version, muslOnly } = opts const integritiesFileUrl = `${nodeMirrorBaseUrl}v${version}/SHASUMS256.txt` const shasumsFileItems = await fetchShasumsFile(fetch, integritiesFileUrl) const escaped = version.replace(/\\/g, '\\\\').replace(/\./g, '\\.') - const pattern = new RegExp(`^node-v${escaped}-([^-.]+)-([^.]+)\\.(?:tar\\.gz|zip)$`) + // The second capture group uses [^.-]+ to stop at a dash, so that the optional + // third group can capture the '-musl' suffix separately (e.g. 'x64' + '-musl'). + const pattern = new RegExp(`^node-v${escaped}-([^-.]+)-([^.-]+)(-musl)?\\.(?:tar\\.gz|zip)$`) const assets: PlatformAssetResolution[] = [] for (const { integrity, fileName } of shasumsFileItems) { const match = pattern.exec(fileName) if (!match) continue - let [, platform, arch] = match + let [, platform, arch, muslSuffix] = match if (platform === 'win') { platform = 'win32' } + const isMusl = muslSuffix != null + if (muslOnly && !isMusl) continue + + const libc = isMusl ? 'musl' : undefined const address = getNodeArtifactAddress({ version, baseUrl: nodeMirrorBaseUrl, platform, arch, + libc, }) const url = `${address.dirname}/${address.basename}${address.extname}` const resolution: BinaryResolution = { @@ -100,11 +136,13 @@ async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: stri if (resolution.archive === 'zip') { resolution.prefix = address.basename } + const target: PlatformAssetTarget = { + os: platform, + cpu: arch, + ...(libc != null && { libc }), + } assets.push({ - targets: [{ - os: platform, - cpu: arch, - }], + targets: [target], resolution, }) } diff --git a/env/node.resolver/test/getNodeArtifactAddress.test.ts b/env/node.resolver/test/getNodeArtifactAddress.test.ts index 00f6a76c75..06a6c8739f 100644 --- a/env/node.resolver/test/getNodeArtifactAddress.test.ts +++ b/env/node.resolver/test/getNodeArtifactAddress.test.ts @@ -64,3 +64,17 @@ test.each([ arch, })).toStrictEqual(tarball) }) + +test('getNodeArtifactAddress with libc=musl appends -musl suffix to arch', () => { + expect(getNodeArtifactAddress({ + version: '22.0.0', + baseUrl: 'https://unofficial-builds.nodejs.org/download/release/', + platform: 'linux', + arch: 'x64', + libc: 'musl', + })).toStrictEqual({ + basename: 'node-v22.0.0-linux-x64-musl', + dirname: 'https://unofficial-builds.nodejs.org/download/release/v22.0.0', + extname: '.tar.gz', + }) +}) diff --git a/env/plugin-commands-env/test/node.test.ts b/env/plugin-commands-env/test/node.test.ts index 5125900705..658a829390 100644 --- a/env/plugin-commands-env/test/node.test.ts +++ b/env/plugin-commands-env/test/node.test.ts @@ -24,6 +24,7 @@ const fetchMock = jest.fn(async (url: string) => { 5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-darwin-arm64.tar.gz 5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-linux-arm64.tar.gz 5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-linux-x64.tar.gz +5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v16.4.0-linux-x64-musl.tar.gz a08f3386090e6511772b949d41970b75a6b71d28abb551dff9854ceb1929dae1 node-v16.4.0-win-x64.zip 5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v18.0.0-rc.3-darwin-arm64.tar.gz 5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef node-v18.0.0-rc.3-linux-arm64.tar.gz diff --git a/pkg-manager/core/test/install/nodeRuntime.ts b/pkg-manager/core/test/install/nodeRuntime.ts index 93aec93e8d..97ed599692 100644 --- a/pkg-manager/core/test/install/nodeRuntime.ts +++ b/pkg-manager/core/test/install/nodeRuntime.ts @@ -1,6 +1,7 @@ import fs from 'fs' import path from 'path' import { LOCKFILE_VERSION, WANTED_LOCKFILE } from '@pnpm/constants' +import { type VariationsResolution } from '@pnpm/resolver-base' import { prepareEmpty } from '@pnpm/prepare' import { addDependenciesToPackage, install } from '@pnpm/core' import { getIntegrity } from '@pnpm/registry-mock' @@ -8,7 +9,8 @@ import { sync as rimraf } from '@zkochan/rimraf' import { sync as writeYamlFile } from 'write-yaml-file' import { testDefaults } from '../utils/index.js' -const RESOLUTIONS = [ +// The standard glibc variants from nodejs.org/download/release/ +const GLIBC_RESOLUTIONS = [ { targets: [ { @@ -185,7 +187,9 @@ test('installing Node.js runtime', async () => { project.isExecutable('.bin/node') expect(fs.readlinkSync('node_modules/node')).toContain(path.join('links', '@', 'node', '22.0.0')) - expect(project.readLockfile()).toStrictEqual({ + + const lockfile = project.readLockfile() + expect(lockfile).toStrictEqual({ settings: { autoInstallPeers: true, excludeLinksFromLockfile: false, @@ -206,7 +210,9 @@ test('installing Node.js runtime', async () => { hasBin: true, resolution: { type: 'variations', - variants: RESOLUTIONS, + // Musl variants from unofficial-builds.nodejs.org are appended alongside + // the standard glibc variants, so use arrayContaining to allow them. + variants: expect.arrayContaining(GLIBC_RESOLUTIONS), }, version: '22.0.0', }, @@ -215,6 +221,14 @@ test('installing Node.js runtime', async () => { 'node@runtime:22.0.0': {}, }, }) + // Verify that musl variants are present for linux x64 and arm64. + const variants = (lockfile.packages['node@runtime:22.0.0'].resolution as VariationsResolution).variants + expect(variants).toContainEqual(expect.objectContaining({ + targets: [{ os: 'linux', cpu: 'x64', libc: 'musl' }], + resolution: expect.objectContaining({ + url: expect.stringContaining('unofficial-builds.nodejs.org'), + }), + })) // Verify that package.json is created expect(fs.existsSync(path.resolve('node_modules/node/package.json'))).toBeTruthy() @@ -254,7 +268,7 @@ test('installing Node.js runtime', async () => { hasBin: true, resolution: { type: 'variations', - variants: RESOLUTIONS, + variants: expect.arrayContaining(GLIBC_RESOLUTIONS), }, version: '22.0.0', }, @@ -309,7 +323,7 @@ test('installing Node.js runtime fails if integrity check fails', async () => { hasBin: true, resolution: { type: 'variations', - variants: RESOLUTIONS.map((resolutionVariant) => ({ + variants: GLIBC_RESOLUTIONS.map((resolutionVariant) => ({ ...resolutionVariant, resolution: { ...resolutionVariant.resolution, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 434558b0b7..1f839cbcd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2275,9 +2275,6 @@ importers: '@pnpm/crypto.shasums-file': specifier: workspace:* version: link:../../crypto/shasums-file - '@pnpm/error': - specifier: workspace:* - version: link:../../packages/error '@pnpm/fetching-types': specifier: workspace:* version: link:../../network/fetching-types