Files
pnpm/pnpm11/resolving/default-resolver/test/customResolver.ts
T
Zoltan Kochan c9b8632ea6 feat(config): declare each registry once in the registries setting (#13942)
Registries do not all lay out tarball URLs the way the npm registry does.
JFrog Artifactory repeats the scope in a scoped package's tarball filename
(`@acme/widget/-/@acme/widget-1.0.0.tgz`) where npm strips it. pnpm cannot
rebuild such a URL, so it writes it out for every scoped package instead of
omitting it, and the lockfile carries a host-specific URL per dependency.

pnpm had no place to record such a fact, because it had no place to
describe a registry at all — only three ways to name one. `registries`
mapped a scope to a URL, `namedRegistries` mapped a bare-specifier prefix
to a URL, and neither could carry anything else.

`registries` now declares a registry once, keyed by its URL, with every
fact about it in the entry: a `serverType`, the `scopes` routed to it,
and the `prefix` it answers to. `serverType` has three states:

  undeclared  strict; only the exact canonical URL is reconstructible
  npm         also serves the percent-encoded scoped path
  artifactory repeats the scope in the tarball filename

registry.npmjs.org resolves to `npm` as a built-in, so its behavior is
unchanged and the old hostname check becomes that one default rather than a
special case in the predicate. `npm` cannot be the default: asserting
npmjs-compatibility is a claim only the operator can make.

The URL is the key because every fact in an entry is a fact about that
server. Keying the layout by scope would bind it to whoever the scope
currently points at, so two developers whose scope resolves differently
would write lockfiles that disagree about which URLs may be omitted.
`scopes` and `prefix` are routes to the registry and are inverted at
config-read time into the two lookups the rest of pnpm already queries,
leaving the resolver, installer, and lockfile layers untouched and the
precedence chain (builtin < .npmrc < yaml < `_auth` < CLI) unchanged.

The layout is declared, never inferred. Sniffing the registry URL cannot work:
a virtual repository serves both layouts at once, depending on whether each
package was synced from upstream or published locally, so no registry-level
signal — route, response header, or probe — decides it. Declaring it also
keeps a wrong guess a fixable misconfiguration instead of a silent breakage.

`serverType` feeds a single URL builder that both sides of the lockfile use:
the writer omits a tarball URL only when the builder reproduces it, and the
reader rebuilds it with the same call. They therefore agree by construction,
so pnpm never has to assume a registry serves some second URL as well.

The setting lives in pnpm-workspace.yaml rather than .npmrc because the
lockfile depends on it: one developer omitting URLs that another reconstructs
differently would break a frozen install. A `serverType` in the global
config.yaml is ignored for the same reason, while the routes declared
alongside it are kept. Credentials are rejected there — the file is
committed — and still belong in .npmrc. The registry URL is the map key, so
the request-destination env gate applies to keys as well as values.
Credentials and unknown fields are refused after parsing, since a parse
error renders the offending source line verbatim.

A map whose values are all strings is the older `<scope>: <url>` shape and
is still read as one. Mixing the two shapes in one map is refused, and so is
a URL-keyed entry written as a string. `namedRegistries` is deprecated in
favor of `prefix` and is read only for prefixes `registries` does not
declare; a prefix stays singular because it is the registry's identity in a
lockfile dep path.

An entry that routes nothing to itself and matches no configured registry is
reported as a warning rather than silently ignored; it is a warning and not
an error because a shared config dependency can legitimately describe
registries a given project does not use.

Config dependencies and pnpr-server-mode resolution pass no server type; in
both, the writer and the reader share that default, so they stay consistent.

Closes pnpm/get-npm-tarball-url#16. Supersedes pnpm/pnpm#13920.
2026-08-17 01:28:21 +02:00

488 lines
16 KiB
TypeScript

/// <reference path="../../../__typings__/index.d.ts"/>
import { expect, jest, test } from '@jest/globals'
import type { CustomResolver, WantedDependency } from '@pnpm/hooks.types'
import { createResolver } from '@pnpm/resolving.default-resolver'
test('custom resolver intercepts matching packages', async () => {
const customResolver: CustomResolver = {
canResolve: (wantedDependency: WantedDependency) => {
return wantedDependency.alias === 'test-package'
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolve: async (wantedDependency: WantedDependency, _opts: any) => {
return {
id: `custom:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
resolution: {
type: 'directory',
directory: '/test/path',
},
}
},
}
const fetchFromRegistry = async (): Promise<Response> => new Response('')
const getAuthHeader = () => undefined
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
const result = await resolve(
{ alias: 'test-package', bareSpecifier: '1.0.0' },
{
lockfileDir: '/test',
projectDir: '/test',
preferredVersions: {},
}
)
expect(result.id).toBe('custom:test-package@1.0.0')
expect(result.resolvedVia).toBe('custom-resolver')
})
test('custom resolver with synchronous methods', async () => {
const customResolver: CustomResolver = {
// Synchronous support check
canResolve: (wantedDependency: WantedDependency) => {
return wantedDependency.alias!.startsWith('@sync/')
},
// Synchronous resolution
resolve: (wantedDependency: WantedDependency) => {
return {
id: `sync:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
resolution: {
tarball: `file://${wantedDependency.alias}-${wantedDependency.bareSpecifier}.tgz`,
integrity: 'sha512-test',
},
}
},
}
const fetchFromRegistry = async (): Promise<Response> => new Response('')
const getAuthHeader = () => undefined
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
const result = await resolve(
{ alias: '@sync/test', bareSpecifier: '2.0.0' },
{
lockfileDir: '/test',
projectDir: '/test',
preferredVersions: {},
}
)
expect(result.id).toBe('sync:@sync/test@2.0.0')
expect(result.resolvedVia).toBe('custom-resolver')
})
test('multiple custom resolvers - first matching wins', async () => {
const resolver1: CustomResolver = {
canResolve: (wantedDependency) => wantedDependency.alias === 'shared-package',
resolve: () => ({
id: 'resolver-1:shared-package',
resolution: { tarball: 'file://resolver1.tgz', integrity: 'sha512-1' },
}),
}
const resolver2: CustomResolver = {
canResolve: (wantedDependency) => wantedDependency.alias === 'shared-package',
resolve: () => ({
id: 'resolver-2:shared-package',
resolution: { tarball: 'file://resolver2.tgz', integrity: 'sha512-2' },
}),
}
const fetchFromRegistry = async (): Promise<Response> => new Response('')
const getAuthHeader = () => undefined
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
customResolvers: [resolver1, resolver2], // Order matters
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
const result = await resolve(
{ alias: 'shared-package', bareSpecifier: '1.0.0' },
{
lockfileDir: '/test',
projectDir: '/test',
preferredVersions: {},
}
)
// First custom resolver should win
expect(result.id).toBe('resolver-1:shared-package')
expect(result.resolvedVia).toBe('custom-resolver')
})
test('custom resolver error handling', async () => {
const customResolver: CustomResolver = {
canResolve: () => true,
resolve: () => {
throw new Error('Custom resolver failed')
},
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
await expect(resolve({ alias: 'any', bareSpecifier: '1.0.0' }, { lockfileDir: '/test', projectDir: '/test', preferredVersions: {} })).rejects.toThrow('Custom resolver failed')
})
test('preferredVersions are passed to custom resolver', async () => {
const resolve = jest.fn<NonNullable<CustomResolver['resolve']>>(() => ({
id: 'test@1.0.0',
resolution: { tarball: 'file://test.tgz', integrity: 'sha512-test' },
}))
const customResolver: CustomResolver = {
canResolve: () => true,
resolve,
}
const { resolve: resolvePackage } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
await resolvePackage(
{ alias: 'any', bareSpecifier: '1.0.0' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: { any: { '1.0.0': 'version' } } as unknown as Record<string, Record<string, 'version' | 'range' | 'tag'>> }
)
expect(resolve).toHaveBeenCalledWith(
{ alias: 'any', bareSpecifier: '1.0.0' },
expect.objectContaining({
lockfileDir: '/test',
projectDir: '/test',
preferredVersions: { any: { '1.0.0': 'version' } },
})
)
})
test('custom resolver can intercept any protocol', async () => {
const customResolver: CustomResolver = {
canResolve: (wantedDependency: WantedDependency) => {
return wantedDependency.alias!.startsWith('custom-')
},
resolve: (wantedDependency: WantedDependency) => ({
id: `custom-handled:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
resolution: {
type: 'custom:test',
directory: `/custom/${wantedDependency.alias}`,
},
}),
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
const result = await resolve(
{ alias: 'custom-package', bareSpecifier: 'file:../some-path' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
)
expect(result.resolvedVia).toBe('custom-resolver')
expect(result.id).toBe('custom-handled:custom-package@file:../some-path')
})
test('custom resolver falls through when not supported', async () => {
const customResolver: CustomResolver = {
canResolve: (wantedDependency: WantedDependency) => {
return wantedDependency.alias!.startsWith('custom-')
},
resolve: (wantedDependency: WantedDependency) => ({
id: `custom:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
resolution: { type: 'custom:test', directory: '/custom' },
}),
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
await expect(
resolve(
{ alias: 'regular-package', bareSpecifier: 'file:../nonexistent' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
)
).rejects.toThrow()
})
test('custom resolver can override npm registry resolution', async () => {
const npmStyleResolver: CustomResolver = {
canResolve: (wantedDependency) => {
return !wantedDependency.bareSpecifier!.includes(':')
},
resolve: (wantedDependency) => ({
id: `custom-registry:${wantedDependency.alias}@${wantedDependency.bareSpecifier}`,
resolution: {
tarball: `https://custom-registry.com/${wantedDependency.alias}/-/${wantedDependency.alias}-${wantedDependency.bareSpecifier}.tgz`,
integrity: 'sha512-custom',
},
}),
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [npmStyleResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
storeDir: '.store',
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
const result = await resolve(
{ alias: 'express', bareSpecifier: '^4.0.0' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
)
expect(result.resolvedVia).toBe('custom-resolver')
expect('tarball' in result.resolution && result.resolution.tarball).toContain('custom-registry.com')
})
// Fetch phase custom fetcher tests - showing complete fetcher replacements
test('custom custom fetcher: reuse local tarball fetcher', async () => {
// This demonstrates how a custom resolver can reuse pnpm's local tarball fetcher
// for a custom protocol like "company-local:package-name"
const localTarballResolver: CustomResolver = {
canResolve: (wantedDependency) => wantedDependency.alias!.startsWith('company-local:'),
resolve: (wantedDependency) => {
const actualName = wantedDependency.alias!.replace('company-local:', '')
return {
id: wantedDependency.alias!,
resolution: {
type: 'custom:local',
localPath: `/company/tarballs/${actualName}-${wantedDependency.bareSpecifier}.tgz`,
},
}
},
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [localTarballResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
registriesByScope: { default: 'https://registry.npmjs.org/' },
storeDir: '.store',
})
const result = await resolve(
{ alias: 'company-local:my-package', bareSpecifier: '1.0.0' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
)
expect(result.resolvedVia).toBe('custom-resolver')
expect(result.resolution).toHaveProperty('type', 'custom:local')
})
test('custom custom fetcher: reuse remote tarball downloader', async () => {
// This demonstrates fetching from a custom CDN using pnpm's download utilities
// for a custom protocol like "cdn:package-name"
const cdnResolver: CustomResolver = {
canResolve: (wantedDependency) => wantedDependency.alias!.startsWith('cdn:'),
resolve: (wantedDependency) => {
const actualName = wantedDependency.alias!.replace('cdn:', '')
return {
id: wantedDependency.alias!,
resolution: {
type: 'custom:cdn',
cdnUrl: `https://cdn.example.com/packages/${actualName}/${wantedDependency.bareSpecifier}/${actualName}-${wantedDependency.bareSpecifier}.tgz`,
},
}
},
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [cdnResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
registriesByScope: { default: 'https://registry.npmjs.org/' },
storeDir: '.store',
})
const result = await resolve(
{ alias: 'cdn:awesome-lib', bareSpecifier: '2.0.0' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
)
expect(result.resolvedVia).toBe('custom-resolver')
expect(result.resolution).toHaveProperty('type', 'custom:cdn')
})
test('custom custom fetcher: wrap npm registry with custom logic', async () => {
// This demonstrates wrapping/enhancing standard npm registry resolution and fetching
// for a protocol like "private-npm:package-name" that uses private registry
const privateNpmResolver: CustomResolver = {
canResolve: (wantedDependency) => wantedDependency.alias!.startsWith('private-npm:'),
resolve: async (wantedDependency, _opts) => {
const actualName = wantedDependency.alias!.replace('private-npm:', '')
// In a real implementation, you'd fetch from your private registry here
// For this test, we mock the registry response
return {
id: `private-npm:${actualName}@${wantedDependency.bareSpecifier}`,
resolution: {
tarball: `https://private-registry.company.com/${actualName}/-/${actualName}-${wantedDependency.bareSpecifier}.tgz`,
integrity: 'sha512-mock-integrity',
registry: 'https://private-registry.company.com/',
},
}
},
}
const { resolve } = createResolver(async () => new Response(''), () => undefined, {
customResolvers: [privateNpmResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
registriesByScope: { default: 'https://registry.npmjs.org/' },
storeDir: '.store',
})
const result = await resolve(
{ alias: 'private-npm:company-utils', bareSpecifier: '3.0.0' },
{ lockfileDir: '/test', projectDir: '/test', preferredVersions: {} }
)
expect(result.resolvedVia).toBe('custom-resolver')
expect('tarball' in result.resolution && result.resolution.tarball).toContain('private-registry.company.com')
})
test('custom resolver receives currentPkg when provided', async () => {
let receivedCurrentPkg: unknown = null
const customResolver: CustomResolver = {
canResolve: (wantedDependency: WantedDependency) => {
return wantedDependency.alias === 'test-package'
},
resolve: async (wantedDependency: WantedDependency, opts) => {
receivedCurrentPkg = opts.currentPkg
// If currentPkg is provided, return existing resolution
if (opts.currentPkg) {
return {
id: opts.currentPkg.id,
resolution: opts.currentPkg.resolution,
}
}
return {
id: `custom:${wantedDependency.alias}@1.0.0`,
resolution: {
type: 'directory',
directory: '/test/path',
},
}
},
}
const fetchFromRegistry = async (): Promise<Response> => new Response('')
const getAuthHeader = () => undefined
const { resolve } = createResolver(fetchFromRegistry, getAuthHeader, {
customResolvers: [customResolver],
cacheDir: '/tmp/test-cache',
offline: false,
preferOffline: false,
retry: {},
timeout: 60000,
registriesByScope: { default: 'https://registry.npmjs.org/' },
})
// First call without currentPkg
const result1 = await resolve(
{ alias: 'test-package', bareSpecifier: '1.0.0' },
{
lockfileDir: '/test',
projectDir: '/test',
preferredVersions: {},
}
)
expect(result1.id).toBe('custom:test-package@1.0.0')
expect(receivedCurrentPkg).toBeUndefined()
// Second call with currentPkg
const existingResolution = {
type: 'directory' as const,
directory: '/existing/path',
}
const result2 = await resolve(
{ alias: 'test-package', bareSpecifier: '1.0.0' },
{
lockfileDir: '/test',
projectDir: '/test',
preferredVersions: {},
currentPkg: {
id: 'existing:test-package@1.0.0' as any, // eslint-disable-line @typescript-eslint/no-explicit-any
resolution: existingResolution,
},
}
)
expect(receivedCurrentPkg).toBeTruthy()
expect((receivedCurrentPkg as any).id).toBe('existing:test-package@1.0.0') // eslint-disable-line @typescript-eslint/no-explicit-any
expect(result2.id).toBe('existing:test-package@1.0.0')
expect(result2.resolution).toBe(existingResolution)
})