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.
This commit is contained in:
Zoltan Kochan
2026-02-21 21:29:05 +01:00
committed by GitHub
parent 8b4a811fd6
commit 9065f491f0
12 changed files with 165 additions and 55 deletions

View File

@@ -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).

View File

@@ -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.

View File

@@ -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:*",

View File

@@ -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<void> {
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<void> {
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<string, string>
platform: string
arch: string
libc?: string
}
): Promise<NodeArtifactInfo> {
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
*/

View File

@@ -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')
})

View File

@@ -24,9 +24,6 @@
{
"path": "../../network/fetching-types"
},
{
"path": "../../packages/error"
},
{
"path": "../../store/cafs-types"
},

View File

@@ -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',
}
}

View File

@@ -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<PlatformAssetResolution[]> {
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<PlatformAssetResolution[]> {
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,
})
}

View File

@@ -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',
})
})

View File

@@ -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

View File

@@ -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,

3
pnpm-lock.yaml generated
View File

@@ -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