Files
pnpm/engine/runtime/node-resolver/src/index.ts
Zoltan Kochan 1627943d2a feat(outdated): include node, deno, and bun runtimes (#11739)
`pnpm outdated` and `pnpm update --interactive` previously skipped runtime dependencies (`node`/`deno`/`bun` installed via the `runtime:` protocol). Both commands go through `outdatedDepsOfProjects` → `outdated()`, and the inner loop bailed out for anything `parseBareSpecifier` couldn't parse — which is everything `runtime:`-shaped. A runtime was only ever reported if the current install differed from the wanted lockfile entry, so the latest available version was never surfaced. The same gap silently affected `jsr:` and named-registry deps too.

Commits, smallest fix first → progressively cleaner architecture:

1. **`feat(outdated)`** — minimal fix: special-case runtime deps in `outdated.ts` so they appear in the table and the interactive update picker.
2. **`refactor(outdated)`** — per-resolver dispatch. Each protocol resolver gets its own "what's the latest?" function; `@pnpm/resolving.default-resolver` composes them.
3. **`refactor(outdated)`** — rename to `resolveLatest` (the function returns info regardless of whether the dep is outdated; "outdated" described a state, not an action).
4. **`refactor(outdated)`** — let the local-resolver own the `link:`/`file:` skip, drop the matching short-circuit in `outdated.ts`.
5. **`refactor(outdated)`** — slim `LatestQuery` / `LatestInfo` to the bare essentials; move `pickRegistryForPackage` into the npm-resolver where it belongs; derive `current`/`wanted` display from `pkgSnapshot.version` in `outdated.ts`.
6. **`chore(outdated)`** — drop stale tsconfig project reference left behind by #5.
7. **`refactor(outdated)`** — drop `wantedRef` from the query; resolvers detect protocol from `bareSpecifier` alone.

## Final architecture

`@pnpm/resolving.resolver-base` defines a single tiny protocol:

```ts
interface LatestQuery {
  wantedDependency: WantedDependency
  compatible?: boolean
}

interface LatestInfo {
  latestManifest?: PackageManifest
}

type ResolveLatestFunction = (query: LatestQuery, opts: ResolveOptions) =>
  Promise<LatestInfo | undefined>
```

- `undefined` from a resolver means "I don't claim this dep — try the next one."
- `{}` means "I claim it, but I can't tell you what's latest" (policy-blocked, network unavailable, or a protocol with no concept of latest — git/tarball).
- `{ latestManifest }` is the happy path.

Each protocol resolver (npm/jsr/named-registry, git, tarball, local, node/bun/deno runtimes) exports its own `resolveLatest*` function alongside its `resolve*`. `@pnpm/resolving.default-resolver` composes them into a single first-match dispatcher, surfaced through `@pnpm/installing.client` as `createResolver(...).resolveLatest`.

`outdated.ts` is protocol-agnostic: dispatches, then derives `current`/`wanted` display from `pkgSnapshot.version` (falling back to the raw ref for URL-shaped refs where the URL is the only diff signal between commits), uses raw `wantedRef !== currentRef` for the lockfile-shifted check, and pulls `packageName` from `dp.parse(relativeDepPath).name` so aliased deps still report under the real package name.

Per-resolver responsibilities:
- **npm-resolver** (`resolveLatestFromNpm` / `resolveLatestFromJsr` / `resolveLatestFromNamedRegistry`): match their respective spec shapes, call the matching `resolveFromX` with `'latest'` (or the original spec under `--compatible`), handle `MINIMUM_RELEASE_AGE_VIOLATION` and `ERR_PNPM_NO_MATCHING_VERSION` so policy-blocked deps don't surface as available updates. Picks the per-package registry internally via its ctx.
- **node/bun/deno runtime resolvers**: claim deps via `bareSpecifier.startsWith('runtime:')` + alias match, query their release sources for the latest version (only the version — no asset-hash fetches), return `{ latestManifest }`.
- **git / tarball resolvers**: claim deps via spec shape, return `{}` (no concept of "latest"); the caller still surfaces a ref-mismatch report if the lockfile shifted to a different commit/URL.
- **local-resolver**: returns `undefined` so `link:`/`file:`/`workspace:` deps fall through and get silently skipped.
2026-05-19 19:15:07 +02:00

253 lines
9.0 KiB
TypeScript

import { fetchShasumsFile } from '@pnpm/crypto.shasums-file'
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type {
BinaryResolution,
LatestInfo,
LatestQuery,
PlatformAssetResolution,
PlatformAssetTarget,
ResolveOptions,
ResolveResult,
VariationsResolution,
WantedDependency,
} from '@pnpm/resolving.resolver-base'
import type { PkgResolutionId } from '@pnpm/types'
import semver from 'semver'
import versionSelectorType from 'version-selector-type'
import { getNodeArtifactAddress } from './getNodeArtifactAddress.js'
import { getNodeMirror } from './getNodeMirror.js'
import { parseNodeSpecifier } from './parseNodeSpecifier.js'
export { getNodeArtifactAddress, getNodeMirror, parseNodeSpecifier }
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/'
// Node.js archives ship with npm, npx, and corepack. pnpm manages package managers itself,
// so these are excluded from the runtime install — skipping ~2,800 files out of ~5,800 in the
// Node.js tarball. The pattern matches paths *after* the archive's top-level
// `node-vX.Y.Z-<platform>-<arch>/` prefix has been stripped.
export const NODE_EXTRAS_IGNORE_PATTERN = '^(?:(?:lib/)?node_modules/(?:npm|corepack)(?:/|$)|bin/(?:npm|npx|corepack)$|(?:npm|npx|corepack)(?:\\.(?:cmd|ps1))?$)'
export interface NodeRuntimeResolveResult extends ResolveResult {
resolution: VariationsResolution
resolvedVia: 'nodejs.org'
}
export async function resolveNodeRuntime (
ctx: {
fetchFromRegistry: FetchFromRegistry
nodeDownloadMirrors?: Record<string, string>
offline?: boolean
},
wantedDependency: WantedDependency,
opts?: Partial<ResolveOptions>
): Promise<NodeRuntimeResolveResult | null> {
if (wantedDependency.alias !== 'node' || !wantedDependency.bareSpecifier?.startsWith('runtime:')) return null
if (opts?.currentPkg && !opts.update) {
return {
id: opts.currentPkg.id,
resolution: opts.currentPkg.resolution as VariationsResolution,
resolvedVia: 'nodejs.org',
}
}
if (ctx.offline) throw new PnpmError('NO_OFFLINE_NODEJS_RESOLUTION', 'Offline Node.js resolution is not supported')
const versionSpec = wantedDependency.bareSpecifier.substring('runtime:'.length)
const { releaseChannel, versionSpecifier } = parseNodeSpecifier(versionSpec)
const nodeMirrorBaseUrl = getNodeMirror(ctx.nodeDownloadMirrors, releaseChannel)
const version = await resolveNodeVersion(ctx.fetchFromRegistry, versionSpecifier, nodeMirrorBaseUrl)
if (!version) {
throw new PnpmError('NODEJS_VERSION_NOT_FOUND', `Could not find a Node.js version that satisfies ${versionSpec}`)
}
const variants = await readNodeAssets(ctx.fetchFromRegistry, nodeMirrorBaseUrl, version)
const range = version === versionSpec ? version : `^${version}`
return {
id: `node@runtime:${version}` as PkgResolutionId,
normalizedBareSpecifier: `runtime:${range}`,
resolvedVia: 'nodejs.org',
manifest: {
name: 'node',
version,
bin: getNodeBinsForCurrentOS(),
},
resolution: {
type: 'variations',
variants,
},
}
}
export async function resolveLatestNodeRuntime (
ctx: { fetchFromRegistry: FetchFromRegistry, nodeDownloadMirrors?: Record<string, string> },
query: LatestQuery,
_opts: ResolveOptions
): Promise<LatestInfo | undefined> {
const manifestSpec = query.wantedDependency.bareSpecifier
if (query.wantedDependency.alias !== 'node' || !manifestSpec?.startsWith('runtime:')) return undefined
const versionSpec = query.compatible ? manifestSpec.substring('runtime:'.length) : 'latest'
const { releaseChannel, versionSpecifier } = parseNodeSpecifier(versionSpec)
const nodeMirrorBaseUrl = getNodeMirror(ctx.nodeDownloadMirrors, releaseChannel)
const version = await resolveNodeVersion(ctx.fetchFromRegistry, versionSpecifier, nodeMirrorBaseUrl)
if (!version) return {}
return { latestManifest: { name: 'node', version } }
}
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, '\\.')
// 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, 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 = {
type: 'binary',
archive: address.extname === '.zip' ? 'zip' : 'tarball',
bin: getNodeBinsForCurrentOS(platform),
integrity,
url,
}
if (resolution.archive === 'zip') {
resolution.prefix = address.basename
}
const target: PlatformAssetTarget = {
os: platform,
cpu: arch,
...(libc != null && { libc }),
}
assets.push({
targets: [target],
resolution,
})
}
return assets
}
interface NodeVersion {
version: string
lts: false | string
}
const SEMVER_OPTS = {
includePrerelease: true,
loose: true,
}
export async function resolveNodeVersion (
fetch: FetchFromRegistry,
versionSpec: string,
nodeMirrorBaseUrl?: string
): Promise<string | null> {
const allVersions = await fetchAllVersions(fetch, nodeMirrorBaseUrl)
if (versionSpec === 'latest') {
return allVersions[0].version
}
const { versions, versionRange } = filterVersions(allVersions, versionSpec)
return semver.maxSatisfying(versions, versionRange, SEMVER_OPTS) ?? null
}
export async function resolveNodeVersions (
fetch: FetchFromRegistry,
versionSpec?: string,
nodeMirrorBaseUrl?: string
): Promise<string[]> {
const allVersions = await fetchAllVersions(fetch, nodeMirrorBaseUrl)
if (!versionSpec) {
return allVersions.map(({ version }) => version)
}
if (versionSpec === 'latest') {
return [allVersions[0].version]
}
const { versions, versionRange } = filterVersions(allVersions, versionSpec)
return versions.filter(version => semver.satisfies(version, versionRange, SEMVER_OPTS))
}
async function fetchAllVersions (fetch: FetchFromRegistry, nodeMirrorBaseUrl?: string): Promise<NodeVersion[]> {
const response = await fetch(`${nodeMirrorBaseUrl ?? 'https://nodejs.org/download/release/'}index.json`)
return ((await response.json()) as NodeVersion[]).map(({ version, lts }) => ({
version: version.substring(1),
lts,
}))
}
function getNodeBinsForCurrentOS (platform: string = process.platform): Record<string, string> {
if (platform === 'win32') {
return { node: 'node.exe' }
}
return { node: 'bin/node' }
}
function filterVersions (versions: NodeVersion[], versionSelector: string): { versions: string[], versionRange: string } {
if (versionSelector === 'lts') {
return {
versions: versions
.filter(({ lts }) => lts !== false)
.map(({ version }) => version),
versionRange: '*',
}
}
const vst = versionSelectorType(versionSelector)
if (vst?.type === 'tag') {
const wantedLtsVersion = vst.normalized.toLowerCase()
return {
versions: versions
.filter(({ lts }) => typeof lts === 'string' && lts.toLowerCase() === wantedLtsVersion)
.map(({ version }) => version),
versionRange: '*',
}
}
return {
versions: versions.map(({ version }) => version),
versionRange: versionSelector,
}
}