mirror of
https://github.com/pnpm/pnpm.git
synced 2025-12-23 23:29:17 -05:00
feat: add custom resolvers and fetchers (#10246)
This commit is contained in:
22
.changeset/sour-hoops-allow.md
Normal file
22
.changeset/sour-hoops-allow.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
"@pnpm/pick-fetcher": major
|
||||
"@pnpm/resolve-dependencies": minor
|
||||
"@pnpm/plugin-commands-deploy": minor
|
||||
"@pnpm/store-connection-manager": minor
|
||||
"@pnpm/default-resolver": minor
|
||||
"@pnpm/license-scanner": minor
|
||||
"@pnpm/calc-dep-state": minor
|
||||
"@pnpm/resolver-base": minor
|
||||
"@pnpm/package-store": minor
|
||||
"@pnpm/client": minor
|
||||
"@pnpm/core": minor
|
||||
"@pnpm/pnpmfile": minor
|
||||
"@pnpm/lockfile.types": minor
|
||||
"@pnpm/hooks.types": minor
|
||||
"@pnpm/package-requester": patch
|
||||
"@pnpm/tarball-fetcher": patch
|
||||
"@pnpm/deps.graph-builder": patch
|
||||
"@pnpm/lockfile.utils": patch
|
||||
---
|
||||
|
||||
Support for custom resolvers and fetchers.
|
||||
1
deps/graph-builder/package.json
vendored
1
deps/graph-builder/package.json
vendored
@@ -34,6 +34,7 @@
|
||||
"@pnpm/constants": "workspace:*",
|
||||
"@pnpm/core-loggers": "workspace:*",
|
||||
"@pnpm/dependency-path": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/lockfile.fs": "workspace:*",
|
||||
"@pnpm/lockfile.utils": "workspace:*",
|
||||
"@pnpm/modules-yaml": "workspace:*",
|
||||
|
||||
3
deps/graph-builder/tsconfig.json
vendored
3
deps/graph-builder/tsconfig.json
vendored
@@ -12,6 +12,9 @@
|
||||
{
|
||||
"path": "../../config/package-is-installable"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../lockfile/fs"
|
||||
},
|
||||
|
||||
@@ -30,11 +30,22 @@
|
||||
"prepublishOnly": "pnpm run compile",
|
||||
"compile": "tsc --build && pnpm run lint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/cafs-types": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/fetcher-base": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/fetcher-base": "workspace:*",
|
||||
"@pnpm/create-cafs-store": "workspace:*",
|
||||
"@pnpm/fetch": "workspace:*",
|
||||
"@pnpm/pick-fetcher": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*"
|
||||
"@pnpm/tarball-fetcher": "workspace:*",
|
||||
"@pnpm/test-fixtures": "workspace:*",
|
||||
"nock": "catalog:",
|
||||
"tempy": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
|
||||
@@ -1,23 +1,67 @@
|
||||
import type { AtomicResolution } from '@pnpm/resolver-base'
|
||||
import type { Fetchers, FetchFunction, DirectoryFetcher, GitFetcher, BinaryFetcher } from '@pnpm/fetcher-base'
|
||||
import type { Fetchers, FetchFunction, DirectoryFetcher, GitFetcher, BinaryFetcher, FetchOptions } from '@pnpm/fetcher-base'
|
||||
import type { Cafs } from '@pnpm/cafs-types'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { type CustomFetcher } from '@pnpm/hooks.types'
|
||||
|
||||
export function pickFetcher (fetcherByHostingType: Partial<Fetchers>, resolution: AtomicResolution): FetchFunction | DirectoryFetcher | GitFetcher | BinaryFetcher {
|
||||
let fetcherType: keyof Fetchers | undefined = resolution.type
|
||||
export async function pickFetcher (
|
||||
fetcherByHostingType: Fetchers,
|
||||
resolution: AtomicResolution,
|
||||
opts?: {
|
||||
customFetchers?: CustomFetcher[]
|
||||
packageId: string
|
||||
}
|
||||
): Promise<FetchFunction | DirectoryFetcher | GitFetcher | BinaryFetcher> {
|
||||
// Try custom fetcher hooks first if available
|
||||
// Custom fetchers act as complete fetcher replacements
|
||||
if (opts?.customFetchers && opts.customFetchers.length > 0) {
|
||||
for (const customFetcher of opts.customFetchers) {
|
||||
if (customFetcher.canFetch && customFetcher.fetch) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const canFetch = await customFetcher.canFetch(opts.packageId, resolution)
|
||||
|
||||
if (resolution.type == null) {
|
||||
if (resolution.tarball.startsWith('file:')) {
|
||||
fetcherType = 'localTarball'
|
||||
} else if (isGitHostedPkgUrl(resolution.tarball)) {
|
||||
fetcherType = 'gitHostedTarball'
|
||||
} else {
|
||||
fetcherType = 'remoteTarball'
|
||||
if (canFetch) {
|
||||
// Return a wrapper FetchFunction that calls the custom fetcher's fetch method
|
||||
// The custom fetcher's fetch receives cafs, resolution, opts, and the standard fetchers for delegation
|
||||
return async (cafs: Cafs, resolution: AtomicResolution, fetchOpts: FetchOptions) => {
|
||||
return customFetcher.fetch!(cafs, resolution, fetchOpts, fetcherByHostingType)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetch = fetcherByHostingType[fetcherType!]
|
||||
// No custom fetcher handled the fetch, use standard fetcher selection
|
||||
let fetcherType: keyof Fetchers | undefined
|
||||
|
||||
// Determine the fetcher type based on resolution
|
||||
if (resolution.type == null) {
|
||||
// Tarball resolution without explicit type
|
||||
if ('tarball' in resolution && resolution.tarball) {
|
||||
if (resolution.tarball.startsWith('file:')) {
|
||||
fetcherType = 'localTarball'
|
||||
} else if (isGitHostedPkgUrl(resolution.tarball)) {
|
||||
fetcherType = 'gitHostedTarball'
|
||||
} else {
|
||||
fetcherType = 'remoteTarball'
|
||||
}
|
||||
}
|
||||
} else if (resolution.type === 'directory' || resolution.type === 'git' || resolution.type === 'binary') {
|
||||
// Standard resolution types that map directly to fetchers
|
||||
fetcherType = resolution.type
|
||||
} else {
|
||||
// Custom resolution type that wasn't handled by any custom fetcher
|
||||
throw new PnpmError(
|
||||
'UNSUPPORTED_RESOLUTION_TYPE',
|
||||
`Cannot fetch dependency with custom resolution type "${resolution.type}". ` +
|
||||
'Custom resolutions must be handled by custom fetchers.'
|
||||
)
|
||||
}
|
||||
|
||||
const fetch = fetcherType != null ? fetcherByHostingType[fetcherType] : undefined
|
||||
|
||||
if (!fetch) {
|
||||
throw new Error(`Fetching for dependency type "${resolution.type ?? 'undefined'}" is not supported`)
|
||||
throw new Error(`Fetching for dependency type "${resolution.type ?? 'tarball'}" is not supported`)
|
||||
}
|
||||
|
||||
return fetch
|
||||
|
||||
558
fetching/pick-fetcher/test/customFetch.ts
Normal file
558
fetching/pick-fetcher/test/customFetch.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { pickFetcher } from '@pnpm/pick-fetcher'
|
||||
import { jest } from '@jest/globals'
|
||||
import { createTarballFetcher } from '@pnpm/tarball-fetcher'
|
||||
import { createFetchFromRegistry } from '@pnpm/fetch'
|
||||
import { createCafsStore } from '@pnpm/create-cafs-store'
|
||||
import { fixtures } from '@pnpm/test-fixtures'
|
||||
import { temporaryDirectory } from 'tempy'
|
||||
import path from 'path'
|
||||
import nock from 'nock'
|
||||
import type { Cafs } from '@pnpm/cafs-types'
|
||||
import type { FetchFunction, Fetchers, FetchOptions } from '@pnpm/fetcher-base'
|
||||
import type { AtomicResolution } from '@pnpm/resolver-base'
|
||||
import type { CustomFetcher } from '@pnpm/hooks.types'
|
||||
|
||||
const f = fixtures(import.meta.dirname)
|
||||
|
||||
// Test helpers to reduce type casting
|
||||
function createMockFetchers (partial: Partial<Fetchers> = {}): Fetchers {
|
||||
const noop = jest.fn() as FetchFunction
|
||||
return {
|
||||
localTarball: noop,
|
||||
remoteTarball: noop,
|
||||
gitHostedTarball: noop,
|
||||
directory: noop as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
git: noop as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
binary: noop as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCafs (partial: Partial<Cafs> = {}): Cafs {
|
||||
return {
|
||||
addFilesFromDir: jest.fn(),
|
||||
addFilesFromTarball: jest.fn() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
...partial,
|
||||
} as Cafs
|
||||
}
|
||||
|
||||
function createMockResolution (resolution: Partial<AtomicResolution> & Record<string, any>): any { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
return resolution
|
||||
}
|
||||
|
||||
function createMockFetchOptions (opts: Partial<FetchOptions> = {}): any { // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
return opts
|
||||
}
|
||||
|
||||
function createMockCustomFetcher (
|
||||
canFetch: CustomFetcher['canFetch'],
|
||||
fetch: CustomFetcher['fetch']
|
||||
): CustomFetcher {
|
||||
return { canFetch, fetch }
|
||||
}
|
||||
|
||||
/**
|
||||
* These tests demonstrate realistic custom fetcher implementations and verify
|
||||
* that the custom fetcher API works correctly for common use cases.
|
||||
*/
|
||||
|
||||
describe('custom fetcher implementation examples', () => {
|
||||
describe('basic custom fetcher contract', () => {
|
||||
test('should successfully return FetchResult with manifest and filesIndex', async () => {
|
||||
const mockManifest = { name: 'test-package', version: '1.0.0' }
|
||||
const mockFilesIndex = { 'package.json': '/path/to/store/package.json' }
|
||||
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
() => true,
|
||||
async () => ({
|
||||
filesIndex: mockFilesIndex,
|
||||
manifest: mockManifest,
|
||||
requiresBuild: false,
|
||||
})
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
{ customFetchers: [customFetcher], packageId: 'test-package@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
createMockCafs(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
createMockFetchOptions()
|
||||
)
|
||||
|
||||
expect(result.manifest).toEqual(mockManifest)
|
||||
expect(result.filesIndex).toEqual(mockFilesIndex)
|
||||
expect(result.requiresBuild).toBe(false)
|
||||
})
|
||||
|
||||
test('should handle requiresBuild flag correctly', async () => {
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
() => true,
|
||||
async () => ({
|
||||
filesIndex: {},
|
||||
manifest: { name: 'pkg', version: '1.0.0', scripts: { install: 'node install.js' } },
|
||||
requiresBuild: true,
|
||||
})
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
{ customFetchers: [customFetcher], packageId: 'pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
createMockCafs(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
createMockFetchOptions()
|
||||
)
|
||||
|
||||
expect(result.requiresBuild).toBe(true)
|
||||
})
|
||||
|
||||
test('should propagate errors from custom fetcher', async () => {
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
() => true,
|
||||
async () => {
|
||||
throw new Error('Network error during fetch')
|
||||
}
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
{ customFetchers: [customFetcher], packageId: 'pkg@1.0.0' }
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetcher(
|
||||
createMockCafs(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
createMockFetchOptions()
|
||||
)
|
||||
).rejects.toThrow('Network error during fetch')
|
||||
})
|
||||
|
||||
test('should pass CAFS to custom fetcher for file operations', async () => {
|
||||
let receivedCafs: Cafs | null = null
|
||||
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
() => true,
|
||||
async (cafs) => {
|
||||
receivedCafs = cafs
|
||||
return {
|
||||
filesIndex: {},
|
||||
manifest: { name: 'pkg', version: '1.0.0' },
|
||||
requiresBuild: false,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
{ customFetchers: [customFetcher], packageId: 'pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const mockCafs = createMockCafs({ addFilesFromTarball: jest.fn() as any }) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
await fetcher(
|
||||
mockCafs,
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
createMockFetchOptions()
|
||||
)
|
||||
|
||||
expect(receivedCafs).toBe(mockCafs)
|
||||
})
|
||||
|
||||
test('should pass progress callbacks to custom fetcher', async () => {
|
||||
const onStartFn = jest.fn()
|
||||
const onProgressFn = jest.fn()
|
||||
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
() => true,
|
||||
async (_cafs, _resolution, opts) => {
|
||||
// Custom fetcher can call progress callbacks
|
||||
opts.onStart?.(100, 1)
|
||||
;(opts.onProgress as any)?.({ done: 50, total: 100 }) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
return {
|
||||
filesIndex: {},
|
||||
manifest: { name: 'pkg', version: '1.0.0' },
|
||||
requiresBuild: false,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
{ customFetchers: [customFetcher], packageId: 'pkg@1.0.0' }
|
||||
)
|
||||
|
||||
await fetcher(
|
||||
createMockCafs(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
createMockFetchOptions({ onStart: onStartFn, onProgress: onProgressFn })
|
||||
)
|
||||
|
||||
expect(onStartFn).toHaveBeenCalledWith(100, 1)
|
||||
expect(onProgressFn).toHaveBeenCalledWith({ done: 50, total: 100 })
|
||||
})
|
||||
|
||||
test('should work with custom resolution types', async () => {
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:cdn',
|
||||
cdnUrl: 'https://cdn.example.com/pkg.tgz',
|
||||
})
|
||||
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:cdn',
|
||||
async (_cafs, resolution) => {
|
||||
// Custom fetcher can access custom resolution fields
|
||||
expect(resolution.type).toBe('custom:cdn')
|
||||
expect((resolution as any).cdnUrl).toBe('https://cdn.example.com/pkg.tgz') // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
return {
|
||||
filesIndex: {},
|
||||
manifest: { name: 'pkg', version: '1.0.0' },
|
||||
requiresBuild: false,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'pkg@1.0.0' }
|
||||
)
|
||||
|
||||
await fetcher(createMockCafs(), customResolution, createMockFetchOptions())
|
||||
})
|
||||
|
||||
test('should allow custom fetcher.fetch to return partial manifest', async () => {
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
() => true,
|
||||
async () => ({
|
||||
filesIndex: {},
|
||||
requiresBuild: false,
|
||||
// Manifest is optional in FetchResult
|
||||
})
|
||||
)
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
{ customFetchers: [customFetcher], packageId: 'pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
createMockCafs(),
|
||||
createMockResolution({ tarball: 'http://example.com/package.tgz' }),
|
||||
createMockFetchOptions()
|
||||
)
|
||||
|
||||
expect(result.manifest).toBeUndefined()
|
||||
expect(result.filesIndex).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delegating to tarball fetcher', () => {
|
||||
const registry = 'http://localhost:4873/'
|
||||
const tarballPath = f.find('babel-helper-hoist-variables-6.24.1.tgz')
|
||||
const tarballIntegrity = 'sha1-HssnaJydJVE+rbyZFKc/VAi+enY='
|
||||
|
||||
test('custom fetcher can delegate to remoteTarball fetcher', async () => {
|
||||
const scope = nock(registry)
|
||||
.get('/custom-pkg.tgz')
|
||||
.replyWithFile(200, tarballPath, {
|
||||
'Content-Length': '1279',
|
||||
})
|
||||
|
||||
const storeDir = temporaryDirectory()
|
||||
const cafs = createCafsStore(storeDir)
|
||||
const filesIndexFile = path.join(storeDir, 'index.json')
|
||||
|
||||
// Create standard fetchers to pass to custom fetcher
|
||||
const fetchFromRegistry = createFetchFromRegistry({})
|
||||
const tarballFetchers = createTarballFetcher(
|
||||
fetchFromRegistry,
|
||||
() => undefined,
|
||||
{ rawConfig: {} }
|
||||
)
|
||||
|
||||
// Custom fetcher that maps custom URLs to tarballs
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:url' && Boolean((resolution as any).customUrl), // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
async (cafs, resolution, opts, fetchers) => {
|
||||
// Map custom resolution to tarball resolution
|
||||
const tarballResolution = {
|
||||
tarball: (resolution as any).customUrl, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
integrity: tarballIntegrity,
|
||||
}
|
||||
|
||||
// Delegate to standard tarball fetcher (passed via fetchers parameter)
|
||||
return fetchers.remoteTarball(cafs, tarballResolution, opts)
|
||||
}
|
||||
)
|
||||
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:url',
|
||||
customUrl: `${registry}custom-pkg.tgz`,
|
||||
})
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
tarballFetchers as Fetchers,
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'custom-pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
cafs,
|
||||
customResolution,
|
||||
createMockFetchOptions({ filesIndexFile, lockfileDir: process.cwd() })
|
||||
)
|
||||
|
||||
expect(result.filesIndex['package.json']).toBeTruthy()
|
||||
expect(scope.isDone()).toBeTruthy()
|
||||
})
|
||||
|
||||
test('custom fetcher can delegate to localTarball fetcher', async () => {
|
||||
const storeDir = temporaryDirectory()
|
||||
const cafs = createCafsStore(storeDir)
|
||||
const filesIndexFile = path.join(storeDir, 'index.json')
|
||||
|
||||
const fetchFromRegistry = createFetchFromRegistry({})
|
||||
const tarballFetchers = createTarballFetcher(
|
||||
fetchFromRegistry,
|
||||
() => undefined,
|
||||
{ rawConfig: {} }
|
||||
)
|
||||
|
||||
// Custom fetcher that maps custom local paths to tarballs
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:local' && Boolean((resolution as any).localPath), // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
async (cafs, resolution, opts, fetchers) => {
|
||||
const tarballResolution = {
|
||||
tarball: `file:${(resolution as any).localPath}`, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
integrity: tarballIntegrity,
|
||||
}
|
||||
|
||||
return fetchers.localTarball(cafs, tarballResolution, opts)
|
||||
}
|
||||
)
|
||||
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:local',
|
||||
localPath: tarballPath,
|
||||
})
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
tarballFetchers as Fetchers,
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'local-pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
cafs,
|
||||
customResolution,
|
||||
createMockFetchOptions({ filesIndexFile, lockfileDir: process.cwd() })
|
||||
)
|
||||
|
||||
expect(result.filesIndex['package.json']).toBeTruthy()
|
||||
})
|
||||
|
||||
test('custom fetcher can transform resolution before delegating to tarball fetcher', async () => {
|
||||
const scope = nock(registry)
|
||||
.get('/transformed-pkg.tgz')
|
||||
.replyWithFile(200, tarballPath, {
|
||||
'Content-Length': '1279',
|
||||
})
|
||||
|
||||
const storeDir = temporaryDirectory()
|
||||
const cafs = createCafsStore(storeDir)
|
||||
const filesIndexFile = path.join(storeDir, 'index.json')
|
||||
|
||||
const fetchFromRegistry = createFetchFromRegistry({})
|
||||
const tarballFetchers = createTarballFetcher(
|
||||
fetchFromRegistry,
|
||||
() => undefined,
|
||||
{ rawConfig: {} }
|
||||
)
|
||||
|
||||
// Custom fetcher that transforms custom resolution to tarball URL
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:registry',
|
||||
async (cafs, resolution, opts, fetchers) => {
|
||||
// Transform custom registry format to standard tarball URL
|
||||
const tarballUrl = `${registry}${(resolution as any).packageName}.tgz` // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const tarballResolution = {
|
||||
tarball: tarballUrl,
|
||||
integrity: tarballIntegrity,
|
||||
}
|
||||
|
||||
return fetchers.remoteTarball(cafs, tarballResolution, opts)
|
||||
}
|
||||
)
|
||||
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:registry',
|
||||
packageName: 'transformed-pkg',
|
||||
})
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
tarballFetchers as Fetchers,
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'transformed-pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
cafs,
|
||||
customResolution,
|
||||
createMockFetchOptions({ filesIndexFile, lockfileDir: process.cwd() })
|
||||
)
|
||||
|
||||
expect(result.filesIndex['package.json']).toBeTruthy()
|
||||
expect(scope.isDone()).toBeTruthy()
|
||||
})
|
||||
|
||||
test('custom fetcher can use gitHostedTarball fetcher for custom git URLs', async () => {
|
||||
const storeDir = temporaryDirectory()
|
||||
const cafs = createCafsStore(storeDir)
|
||||
const filesIndexFile = path.join(storeDir, 'index.json')
|
||||
|
||||
const fetchFromRegistry = createFetchFromRegistry({})
|
||||
const tarballFetchers = createTarballFetcher(
|
||||
fetchFromRegistry,
|
||||
() => undefined,
|
||||
{ rawConfig: {}, ignoreScripts: true }
|
||||
)
|
||||
|
||||
// Custom fetcher that maps custom git resolution to git-hosted tarball
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:git',
|
||||
async (cafs, resolution, opts, fetchers) => {
|
||||
// Map custom git resolution to GitHub codeload URL
|
||||
const tarballResolution = {
|
||||
tarball: `https://codeload.github.com/${(resolution as any).repo}/tar.gz/${(resolution as any).commit}`, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
return fetchers.gitHostedTarball(cafs, tarballResolution, opts)
|
||||
}
|
||||
)
|
||||
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:git',
|
||||
repo: 'sveltejs/action-deploy-docs',
|
||||
commit: 'a65fbf5a90f53c9d72fed4daaca59da50f074355',
|
||||
})
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
tarballFetchers as Fetchers,
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'git-pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(
|
||||
cafs,
|
||||
customResolution,
|
||||
createMockFetchOptions({ filesIndexFile, lockfileDir: process.cwd() })
|
||||
)
|
||||
|
||||
expect(result.filesIndex).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom fetch implementations', () => {
|
||||
test('custom fetcher can implement custom caching logic', async () => {
|
||||
const fetchCalls: number[] = []
|
||||
const cache = new Map<string, any>() // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:cached',
|
||||
async (_cafs, resolution) => {
|
||||
fetchCalls.push(Date.now())
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${(resolution as any).url}@${(resolution as any).version}` // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
if (cache.has(cacheKey)) {
|
||||
return cache.get(cacheKey)
|
||||
}
|
||||
|
||||
// Simulate fetch
|
||||
const result = {
|
||||
filesIndex: { 'package.json': '/store/pkg.json' },
|
||||
manifest: { name: 'cached-pkg', version: (resolution as any).version }, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
cache.set(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:cached',
|
||||
url: 'https://cache.example.com/pkg',
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'cached-pkg@1.0.0' }
|
||||
)
|
||||
|
||||
// First fetch - should hit the fetch logic
|
||||
const result1 = await fetcher(createMockCafs(), customResolution, createMockFetchOptions())
|
||||
|
||||
// Second fetch - should use cache
|
||||
const result2 = await fetcher(createMockCafs(), customResolution, createMockFetchOptions())
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
expect(fetchCalls).toHaveLength(2) // Fetcher called twice, but cache hit on second call
|
||||
})
|
||||
|
||||
test('custom fetcher can implement authentication and token refresh', async () => {
|
||||
let authToken = 'initial-token'
|
||||
const authCalls: string[] = []
|
||||
|
||||
const customFetcher = createMockCustomFetcher(
|
||||
(_pkgId, resolution) => resolution.type === 'custom:auth',
|
||||
async () => {
|
||||
authCalls.push(authToken)
|
||||
|
||||
// Simulate token refresh on 401
|
||||
if (authToken === 'initial-token') {
|
||||
authToken = 'refreshed-token'
|
||||
}
|
||||
|
||||
return {
|
||||
filesIndex: {},
|
||||
manifest: { name: 'auth-pkg', version: '1.0.0' },
|
||||
requiresBuild: false,
|
||||
authToken, // Could store for future use
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const customResolution = createMockResolution({
|
||||
type: 'custom:auth',
|
||||
url: 'https://secure.example.com/pkg',
|
||||
})
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers(),
|
||||
customResolution,
|
||||
{ customFetchers: [customFetcher], packageId: 'auth-pkg@1.0.0' }
|
||||
)
|
||||
|
||||
const result = await fetcher(createMockCafs(), customResolution, createMockFetchOptions())
|
||||
|
||||
expect(authCalls).toEqual(['initial-token'])
|
||||
expect((result as any).authToken).toBe('refreshed-token') // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,31 @@
|
||||
import { pickFetcher } from '@pnpm/pick-fetcher'
|
||||
import { jest } from '@jest/globals'
|
||||
import { type FetchFunction } from '@pnpm/fetcher-base'
|
||||
import { type FetchFunction, type Fetchers } from '@pnpm/fetcher-base'
|
||||
import { type CustomFetcher } from '@pnpm/hooks.types'
|
||||
|
||||
test('should pick localTarball fetcher', () => {
|
||||
// Helper to create a mock Fetchers object with only the needed fetcher
|
||||
function createMockFetchers (partial: Partial<Fetchers>): Fetchers {
|
||||
const noop = jest.fn() as FetchFunction
|
||||
return {
|
||||
localTarball: noop,
|
||||
remoteTarball: noop,
|
||||
gitHostedTarball: noop,
|
||||
directory: noop as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
git: noop as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
binary: noop as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
test('should pick localTarball fetcher', async () => {
|
||||
const localTarball = jest.fn() as FetchFunction
|
||||
const fetcher = pickFetcher({ localTarball }, { tarball: 'file:is-positive-1.0.0.tgz' })
|
||||
const fetcher = await pickFetcher(createMockFetchers({ localTarball }), { tarball: 'file:is-positive-1.0.0.tgz' })
|
||||
expect(fetcher).toBe(localTarball)
|
||||
})
|
||||
|
||||
test('should pick remoteTarball fetcher', () => {
|
||||
test('should pick remoteTarball fetcher', async () => {
|
||||
const remoteTarball = jest.fn() as FetchFunction
|
||||
const fetcher = pickFetcher({ remoteTarball }, { tarball: 'is-positive-1.0.0.tgz' })
|
||||
const fetcher = await pickFetcher(createMockFetchers({ remoteTarball }), { tarball: 'is-positive-1.0.0.tgz' })
|
||||
expect(fetcher).toBe(remoteTarball)
|
||||
})
|
||||
|
||||
@@ -18,14 +33,232 @@ test.each([
|
||||
'https://codeload.github.com/zkochan/is-negative/tar.gz/2fa0531ab04e300a24ef4fd7fb3a280eccb7ccc5',
|
||||
'https://bitbucket.org/pnpmjs/git-resolver/get/87cf6a67064d2ce56e8cd20624769a5512b83ff9.tar.gz',
|
||||
'https://gitlab.com/api/v4/projects/pnpm%2Fgit-resolver/repository/archive.tar.gz',
|
||||
])('should pick gitHostedTarball fetcher', (tarball) => {
|
||||
])('should pick gitHostedTarball fetcher', async (tarball) => {
|
||||
const gitHostedTarball = jest.fn() as FetchFunction
|
||||
const fetcher = pickFetcher({ gitHostedTarball }, { tarball })
|
||||
const fetcher = await pickFetcher(createMockFetchers({ gitHostedTarball }), { tarball })
|
||||
expect(fetcher).toBe(gitHostedTarball)
|
||||
})
|
||||
|
||||
test('should fail to pick fetcher if the type is not defined', () => {
|
||||
expect(() => {
|
||||
pickFetcher({}, { type: 'directory', directory: expect.anything() })
|
||||
}).toThrow('Fetching for dependency type "directory" is not supported')
|
||||
test('should fail to pick fetcher if the type is not defined', async () => {
|
||||
await expect(async () => {
|
||||
// This test specifically needs an incomplete Fetchers object to test error handling
|
||||
await pickFetcher({} as any, { type: 'directory', directory: expect.anything() } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}).rejects.toThrow('Fetching for dependency type "directory" is not supported')
|
||||
})
|
||||
|
||||
describe('custom fetcher support', () => {
|
||||
test('should use custom fetcher when canFetch returns true', async () => {
|
||||
const mockFetchResult = { filesIndex: {}, manifest: { name: 'test', version: '1.0.0' }, requiresBuild: false }
|
||||
const customFetch = jest.fn(async () => mockFetchResult)
|
||||
const remoteTarball = jest.fn() as FetchFunction
|
||||
|
||||
const customFetcher: Partial<CustomFetcher> = {
|
||||
canFetch: () => true,
|
||||
fetch: customFetch,
|
||||
}
|
||||
|
||||
const mockFetchers = createMockFetchers({ remoteTarball })
|
||||
const fetcher = await pickFetcher(
|
||||
mockFetchers,
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
{
|
||||
customFetchers: [customFetcher as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
expect(typeof fetcher).toBe('function')
|
||||
|
||||
// Call the fetcher and verify it uses the custom fetch function
|
||||
const mockCafs = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockResolution = { tarball: 'http://example.com/package.tgz' } as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockFetchOpts = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const result = await fetcher(mockCafs, mockResolution, mockFetchOpts)
|
||||
|
||||
expect(result).toBe(mockFetchResult)
|
||||
expect(customFetch).toHaveBeenCalledWith(
|
||||
mockCafs,
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
mockFetchOpts,
|
||||
mockFetchers
|
||||
)
|
||||
expect(remoteTarball).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should use custom fetcher when canFetch returns promise resolving to true', async () => {
|
||||
const mockFetchResult = { filesIndex: {}, manifest: { name: 'test', version: '1.0.0' }, requiresBuild: false }
|
||||
const customFetch = jest.fn(async () => mockFetchResult)
|
||||
|
||||
const customFetcher: Partial<CustomFetcher> = {
|
||||
canFetch: async () => Promise.resolve(true),
|
||||
fetch: customFetch,
|
||||
}
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers({}),
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
{
|
||||
customFetchers: [customFetcher as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
expect(typeof fetcher).toBe('function')
|
||||
})
|
||||
|
||||
test('should fall through to standard fetcher when canFetch returns false', async () => {
|
||||
const customFetch = jest.fn() as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const remoteTarball = jest.fn() as FetchFunction
|
||||
|
||||
const customFetcher: Partial<CustomFetcher> = {
|
||||
canFetch: () => false,
|
||||
fetch: customFetch,
|
||||
}
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers({ remoteTarball }),
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
{
|
||||
customFetchers: [customFetcher as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
expect(fetcher).toBe(remoteTarball)
|
||||
expect(customFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should skip custom fetcher without canFetch method', async () => {
|
||||
const remoteTarball = jest.fn() as FetchFunction
|
||||
|
||||
const customFetcher: Partial<CustomFetcher> = {
|
||||
// No canFetch method
|
||||
fetch: jest.fn() as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers({ remoteTarball }),
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
{
|
||||
customFetchers: [customFetcher as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
expect(fetcher).toBe(remoteTarball)
|
||||
})
|
||||
|
||||
test('should check custom fetchers in order and use first match', async () => {
|
||||
const mockFetchResult1 = { filesIndex: {}, manifest: { name: 'fetcher1', version: '1.0.0' }, requiresBuild: false }
|
||||
const mockFetchResult2 = { filesIndex: {}, manifest: { name: 'fetcher2', version: '1.0.0' }, requiresBuild: false }
|
||||
|
||||
const fetcher1: Partial<CustomFetcher> = {
|
||||
canFetch: () => true,
|
||||
fetch: jest.fn(async () => mockFetchResult1),
|
||||
}
|
||||
|
||||
const fetcher2: Partial<CustomFetcher> = {
|
||||
canFetch: () => true,
|
||||
fetch: jest.fn(async () => mockFetchResult2),
|
||||
}
|
||||
|
||||
const fetcher = await pickFetcher(
|
||||
createMockFetchers({}),
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
{
|
||||
customFetchers: [fetcher1 as CustomFetcher, fetcher2 as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
const mockCafs = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockResolution = { tarball: 'http://example.com/package.tgz' } as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockFetchOpts = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
const result = await fetcher(mockCafs, mockResolution, mockFetchOpts)
|
||||
|
||||
expect(result).toBe(mockFetchResult1)
|
||||
expect(fetcher1.fetch).toHaveBeenCalled()
|
||||
expect(fetcher2.fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should handle custom resolution types', async () => {
|
||||
const mockFetchResult = { filesIndex: {}, manifest: { name: 'test', version: '1.0.0' }, requiresBuild: false }
|
||||
const customFetch = jest.fn(async () => mockFetchResult)
|
||||
|
||||
const customFetcher: Partial<CustomFetcher> = {
|
||||
canFetch: (pkgId: string, resolution: any) => resolution.type === 'custom:test', // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
fetch: customFetch,
|
||||
}
|
||||
|
||||
const mockFetchers = createMockFetchers({})
|
||||
const fetcher = await pickFetcher(
|
||||
mockFetchers,
|
||||
{ type: 'custom:test', customField: 'value' } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
{
|
||||
customFetchers: [customFetcher as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
const mockCafs = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockResolution = { type: 'custom:test', customField: 'value' } as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockFetchOpts = {} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
await fetcher(mockCafs, mockResolution, mockFetchOpts)
|
||||
|
||||
expect(customFetch).toHaveBeenCalledWith(
|
||||
mockCafs,
|
||||
{ type: 'custom:test', customField: 'value' },
|
||||
mockFetchOpts,
|
||||
mockFetchers
|
||||
)
|
||||
})
|
||||
|
||||
test('should pass all fetch options to custom fetcher.fetch', async () => {
|
||||
const customFetch = jest.fn(async () => ({ filesIndex: {}, manifest: { name: 'test', version: '1.0.0' }, requiresBuild: false }))
|
||||
|
||||
const customFetcher: Partial<CustomFetcher> = {
|
||||
canFetch: () => true,
|
||||
fetch: customFetch,
|
||||
}
|
||||
|
||||
const mockFetchers = createMockFetchers({})
|
||||
const fetcher = await pickFetcher(
|
||||
mockFetchers,
|
||||
{ tarball: 'http://example.com/package.tgz' },
|
||||
{
|
||||
customFetchers: [customFetcher as CustomFetcher],
|
||||
packageId: 'test-package@1.0.0',
|
||||
}
|
||||
)
|
||||
|
||||
const mockCafs = { addFilesFromTarball: jest.fn() } as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockResolution = { tarball: 'http://example.com/package.tgz' } as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const mockFetchOpts = {
|
||||
onStart: jest.fn(),
|
||||
onProgress: jest.fn(),
|
||||
readManifest: true,
|
||||
filesIndexFile: 'index.json',
|
||||
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
await fetcher(mockCafs, mockResolution, mockFetchOpts)
|
||||
|
||||
expect(customFetch).toHaveBeenCalledWith(mockCafs, mockResolution, mockFetchOpts, mockFetchers)
|
||||
})
|
||||
|
||||
test('throws error for custom resolution type with no custom fetcher', async () => {
|
||||
// Custom resolution type without a matching custom fetcher
|
||||
const customResolution = {
|
||||
type: 'custom:cdn',
|
||||
cdnUrl: 'https://cdn.company.com/package.tgz',
|
||||
} as any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
await expect(
|
||||
pickFetcher(createMockFetchers({}), customResolution, {
|
||||
packageId: 'test-package@1.0.0',
|
||||
})
|
||||
).rejects.toThrow('Cannot fetch dependency with custom resolution type "custom:cdn". Custom resolutions must be handled by custom fetchers.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,11 +9,32 @@
|
||||
"../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../__utils__/test-fixtures"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../network/fetch"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/resolver-base"
|
||||
},
|
||||
{
|
||||
"path": "../../store/cafs-types"
|
||||
},
|
||||
{
|
||||
"path": "../../store/create-cafs-store"
|
||||
},
|
||||
{
|
||||
"path": "../fetcher-base"
|
||||
},
|
||||
{
|
||||
"path": "../tarball-fetcher"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ export { BadTarballError } from './errorTypes/index.js'
|
||||
|
||||
export { TarballIntegrityError }
|
||||
|
||||
// Export individual fetcher factories for custom fetcher authors
|
||||
export { createLocalTarballFetcher } from './localTarballFetcher.js'
|
||||
export { createGitHostedTarballFetcher } from './gitHostedTarballFetcher.js'
|
||||
export { createDownloader, type DownloadFunction, type CreateDownloaderOptions } from './remoteTarballFetcher.js'
|
||||
|
||||
export interface TarballFetchers {
|
||||
localTarball: FetchFunction
|
||||
remoteTarball: FetchFunction
|
||||
|
||||
@@ -9,13 +9,16 @@ export interface HookContext {
|
||||
}
|
||||
|
||||
export interface Hooks {
|
||||
// eslint-disable-next-line
|
||||
readPackage?: (pkg: any, context: HookContext) => any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Flexible hook signature for any package manifest
|
||||
readPackage?: (pkg: any, context: HookContext) => any
|
||||
preResolution?: PreResolutionHook
|
||||
afterAllResolved?: (lockfile: LockfileObject, context: HookContext) => LockfileObject | Promise<LockfileObject>
|
||||
filterLog?: (log: Log) => boolean
|
||||
importPackage?: ImportIndexedPackageAsync
|
||||
/**
|
||||
* @deprecated Use top-level `fetchers` export instead. This will be removed in a future version.
|
||||
*/
|
||||
fetchers?: CustomFetchers
|
||||
// eslint-disable-next-line
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Flexible hook signature for any config object
|
||||
updateConfig?: (config: any) => any
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { PreResolutionHookContext, PreResolutionHookLogger } from '@pnpm/hooks.types'
|
||||
import type { PreResolutionHookContext, PreResolutionHookLogger, CustomResolver, CustomFetcher } from '@pnpm/hooks.types'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { hookLogger } from '@pnpm/core-loggers'
|
||||
import { createHashFromMultipleFiles } from '@pnpm/crypto.hash'
|
||||
import pathAbsolute from 'path-absolute'
|
||||
import type { CustomFetchers } from '@pnpm/fetcher-base'
|
||||
import { type ImportIndexedPackageAsync } from '@pnpm/store-controller-types'
|
||||
import { type ReadPackageHook, type BaseManifest } from '@pnpm/types'
|
||||
import { type LockfileObject } from '@pnpm/lockfile.types'
|
||||
import { requirePnpmfile, type Pnpmfile, type Finders } from './requirePnpmfile.js'
|
||||
import { type HookContext, type Hooks } from './Hooks.js'
|
||||
import { type Hooks, type HookContext } from './Hooks.js'
|
||||
|
||||
// eslint-disable-next-line
|
||||
type Cook<T extends (...args: any[]) => any> = (
|
||||
@@ -25,17 +27,21 @@ interface PnpmfileEntryLoaded {
|
||||
file: string
|
||||
hooks: Pnpmfile['hooks'] | undefined
|
||||
finders: Pnpmfile['finders'] | undefined
|
||||
resolvers: Pnpmfile['resolvers'] | undefined
|
||||
fetchers: Pnpmfile['fetchers'] | undefined
|
||||
includeInChecksum: boolean
|
||||
}
|
||||
|
||||
export interface CookedHooks {
|
||||
readPackage?: Array<Cook<Required<Hooks>['readPackage']>>
|
||||
preResolution?: Array<Cook<Required<Hooks>['preResolution']>>
|
||||
afterAllResolved?: Array<Cook<Required<Hooks>['afterAllResolved']>>
|
||||
readPackage?: ReadPackageHook[]
|
||||
preResolution?: Array<(ctx: PreResolutionHookContext) => Promise<void>>
|
||||
afterAllResolved?: Array<(lockfile: LockfileObject) => LockfileObject | Promise<LockfileObject>>
|
||||
filterLog?: Array<Cook<Required<Hooks>['filterLog']>>
|
||||
updateConfig?: Array<Cook<Required<Hooks>['updateConfig']>>
|
||||
importPackage?: ImportIndexedPackageAsync
|
||||
fetchers?: CustomFetchers
|
||||
customResolvers?: CustomResolver[]
|
||||
customFetchers?: CustomFetcher[]
|
||||
calculatePnpmfileChecksum?: () => Promise<string>
|
||||
}
|
||||
|
||||
@@ -88,6 +94,8 @@ export async function requireHooks (
|
||||
includeInChecksum,
|
||||
hooks: requirePnpmfileResult.pnpmfileModule?.hooks,
|
||||
finders: requirePnpmfileResult.pnpmfileModule?.finders,
|
||||
resolvers: requirePnpmfileResult.pnpmfileModule?.resolvers,
|
||||
fetchers: requirePnpmfileResult.pnpmfileModule?.fetchers,
|
||||
})
|
||||
} else if (!optional) {
|
||||
throw new PnpmError('PNPMFILE_NOT_FOUND', `pnpmfile at "${file}" is not found`)
|
||||
@@ -139,13 +147,18 @@ export async function requireHooks (
|
||||
}
|
||||
const fileHooks: Hooks = hooks ?? {}
|
||||
|
||||
// readPackage & afterAllResolved
|
||||
for (const hookName of ['readPackage', 'afterAllResolved'] as const) {
|
||||
const fn = fileHooks[hookName]
|
||||
if (fn) {
|
||||
const context = createReadPackageHookContext(file, prefix, hookName)
|
||||
cookedHooks[hookName].push((pkg: object) => fn(pkg as any, context)) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
// readPackage
|
||||
if (fileHooks.readPackage) {
|
||||
const fn = fileHooks.readPackage
|
||||
const context = createReadPackageHookContext(file, prefix, 'readPackage')
|
||||
cookedHooks.readPackage.push(<Pkg extends BaseManifest>(pkg: Pkg, _dir?: string) => fn(pkg, context))
|
||||
}
|
||||
|
||||
// afterAllResolved
|
||||
if (fileHooks.afterAllResolved) {
|
||||
const fn = fileHooks.afterAllResolved
|
||||
const context = createReadPackageHookContext(file, prefix, 'afterAllResolved')
|
||||
cookedHooks.afterAllResolved.push((lockfile) => fn(lockfile, context))
|
||||
}
|
||||
|
||||
// filterLog
|
||||
@@ -196,6 +209,21 @@ export async function requireHooks (
|
||||
}
|
||||
}
|
||||
|
||||
// Process top-level resolvers and fetchers exports
|
||||
for (const { resolvers, fetchers } of entries) {
|
||||
// Custom resolvers: merge all
|
||||
if (resolvers) {
|
||||
cookedHooks.customResolvers = cookedHooks.customResolvers ?? []
|
||||
cookedHooks.customResolvers.push(...resolvers)
|
||||
}
|
||||
|
||||
// Custom fetchers: merge all
|
||||
if (fetchers) {
|
||||
cookedHooks.customFetchers = cookedHooks.customFetchers ?? []
|
||||
cookedHooks.customFetchers.push(...fetchers)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hooks: cookedHooks,
|
||||
finders: mergedFinders,
|
||||
@@ -213,12 +241,13 @@ function createReadPackageHookContext (calledFrom: string, prefix: string, hook:
|
||||
|
||||
function createPreResolutionHookLogger (prefix: string): PreResolutionHookLogger {
|
||||
const hook = 'preResolution'
|
||||
const from = 'pnpmfile'
|
||||
return {
|
||||
info: (message: string) => {
|
||||
hookLogger.info({ message, prefix, hook } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
hookLogger.info({ message, prefix, hook, from } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
warn: (message: string) => {
|
||||
hookLogger.warn({ message, prefix, hook } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
hookLogger.warn({ message, prefix, hook, from } as any) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createRequire } from 'module'
|
||||
import { PnpmError } from '@pnpm/error'
|
||||
import { logger } from '@pnpm/logger'
|
||||
import { type PackageManifest, type Finder } from '@pnpm/types'
|
||||
import { type CustomResolver, type CustomFetcher } from '@pnpm/hooks.types'
|
||||
import chalk from 'chalk'
|
||||
import { type Hooks } from './Hooks.js'
|
||||
|
||||
@@ -37,6 +38,8 @@ export type Finders = Record<string, Finder>
|
||||
export interface Pnpmfile {
|
||||
hooks?: Hooks
|
||||
finders?: Finders
|
||||
resolvers?: CustomResolver[]
|
||||
fetchers?: CustomFetcher[]
|
||||
}
|
||||
|
||||
export async function requirePnpmfile (pnpmFilePath: string, prefix: string): Promise<{ pnpmfileModule: Pnpmfile | undefined } | undefined> {
|
||||
|
||||
@@ -24,16 +24,21 @@
|
||||
"!*.map"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "pnpm run compile",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"_test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
||||
"test": "pnpm run compile && pnpm run _test",
|
||||
"prepublishOnly": "pnpm run compile",
|
||||
"compile": "tsc --build && pnpm run lint --fix",
|
||||
"lint": "eslint \"src/**/*.ts\""
|
||||
"compile": "tsc --build && pnpm run lint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/cafs-types": "workspace:*",
|
||||
"@pnpm/fetcher-base": "workspace:*",
|
||||
"@pnpm/lockfile.types": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*",
|
||||
"@pnpm/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/hooks.types": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
49
hooks/types/src/customResolverCache.ts
Normal file
49
hooks/types/src/customResolverCache.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type CustomResolver, type WantedDependency } from './index.js'
|
||||
|
||||
// Shared cache for canResolve results to avoid calling expensive async operations twice
|
||||
// WeakMap ensures automatic garbage collection when custom resolvers are no longer referenced
|
||||
const customResolverCanResolveCache = new WeakMap<CustomResolver, Map<string, boolean>>()
|
||||
|
||||
export function getCustomResolverCacheKey (wantedDependency: WantedDependency): string {
|
||||
const alias = wantedDependency.alias ?? ''
|
||||
const bareSpecifier = wantedDependency.bareSpecifier ?? ''
|
||||
return `${alias}@${bareSpecifier}`
|
||||
}
|
||||
|
||||
export function getCachedCanResolve (customResolver: CustomResolver, cacheKey: string): boolean | undefined {
|
||||
return customResolverCanResolveCache.get(customResolver)?.get(cacheKey)
|
||||
}
|
||||
|
||||
export function setCachedCanResolve (customResolver: CustomResolver, cacheKey: string, value: boolean): void {
|
||||
let cache = customResolverCanResolveCache.get(customResolver)
|
||||
if (!cache) {
|
||||
cache = new Map<string, boolean>()
|
||||
customResolverCanResolveCache.set(customResolver, cache)
|
||||
}
|
||||
cache.set(cacheKey, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a custom resolver can resolve a wanted dependency, using cache when available
|
||||
* This centralizes the cache check/call/store logic
|
||||
*/
|
||||
export async function checkCustomResolverCanResolve (
|
||||
customResolver: CustomResolver,
|
||||
wantedDependency: WantedDependency
|
||||
): Promise<boolean> {
|
||||
if (!customResolver.canResolve) return false
|
||||
|
||||
const cacheKey = getCustomResolverCacheKey(wantedDependency)
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedCanResolve(customResolver, cacheKey)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
// Call canResolve and handle sync/async (await works for both)
|
||||
const canResolve = await customResolver.canResolve(wantedDependency)
|
||||
|
||||
// Cache the result
|
||||
setCachedCanResolve(customResolver, cacheKey, canResolve)
|
||||
|
||||
return canResolve
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { type LockfileObject } from '@pnpm/lockfile.types'
|
||||
import { type Resolution, type WantedDependency } from '@pnpm/resolver-base'
|
||||
import { type Registries } from '@pnpm/types'
|
||||
import { type Cafs } from '@pnpm/cafs-types'
|
||||
import { type FetchOptions, type FetchResult, type Fetchers } from '@pnpm/fetcher-base'
|
||||
|
||||
// Custom resolution types must use scoped naming to avoid conflicts with pnpm's built-in types
|
||||
export type CustomResolutionType = `@${string}/${string}`
|
||||
|
||||
// preResolution hook
|
||||
export interface PreResolutionHookContext {
|
||||
wantedLockfile: LockfileObject
|
||||
currentLockfile: LockfileObject
|
||||
@@ -17,3 +24,88 @@ export interface PreResolutionHookLogger {
|
||||
}
|
||||
|
||||
export type PreResolutionHook = (ctx: PreResolutionHookContext, logger: PreResolutionHookLogger) => Promise<void>
|
||||
|
||||
// Custom resolver and fetcher hooks
|
||||
export type { WantedDependency }
|
||||
|
||||
export interface ResolveOptions {
|
||||
lockfileDir: string
|
||||
projectDir: string
|
||||
preferredVersions: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ResolveResult {
|
||||
id: string
|
||||
resolution: Resolution
|
||||
}
|
||||
|
||||
export interface CustomResolver {
|
||||
/**
|
||||
* Called during resolution to determine if this resolver should handle a dependency.
|
||||
* This should be a cheap check (ideally synchronous) as it's called for every dependency.
|
||||
*
|
||||
* @param wantedDependency - The dependency descriptor to check
|
||||
* @returns true if this resolver should handle the dependency
|
||||
*/
|
||||
canResolve?: (wantedDependency: WantedDependency) => boolean | Promise<boolean>
|
||||
|
||||
/**
|
||||
* Called to resolve a dependency that canResolve returned true for.
|
||||
* This can be an expensive async operation (e.g., network requests).
|
||||
*
|
||||
* @param wantedDependency - The dependency descriptor to resolve
|
||||
* @param opts - Resolution options including lockfileDir, projectDir, and preferredVersions
|
||||
* @returns Resolution result with id and resolution object
|
||||
*/
|
||||
resolve?: (wantedDependency: WantedDependency, opts: ResolveOptions) => ResolveResult | Promise<ResolveResult>
|
||||
|
||||
/**
|
||||
* Called on subsequent installs (when lockfile exists) to determine if this dependency
|
||||
* needs re-resolution. This is called before checking if the lockfile is up-to-date.
|
||||
*
|
||||
* If this returns true for ANY dependency, full resolution will be triggered for ALL packages,
|
||||
* bypassing the "Lockfile is up to date" optimization.
|
||||
*
|
||||
* Use this to implement custom cache invalidation logic (e.g., time-based expiry, version checks).
|
||||
*
|
||||
* @param wantedDependency - The dependency to check for force re-resolution
|
||||
* @returns true to force re-resolution of all dependencies
|
||||
*/
|
||||
shouldForceResolve?: (wantedDependency: WantedDependency) => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
export interface CustomFetcher {
|
||||
/**
|
||||
* Called to determine if this fetcher should handle fetching a package.
|
||||
* This is called for each package that needs to be fetched.
|
||||
*
|
||||
* @param pkgId - The package ID (e.g., 'foo@1.0.0' or custom format)
|
||||
* @param resolution - The resolution object from the lockfile
|
||||
* @returns true if this fetcher should handle fetching this package
|
||||
*/
|
||||
canFetch?: (pkgId: string, resolution: Resolution) => boolean | Promise<boolean>
|
||||
|
||||
/**
|
||||
* Called to fetch and extract a package's contents.
|
||||
* This is a complete fetcher implementation that should download/copy the package
|
||||
* and add its files to the content-addressable file system (cafs).
|
||||
*
|
||||
* The fetchers parameter provides access to pnpm's standard fetchers, allowing you
|
||||
* to delegate to them (e.g., transform a custom resolution to a tarball URL and use
|
||||
* fetchers.remoteTarball).
|
||||
*
|
||||
* @param cafs - The content-addressable file system to add package files to
|
||||
* @param resolution - The resolution object containing fetch information
|
||||
* @param opts - Fetch options including package manifest
|
||||
* @param fetchers - Standard pnpm fetchers available for delegation (remoteTarball, localTarball, git, etc.)
|
||||
* @returns FetchResult with files index and other package information
|
||||
*/
|
||||
fetch?: (cafs: Cafs, resolution: Resolution, opts: FetchOptions, fetchers: Fetchers) => FetchResult | Promise<FetchResult>
|
||||
}
|
||||
|
||||
export {
|
||||
getCustomResolverCacheKey,
|
||||
getCachedCanResolve,
|
||||
setCachedCanResolve,
|
||||
checkCustomResolverCanResolve,
|
||||
} from './customResolverCache.js'
|
||||
|
||||
207
hooks/types/test/customResolverCache.test.ts
Normal file
207
hooks/types/test/customResolverCache.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
getCustomResolverCacheKey,
|
||||
getCachedCanResolve,
|
||||
setCachedCanResolve,
|
||||
checkCustomResolverCanResolve,
|
||||
type CustomResolver,
|
||||
} from '../src/index.js'
|
||||
|
||||
describe('customResolverCache', () => {
|
||||
describe('getCustomResolverCacheKey', () => {
|
||||
test('generates cache key from descriptor', () => {
|
||||
const wantedDependency = { alias: 'test-package', bareSpecifier: '1.0.0' }
|
||||
expect(getCustomResolverCacheKey(wantedDependency)).toBe('test-package@1.0.0')
|
||||
})
|
||||
|
||||
test('handles scoped packages', () => {
|
||||
const wantedDependency = { alias: '@org/package', bareSpecifier: '^2.0.0' }
|
||||
expect(getCustomResolverCacheKey(wantedDependency)).toBe('@org/package@^2.0.0')
|
||||
})
|
||||
|
||||
test('handles version ranges', () => {
|
||||
const wantedDependency = { alias: 'lodash', bareSpecifier: '>=4.0.0 <5.0.0' }
|
||||
expect(getCustomResolverCacheKey(wantedDependency)).toBe('lodash@>=4.0.0 <5.0.0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCachedCanResolve', () => {
|
||||
test('returns undefined for uncached custom resolver', () => {
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
}
|
||||
const result = getCachedCanResolve(customResolver, 'test@1.0.0')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns cached value when available', () => {
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
}
|
||||
setCachedCanResolve(customResolver, 'test@1.0.0', true)
|
||||
const result = getCachedCanResolve(customResolver, 'test@1.0.0')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when cached as false', () => {
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
}
|
||||
setCachedCanResolve(customResolver, 'test@1.0.0', false)
|
||||
const result = getCachedCanResolve(customResolver, 'test@1.0.0')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('cache is isolated per custom resolver', () => {
|
||||
const customResolver1: CustomResolver = { canResolve: () => true }
|
||||
const customResolver2: CustomResolver = { canResolve: () => false }
|
||||
|
||||
setCachedCanResolve(customResolver1, 'pkg@1.0.0', true)
|
||||
setCachedCanResolve(customResolver2, 'pkg@1.0.0', false)
|
||||
|
||||
expect(getCachedCanResolve(customResolver1, 'pkg@1.0.0')).toBe(true)
|
||||
expect(getCachedCanResolve(customResolver2, 'pkg@1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
test('cache is isolated per descriptor', () => {
|
||||
const customResolver: CustomResolver = { canResolve: () => true }
|
||||
|
||||
setCachedCanResolve(customResolver, 'pkg1@1.0.0', true)
|
||||
setCachedCanResolve(customResolver, 'pkg2@1.0.0', false)
|
||||
|
||||
expect(getCachedCanResolve(customResolver, 'pkg1@1.0.0')).toBe(true)
|
||||
expect(getCachedCanResolve(customResolver, 'pkg2@1.0.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setCachedCanResolve', () => {
|
||||
test('creates new cache for custom resolver', () => {
|
||||
const customResolver: CustomResolver = { canResolve: () => true }
|
||||
|
||||
setCachedCanResolve(customResolver, 'test@1.0.0', true)
|
||||
|
||||
expect(getCachedCanResolve(customResolver, 'test@1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test('updates existing cache entry', () => {
|
||||
const customResolver: CustomResolver = { canResolve: () => true }
|
||||
|
||||
setCachedCanResolve(customResolver, 'test@1.0.0', false)
|
||||
setCachedCanResolve(customResolver, 'test@1.0.0', true)
|
||||
|
||||
expect(getCachedCanResolve(customResolver, 'test@1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
test('allows multiple cache entries per custom resolver', () => {
|
||||
const customResolver: CustomResolver = { canResolve: () => true }
|
||||
|
||||
setCachedCanResolve(customResolver, 'pkg1@1.0.0', true)
|
||||
setCachedCanResolve(customResolver, 'pkg2@2.0.0', false)
|
||||
setCachedCanResolve(customResolver, 'pkg3@3.0.0', true)
|
||||
|
||||
expect(getCachedCanResolve(customResolver, 'pkg1@1.0.0')).toBe(true)
|
||||
expect(getCachedCanResolve(customResolver, 'pkg2@2.0.0')).toBe(false)
|
||||
expect(getCachedCanResolve(customResolver, 'pkg3@3.0.0')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkCustomResolverCanResolve', () => {
|
||||
test('returns false when custom resolver has no canResolve', async () => {
|
||||
const customResolver: CustomResolver = {}
|
||||
const wantedDependency = { alias: 'test', bareSpecifier: '1.0.0' }
|
||||
|
||||
const result = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('calls canResolve and caches result (true)', async () => {
|
||||
let callCount = 0
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: () => {
|
||||
callCount++
|
||||
return true
|
||||
},
|
||||
}
|
||||
const wantedDependency = { alias: 'test', bareSpecifier: '1.0.0' }
|
||||
|
||||
const result1 = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
const result2 = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
|
||||
expect(result1).toBe(true)
|
||||
expect(result2).toBe(true)
|
||||
expect(callCount).toBe(1) // Should only be called once due to caching
|
||||
})
|
||||
|
||||
test('calls canResolve and caches result (false)', async () => {
|
||||
let callCount = 0
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: () => {
|
||||
callCount++
|
||||
return false
|
||||
},
|
||||
}
|
||||
const wantedDependency = { alias: 'test', bareSpecifier: '1.0.0' }
|
||||
|
||||
const result1 = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
const result2 = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
|
||||
expect(result1).toBe(false)
|
||||
expect(result2).toBe(false)
|
||||
expect(callCount).toBe(1) // Should only be called once due to caching
|
||||
})
|
||||
|
||||
test('handles async canResolve', async () => {
|
||||
let callCount = 0
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: async () => {
|
||||
callCount++
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return true
|
||||
},
|
||||
}
|
||||
const wantedDependency = { alias: 'test', bareSpecifier: '1.0.0' }
|
||||
|
||||
const result1 = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
const result2 = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
|
||||
expect(result1).toBe(true)
|
||||
expect(result2).toBe(true)
|
||||
expect(callCount).toBe(1) // Should only be called once due to caching
|
||||
})
|
||||
|
||||
test('different descriptors are cached separately', async () => {
|
||||
let callCount = 0
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: (descriptor) => {
|
||||
callCount++
|
||||
return descriptor.alias === 'match'
|
||||
},
|
||||
}
|
||||
|
||||
const result1 = await checkCustomResolverCanResolve(customResolver, { alias: 'match', bareSpecifier: '1.0.0' })
|
||||
const result2 = await checkCustomResolverCanResolve(customResolver, { alias: 'no-match', bareSpecifier: '1.0.0' })
|
||||
const result3 = await checkCustomResolverCanResolve(customResolver, { alias: 'match', bareSpecifier: '1.0.0' })
|
||||
|
||||
expect(result1).toBe(true)
|
||||
expect(result2).toBe(false)
|
||||
expect(result3).toBe(true)
|
||||
expect(callCount).toBe(2) // Called for 'match' and 'no-match', but cached for second 'match'
|
||||
})
|
||||
|
||||
test('uses cache key based on alias and bareSpecifier', async () => {
|
||||
let callCount = 0
|
||||
const customResolver: CustomResolver = {
|
||||
canResolve: () => {
|
||||
callCount++
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// Same package, different versions
|
||||
await checkCustomResolverCanResolve(customResolver, { alias: 'test', bareSpecifier: '1.0.0' })
|
||||
await checkCustomResolverCanResolve(customResolver, { alias: 'test', bareSpecifier: '2.0.0' })
|
||||
|
||||
expect(callCount).toBe(2) // Different bareSpecifiers mean different cache keys
|
||||
})
|
||||
})
|
||||
})
|
||||
18
hooks/types/test/tsconfig.json
Normal file
18
hooks/types/test/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "../node_modules/.test.lib",
|
||||
"rootDir": "..",
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"../../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,11 +9,20 @@
|
||||
"../../__typings__/**/*.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../fetching/fetcher-base"
|
||||
},
|
||||
{
|
||||
"path": "../../lockfile/types"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/types"
|
||||
},
|
||||
{
|
||||
"path": "../../resolving/resolver-base"
|
||||
},
|
||||
{
|
||||
"path": "../../store/cafs-types"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -123,11 +123,23 @@ export interface PlatformAssetResolution {
|
||||
targets: PlatformAssetTarget[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom resolution type for custom resolver-provided packages.
|
||||
* The type field must be prefixed with 'custom:' to differentiate it from built-in resolution types.
|
||||
*
|
||||
* Example: { type: 'custom:cdn', cdnUrl: '...' }
|
||||
*/
|
||||
export interface CustomResolution {
|
||||
type: `custom:${string}` // e.g., 'custom:cdn', 'custom:artifactory'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type Resolution =
|
||||
TarballResolution |
|
||||
GitRepositoryResolution |
|
||||
DirectoryResolution |
|
||||
BinaryResolution
|
||||
BinaryResolution |
|
||||
CustomResolution
|
||||
|
||||
export interface VariationsResolution {
|
||||
type: 'variations'
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@pnpm/dependency-path": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/lockfile.types": "workspace:*",
|
||||
"@pnpm/pick-fetcher": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*",
|
||||
|
||||
@@ -12,9 +12,15 @@
|
||||
{
|
||||
"path": "../../fetching/pick-fetcher"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/dependency-path"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/types"
|
||||
},
|
||||
|
||||
@@ -143,6 +143,6 @@ function lockfileDepsToGraphChildren (deps: Record<string, string>): Record<stri
|
||||
}
|
||||
|
||||
function createFullPkgId (pkgIdWithPatchHash: PkgIdWithPatchHash, resolution: LockfileResolution): string {
|
||||
const res = 'integrity' in resolution ? resolution.integrity : hashObject(resolution)
|
||||
const res = 'integrity' in resolution ? String(resolution.integrity) : hashObject(resolution)
|
||||
return `${pkgIdWithPatchHash}:${res}`
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@pnpm/fetching-types": "workspace:*",
|
||||
"@pnpm/fetching.binary-fetcher": "workspace:*",
|
||||
"@pnpm/git-fetcher": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/network.auth-header": "workspace:*",
|
||||
"@pnpm/node.fetcher": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@pnpm/default-resolver'
|
||||
import { type AgentOptions, createFetchFromRegistry } from '@pnpm/fetch'
|
||||
import { type SslConfig } from '@pnpm/types'
|
||||
import { type CustomResolver, type CustomFetcher as CustomFetcherHook } from '@pnpm/hooks.types'
|
||||
import { type FetchFromRegistry, type GetAuthHeader, type RetryTimeoutOptions } from '@pnpm/fetching-types'
|
||||
import type { CustomFetchers, GitFetcher, DirectoryFetcher, BinaryFetcher } from '@pnpm/fetcher-base'
|
||||
import { createDirectoryFetcher } from '@pnpm/directory-fetcher'
|
||||
@@ -19,6 +20,8 @@ export type { ResolveFunction }
|
||||
export type ClientOptions = {
|
||||
authConfig: Record<string, string>
|
||||
customFetchers?: CustomFetchers
|
||||
customResolvers?: CustomResolver[]
|
||||
customFetcherHooks?: CustomFetcherHook[]
|
||||
ignoreScripts?: boolean
|
||||
rawConfig: Record<string, string>
|
||||
sslConfigs?: Record<string, SslConfig>
|
||||
@@ -43,7 +46,8 @@ export interface Client {
|
||||
export function createClient (opts: ClientOptions): Client {
|
||||
const fetchFromRegistry = createFetchFromRegistry(opts)
|
||||
const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.authConfig, userSettings: opts.userConfig })
|
||||
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, opts)
|
||||
|
||||
const { resolve, clearCache: clearResolutionCache } = _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
|
||||
return {
|
||||
fetchers: createFetchers(fetchFromRegistry, getAuthHeader, opts, opts.customFetchers),
|
||||
resolve,
|
||||
@@ -54,7 +58,8 @@ export function createClient (opts: ClientOptions): Client {
|
||||
export function createResolver (opts: ClientOptions): { resolve: ResolveFunction, clearCache: () => void } {
|
||||
const fetchFromRegistry = createFetchFromRegistry(opts)
|
||||
const getAuthHeader = createGetAuthHeaderByURI({ allSettings: opts.authConfig, userSettings: opts.userConfig })
|
||||
return _createResolver(fetchFromRegistry, getAuthHeader, opts)
|
||||
|
||||
return _createResolver(fetchFromRegistry, getAuthHeader, { ...opts, customResolvers: opts.customResolvers })
|
||||
}
|
||||
|
||||
type Fetchers = {
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
{
|
||||
"path": "../../fetching/tarball-fetcher"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../network/auth-header"
|
||||
},
|
||||
|
||||
@@ -70,25 +70,33 @@ Remove unreferenced packages from the store.
|
||||
|
||||
## Hooks
|
||||
|
||||
Hooks are functions that can step into the installation process.
|
||||
Hooks are functions that can step into the installation process. All hooks can be provided as arrays to register multiple hook functions.
|
||||
|
||||
### `readPackage(pkg: Manifest): Manifest | Promise<Manifest>`
|
||||
### `readPackage(pkg: Manifest, context): Manifest | Promise<Manifest>`
|
||||
|
||||
This hook is called with every dependency's manifest information.
|
||||
The modified manifest returned by this hook is then used by `@pnpm/core` during installation.
|
||||
An async function is supported.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
* `pkg` - The dependency's package manifest.
|
||||
* `context.log(message)` - A function to log debug messages.
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
const { installPkgs } = require('@pnpm/core')
|
||||
|
||||
installPkgs({
|
||||
hooks: {readPackage}
|
||||
hooks: {
|
||||
readPackage: [readPackageHook]
|
||||
}
|
||||
})
|
||||
|
||||
function readPackage (pkg) {
|
||||
function readPackageHook (pkg, context) {
|
||||
if (pkg.name === 'foo') {
|
||||
context.log('Modifying foo dependencies')
|
||||
pkg.dependencies = {
|
||||
bar: '^2.0.0',
|
||||
}
|
||||
@@ -97,11 +105,151 @@ function readPackage (pkg) {
|
||||
}
|
||||
```
|
||||
|
||||
### `preResolution(context, logger): Promise<void>`
|
||||
|
||||
This hook is called after reading lockfiles but before resolving dependencies. It can modify lockfile objects.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
* `context.wantedLockfile` - The lockfile from `pnpm-lock.yaml`.
|
||||
* `context.currentLockfile` - The lockfile from `node_modules/.pnpm/lock.yaml`.
|
||||
* `context.existsCurrentLockfile` - Boolean indicating if current lockfile exists.
|
||||
* `context.existsNonEmptyWantedLockfile` - Boolean indicating if wanted lockfile exists and is not empty.
|
||||
* `context.lockfileDir` - Directory containing the lockfile.
|
||||
* `context.storeDir` - Location of the store directory.
|
||||
* `context.registries` - Map of registry URLs.
|
||||
* `logger.info(message)` - Log an informational message.
|
||||
* `logger.warn(message)` - Log a warning message.
|
||||
|
||||
### `afterAllResolved(lockfile: Lockfile): Lockfile | Promise<Lockfile>`
|
||||
|
||||
This hook is called after all dependencies are resolved. It receives and returns the resolved lockfile object.
|
||||
An async function is supported.
|
||||
|
||||
**Arguments:**
|
||||
|
||||
* `lockfile` - The resolved lockfile object that will be written to `pnpm-lock.yaml`.
|
||||
|
||||
### Custom Resolvers and Fetchers
|
||||
|
||||
Custom resolvers and fetchers allow you to implement custom package resolution and fetching logic for new package identifier schemes (like `my-protocol:package-name`). These are defined as top-level exports in your `.pnpmfile.cjs`:
|
||||
|
||||
* **Custom Resolvers**: Convert package descriptors (e.g., `foo@^1.0.0`) into resolutions
|
||||
* **Custom Fetchers**: Completely handle fetching for custom package types
|
||||
|
||||
**Custom Resolver Interface:**
|
||||
|
||||
```typescript
|
||||
interface CustomResolver {
|
||||
// Resolution phase
|
||||
canResolve?: (wantedDependency: WantedDependency) => boolean | Promise<boolean>
|
||||
resolve?: (wantedDependency: WantedDependency, opts: ResolveOptions) => ResolveResult | Promise<ResolveResult>
|
||||
|
||||
// Force resolution check
|
||||
shouldForceResolve?: (wantedDependency: WantedDependency) => boolean | Promise<boolean>
|
||||
}
|
||||
```
|
||||
|
||||
**Custom Fetcher Interface:**
|
||||
|
||||
```typescript
|
||||
interface CustomFetcher {
|
||||
// Fetch phase - complete fetcher replacement
|
||||
canFetch?: (pkgId: string, resolution: Resolution) => boolean | Promise<boolean>
|
||||
fetch?: (cafs: Cafs, resolution: Resolution, opts: FetchOptions, fetchers: Fetchers) => FetchResult | Promise<FetchResult>
|
||||
}
|
||||
```
|
||||
|
||||
**Custom Resolver Methods:**
|
||||
|
||||
* `canResolve(wantedDependency)` - Returns `true` if this resolver can resolve the given package descriptor
|
||||
* `resolve(wantedDependency, opts)` - Resolves a package descriptor to a resolution. Should return an object with `id` and `resolution`
|
||||
* `shouldForceResolve(wantedDependency)` - Return `true` to trigger full resolution of all packages (skipping the "Lockfile is up to date" optimization)
|
||||
|
||||
**Custom Fetcher Methods:**
|
||||
|
||||
* `canFetch(pkgId, resolution)` - Returns `true` if this fetcher can handle fetching for the given resolution
|
||||
* `fetch(cafs, resolution, opts, fetchers)` - Completely handles fetching the package contents. Receives the content-addressable file system (cafs), the resolution, fetch options, and pnpm's standard fetchers for delegation. Must return a FetchResult with the package files.
|
||||
|
||||
**Example - Reusing pnpm's fetcher utilities:**
|
||||
|
||||
```js
|
||||
// .pnpmfile.cjs
|
||||
const customResolver = {
|
||||
canResolve: (wantedDependency) => {
|
||||
return wantedDependency.alias.startsWith('company-cdn:')
|
||||
},
|
||||
|
||||
resolve: async (wantedDependency, opts) => {
|
||||
const actualName = wantedDependency.alias.replace('company-cdn:', '')
|
||||
const version = await fetchVersionFromCompanyCDN(actualName, wantedDependency.bareSpecifier)
|
||||
|
||||
return {
|
||||
id: `company-cdn:${actualName}@${version}`,
|
||||
resolution: {
|
||||
type: 'custom:cdn',
|
||||
cdnUrl: `https://cdn.company.com/packages/${actualName}/${version}.tgz`,
|
||||
cachedAt: Date.now(), // Custom metadata for shouldForceResolve
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
shouldForceResolve: (wantedDependency) => {
|
||||
// You can implement custom logic to determine if re-resolution is needed
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
const customFetcher = {
|
||||
canFetch: (pkgId, resolution) => {
|
||||
return resolution.type === 'custom:cdn'
|
||||
},
|
||||
|
||||
fetch: async (cafs, resolution, opts, fetchers) => {
|
||||
// Delegate to pnpm's standard tarball fetcher
|
||||
// Transform the custom resolution to a standard tarball resolution
|
||||
const tarballResolution = {
|
||||
tarball: resolution.cdnUrl,
|
||||
integrity: resolution.integrity,
|
||||
}
|
||||
|
||||
return fetchers.remoteTarball(cafs, tarballResolution, opts)
|
||||
},
|
||||
}
|
||||
|
||||
// Export as top-level arrays
|
||||
module.exports = {
|
||||
resolvers: [customResolver],
|
||||
fetchers: [customFetcher],
|
||||
}
|
||||
```
|
||||
|
||||
**Delegating to Standard Fetchers:**
|
||||
|
||||
The `fetchers` parameter passed to the custom fetcher's `fetch` method provides access to pnpm's standard fetchers for delegation:
|
||||
|
||||
* `fetchers.remoteTarball` - Fetch from remote tarball URLs
|
||||
* `fetchers.localTarball` - Fetch from local tarball files
|
||||
* `fetchers.gitHostedTarball` - Fetch from GitHub/GitLab/Bitbucket tarballs
|
||||
* `fetchers.directory` - Fetch from local directories
|
||||
* `fetchers.git` - Fetch from git repositories
|
||||
|
||||
See the test cases in `resolving/default-resolver/test/customResolver.ts` and `fetching/pick-fetcher/test/pickFetcher.ts` for complete working examples.
|
||||
|
||||
**Notes:**
|
||||
|
||||
* Multiple custom resolvers and fetchers can be registered; they are tried in order until one matches
|
||||
* All methods support both synchronous and asynchronous implementations
|
||||
* Custom resolvers are tried before pnpm's built-in resolvers (npm, git, tarball, etc.)
|
||||
* Custom fetchers can delegate to pnpm's standard fetchers via the `fetchers` parameter to avoid reimplementing common fetch logic
|
||||
* The `shouldForceResolve` hook allows fine-grained control over when packages should be re-resolved
|
||||
|
||||
**Performance Considerations:**
|
||||
|
||||
* `canResolve()` should be a cheap check (ideally synchronous) as it's called for every dependency during resolution
|
||||
* `resolve()` can be an expensive async operation (e.g., network requests) as it's only called for matching dependencies
|
||||
* If your `canResolve()` implementation is expensive, performance may be impacted during large installations
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type ProjectId, type ProjectManifest } from '@pnpm/types'
|
||||
import { type LockfileObject } from '@pnpm/lockfile.types'
|
||||
import { type CustomResolver, type WantedDependency, checkCustomResolverCanResolve } from '@pnpm/hooks.types'
|
||||
|
||||
export interface ProjectWithManifest {
|
||||
id: ProjectId
|
||||
manifest: ProjectManifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any custom resolver requires force re-resolution for dependencies in the lockfile.
|
||||
* This is a pure function extracted from the install flow for testability.
|
||||
*
|
||||
* @param customResolvers - Array of custom resolvers to check
|
||||
* @param wantedLockfile - Current lockfile
|
||||
* @param projects - Projects with their manifests
|
||||
* @returns Promise<boolean> - true if any custom resolver requires force re-resolution
|
||||
*/
|
||||
export async function checkCustomResolverForceResolve (
|
||||
customResolvers: CustomResolver[],
|
||||
wantedLockfile: LockfileObject,
|
||||
projects: ProjectWithManifest[]
|
||||
): Promise<boolean> {
|
||||
for (const project of projects) {
|
||||
const allDeps = getAllDependenciesFromManifest(project.manifest)
|
||||
|
||||
for (const [depName, bareSpec] of Object.entries(allDeps)) {
|
||||
const wantedDependency: WantedDependency = {
|
||||
alias: depName,
|
||||
bareSpecifier: bareSpec,
|
||||
}
|
||||
|
||||
for (const customResolver of customResolvers) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const canResolve = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
|
||||
if (canResolve && customResolver.shouldForceResolve) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const shouldForce = await customResolver.shouldForceResolve(wantedDependency)
|
||||
|
||||
if (shouldForce) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getAllDependenciesFromManifest (manifest: {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
...manifest.dependencies,
|
||||
...manifest.devDependencies,
|
||||
...manifest.optionalDependencies,
|
||||
...manifest.peerDependencies,
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
type PrepareExecutionEnv,
|
||||
type TrustPolicy,
|
||||
} from '@pnpm/types'
|
||||
import { type CustomResolver, type CustomFetcher, type PreResolutionHookContext } from '@pnpm/hooks.types'
|
||||
import { parseOverrides, type VersionOverride } from '@pnpm/parse-overrides'
|
||||
import { pnpmPkgJson } from '../pnpmPkgJson.js'
|
||||
import { type ReporterFunction } from '../types.js'
|
||||
import { type PreResolutionHookContext } from '@pnpm/hooks.types'
|
||||
|
||||
export interface StrictInstallOptions {
|
||||
autoInstallPeers: boolean
|
||||
@@ -92,6 +92,8 @@ export interface StrictInstallOptions {
|
||||
readPackage?: ReadPackageHook[]
|
||||
preResolution?: Array<(ctx: PreResolutionHookContext) => Promise<void>>
|
||||
afterAllResolved?: Array<(lockfile: LockfileObject) => LockfileObject | Promise<LockfileObject>>
|
||||
customResolvers?: CustomResolver[]
|
||||
customFetchers?: CustomFetcher[]
|
||||
calculatePnpmfileChecksum?: () => Promise<string | undefined>
|
||||
}
|
||||
sideEffectsCacheRead: boolean
|
||||
|
||||
@@ -76,6 +76,7 @@ import pLimit from 'p-limit'
|
||||
import { map as mapValues, clone, isEmpty, pipeWith, props } from 'ramda'
|
||||
import { parseWantedDependencies } from '../parseWantedDependencies.js'
|
||||
import { removeDeps } from '../uninstall/removeDeps.js'
|
||||
import { checkCustomResolverForceResolve } from './checkCustomResolverForceResolve.js'
|
||||
import {
|
||||
extendOptions,
|
||||
type InstallOptions,
|
||||
@@ -318,6 +319,24 @@ export async function mutateModules (
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any custom resolvers want to force resolution for specific dependencies
|
||||
// Skip this check when not saving the lockfile (e.g., during deploy) since there's no point
|
||||
// in forcing re-resolution if we're not going to persist the results
|
||||
let forceResolutionFromHook = false
|
||||
const shouldCheckCustomResolverForceResolve =
|
||||
opts.hooks.customResolvers &&
|
||||
ctx.existsNonEmptyWantedLockfile &&
|
||||
!opts.frozenLockfile &&
|
||||
opts.saveLockfile
|
||||
if (shouldCheckCustomResolverForceResolve) {
|
||||
const projects = Object.values(ctx.projects).map(({ id, manifest }) => ({ id, manifest }))
|
||||
forceResolutionFromHook = await checkCustomResolverForceResolve(
|
||||
opts.hooks.customResolvers!,
|
||||
ctx.wantedLockfile,
|
||||
projects
|
||||
)
|
||||
}
|
||||
|
||||
const pruneVirtualStore = !opts.enableGlobalVirtualStore && (ctx.modulesFile?.prunedAt && opts.modulesCacheMaxAge > 0
|
||||
? cacheExpired(ctx.modulesFile.prunedAt, opts.modulesCacheMaxAge)
|
||||
: true
|
||||
@@ -430,7 +449,8 @@ export async function mutateModules (
|
||||
let needsFullResolution = outdatedLockfileSettings ||
|
||||
opts.fixLockfile ||
|
||||
!upToDateLockfileMajorVersion ||
|
||||
opts.forceFullResolution
|
||||
opts.forceFullResolution ||
|
||||
forceResolutionFromHook
|
||||
if (needsFullResolution) {
|
||||
ctx.wantedLockfile.settings = {
|
||||
autoInstallPeers: opts.autoInstallPeers,
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { checkCustomResolverForceResolve, type ProjectWithManifest } from '../../src/install/checkCustomResolverForceResolve.js'
|
||||
import { type CustomResolver } from '@pnpm/hooks.types'
|
||||
import { type LockfileObject } from '@pnpm/lockfile.types'
|
||||
import { type ProjectId } from '@pnpm/types'
|
||||
|
||||
describe('checkCustomResolverForceResolve', () => {
|
||||
test('returns false when no custom resolvers provided', async () => {
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {},
|
||||
}
|
||||
const projects: ProjectWithManifest[] = []
|
||||
|
||||
const result = await checkCustomResolverForceResolve([], lockfile, projects)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when no projects provided', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {},
|
||||
}
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, [])
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when custom resolver canResolve returns false', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => false,
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when custom resolver has no shouldForceResolve', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
// No shouldForceResolve
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when shouldForceResolve returns false', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
shouldForceResolve: () => false,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test('returns true when shouldForceResolve returns true', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: (wantedDependency) => wantedDependency.alias === 'test-pkg',
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('checks devDependencies', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: (wantedDependency) => wantedDependency.alias === 'dev-pkg',
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
devDependencies: {
|
||||
'dev-pkg': '/dev-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/dev-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/dev-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
devDependencies: {
|
||||
'dev-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('checks optionalDependencies', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: (wantedDependency) => wantedDependency.alias === 'opt-pkg',
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
optionalDependencies: {
|
||||
'opt-pkg': '/opt-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/opt-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/opt-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
optionalDependencies: {
|
||||
'opt-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('checks peerDependencies', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: (wantedDependency) => wantedDependency.alias === 'peer-pkg',
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
peerDependencies: {
|
||||
'peer-pkg': '/peer-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/peer-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/peer-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
peerDependencies: {
|
||||
'peer-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('checks all dependency types together', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
shouldForceResolve: (wantedDependency) => wantedDependency.alias === 'peer-pkg',
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'reg-pkg': '/reg-pkg@1.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'dev-pkg': '/dev-pkg@1.0.0',
|
||||
},
|
||||
optionalDependencies: {
|
||||
'opt-pkg': '/opt-pkg@1.0.0',
|
||||
},
|
||||
peerDependencies: {
|
||||
'peer-pkg': '/peer-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/reg-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/reg-pkg@1.0.0.tgz', integrity: 'sha512-test1' },
|
||||
},
|
||||
'/dev-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/dev-pkg-1.0.0.tgz', integrity: 'sha512-test2' },
|
||||
},
|
||||
'/opt-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/opt-pkg-1.0.0.tgz', integrity: 'sha512-test3' },
|
||||
},
|
||||
'/peer-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/peer-pkg-1.0.0.tgz', integrity: 'sha512-test4' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'reg-pkg': '1.0.0',
|
||||
},
|
||||
devDependencies: {
|
||||
'dev-pkg': '1.0.0',
|
||||
},
|
||||
optionalDependencies: {
|
||||
'opt-pkg': '1.0.0',
|
||||
},
|
||||
peerDependencies: {
|
||||
'peer-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('handles multiple projects', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: (wantedDependency) => wantedDependency.alias === 'pkg-b',
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'project-a': {
|
||||
dependencies: {
|
||||
'pkg-a': '/pkg-a@1.0.0',
|
||||
},
|
||||
},
|
||||
'project-b': {
|
||||
dependencies: {
|
||||
'pkg-b': '/pkg-b@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/pkg-a@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/pkg-a-1.0.0.tgz', integrity: 'sha512-test1' },
|
||||
},
|
||||
'/pkg-b@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/pkg-b-1.0.0.tgz', integrity: 'sha512-test2' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: 'project-a' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'pkg-a': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
{
|
||||
id: 'project-b' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'pkg-b': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('handles multiple custom resolvers - first matching returns true', async () => {
|
||||
const resolver1: CustomResolver = {
|
||||
canResolve: () => false,
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const resolver2: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver1, resolver2], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('handles async shouldForceResolve', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
shouldForceResolve: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
return true
|
||||
},
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('short-circuits on first true result', async () => {
|
||||
let callCount = 0
|
||||
const resolver: CustomResolver = {
|
||||
canResolve: () => true,
|
||||
shouldForceResolve: () => {
|
||||
callCount++
|
||||
return callCount === 1 // First call returns true
|
||||
},
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
pkg1: '/pkg1@1.0.0',
|
||||
pkg2: '/pkg2@1.0.0',
|
||||
pkg3: '/pkg3@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/pkg1@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/pkg1-1.0.0.tgz', integrity: 'sha512-test1' },
|
||||
},
|
||||
'/pkg2@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/pkg2-1.0.0.tgz', integrity: 'sha512-test2' },
|
||||
},
|
||||
'/pkg3@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/pkg3-1.0.0.tgz', integrity: 'sha512-test3' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
pkg1: '1.0.0',
|
||||
pkg2: '1.0.0',
|
||||
pkg3: '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(callCount).toBe(1) // Should stop after first true
|
||||
})
|
||||
|
||||
test('returns false when custom resolver has no canResolve method', async () => {
|
||||
const resolver: CustomResolver = {
|
||||
// No canResolve method at all
|
||||
shouldForceResolve: () => true,
|
||||
}
|
||||
const lockfile: LockfileObject = {
|
||||
lockfileVersion: '9.0',
|
||||
importers: {
|
||||
'.': {
|
||||
dependencies: {
|
||||
'test-pkg': '/test-pkg@1.0.0',
|
||||
},
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
packages: {
|
||||
'/test-pkg@1.0.0': {
|
||||
resolution: { tarball: 'http://example.com/test-pkg-1.0.0.tgz', integrity: 'sha512-test' },
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
}
|
||||
const projects: ProjectWithManifest[] = [
|
||||
{
|
||||
id: '.' as ProjectId,
|
||||
manifest: {
|
||||
dependencies: {
|
||||
'test-pkg': '1.0.0',
|
||||
},
|
||||
} as any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
},
|
||||
]
|
||||
|
||||
const result = await checkCustomResolverForceResolve([resolver], lockfile, projects)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
240
pkg-manager/core/test/install/customResolvers.ts
Normal file
240
pkg-manager/core/test/install/customResolvers.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { prepareEmpty } from '@pnpm/prepare'
|
||||
import { addDependenciesToPackage } from '@pnpm/core'
|
||||
import { type CustomResolver } from '@pnpm/hooks.types'
|
||||
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
|
||||
import { testDefaults } from '../utils/index.js'
|
||||
|
||||
// Test 1: Validate custom metadata flows through resolve() → lockfile
|
||||
// TODO: Unskip when fixed - tests timeout with "Jest environment has been torn down" errors during dependency resolution
|
||||
test.skip('custom resolver: metadata from resolve() is persisted to lockfile', async () => {
|
||||
const project = prepareEmpty()
|
||||
|
||||
const resolveCallCount = { count: 0 }
|
||||
const shouldForceResolveCallCount = { count: 0 }
|
||||
let savedCachedAt: number | undefined
|
||||
|
||||
// Custom resolver that wraps @pnpm.e2e/dep-of-pkg-with-1-dep and adds custom metadata
|
||||
const timestampResolver: CustomResolver = {
|
||||
canResolve: (descriptor) => {
|
||||
return descriptor.alias === '@pnpm.e2e/dep-of-pkg-with-1-dep'
|
||||
},
|
||||
|
||||
resolve: async (descriptor, opts) => {
|
||||
resolveCallCount.count++
|
||||
const now = Date.now()
|
||||
savedCachedAt = now
|
||||
|
||||
// Use standard npm resolution but add custom metadata
|
||||
return {
|
||||
id: '@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0',
|
||||
resolution: {
|
||||
integrity: 'sha512-jPYrv4nLDd6nHrJWCAddqh+R+7WsbsU/lZ3tpDBQpjteXJVbSGSaicpkVQJp7lbVSvBJzdF+GKmqXvQXLv4rIg==',
|
||||
tarball: `http://localhost:${REGISTRY_MOCK_PORT}/@pnpm.e2e/dep-of-pkg-with-1-dep/-/dep-of-pkg-with-1-dep-100.0.0.tgz`,
|
||||
cachedAt: now, // Custom metadata
|
||||
},
|
||||
manifest: {
|
||||
name: '@pnpm.e2e/dep-of-pkg-with-1-dep',
|
||||
version: '100.0.0',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
shouldForceResolve: (descriptor) => {
|
||||
shouldForceResolveCallCount.count++
|
||||
// Don't force re-resolution in this test
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
// First install - creates lockfile with custom metadata
|
||||
const { updatedManifest: manifest } = await addDependenciesToPackage(
|
||||
{},
|
||||
['@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0'],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(resolveCallCount.count).toBe(1)
|
||||
expect(shouldForceResolveCallCount.count).toBe(0) // Not called on first install
|
||||
|
||||
// Read lockfile to verify custom metadata was persisted
|
||||
const lockfile = project.readLockfile()
|
||||
const depPath = Object.keys(lockfile.packages ?? {})[0]
|
||||
expect(depPath).toBeTruthy()
|
||||
const pkgSnapshot = lockfile.packages?.[depPath]
|
||||
expect((pkgSnapshot?.resolution as any)?.cachedAt).toBe(savedCachedAt) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
// Second install - reads from lockfile and calls shouldForceResolve
|
||||
await addDependenciesToPackage(
|
||||
manifest,
|
||||
[],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// On second install, shouldForceResolve should be called
|
||||
expect(shouldForceResolveCallCount.count).toBe(1)
|
||||
// resolve() should not be called again since shouldForceResolve returned false
|
||||
expect(resolveCallCount.count).toBe(1)
|
||||
|
||||
// Verify custom metadata still in lockfile after second install
|
||||
const lockfile2 = project.readLockfile()
|
||||
const depPath2 = Object.keys(lockfile2.packages ?? {})[0]
|
||||
const pkgSnapshot2 = lockfile2.packages?.[depPath2]
|
||||
expect((pkgSnapshot2?.resolution as any)?.cachedAt).toBe(savedCachedAt) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
})
|
||||
|
||||
// Test 2: Validate custom resolver works for both fresh and cached installs
|
||||
// TODO: Unskip when fixed - tests timeout with "Jest environment has been torn down" errors during dependency resolution
|
||||
test.skip('custom resolver: works for fresh resolve() and lockfile resolutions', async () => {
|
||||
const project = prepareEmpty()
|
||||
|
||||
let resolveCallCount = 0
|
||||
|
||||
// Custom resolver that wraps standard resolution but tracks calls
|
||||
const timestampResolver: CustomResolver = {
|
||||
canResolve: (descriptor) => {
|
||||
return descriptor.alias === '@pnpm.e2e/pkg-with-1-dep'
|
||||
},
|
||||
|
||||
resolve: async (descriptor, _opts) => {
|
||||
resolveCallCount++
|
||||
// Use standard npm resolution
|
||||
return {
|
||||
id: '@pnpm.e2e/pkg-with-1-dep@100.0.0',
|
||||
resolution: {
|
||||
integrity: 'sha512-1MYbHCSEbOwCLN6cERxVQcVH/W0dXIz9YSv6dBdq3CaWVqx11tMwFt6o7gPBs/r2eDxPEz+CDNT/ZFNYNt78wg==',
|
||||
tarball: `http://localhost:${REGISTRY_MOCK_PORT}/@pnpm.e2e/pkg-with-1-dep/-/pkg-with-1-dep-100.0.0.tgz`,
|
||||
},
|
||||
manifest: {
|
||||
name: '@pnpm.e2e/pkg-with-1-dep',
|
||||
version: '100.0.0',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// First install - fresh resolution, custom resolver should be used
|
||||
const { updatedManifest: manifest } = await addDependenciesToPackage(
|
||||
{},
|
||||
['@pnpm.e2e/pkg-with-1-dep@100.0.0'],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// Verify custom resolver resolve was called for fresh resolution
|
||||
expect(resolveCallCount).toBe(1)
|
||||
|
||||
project.has('@pnpm.e2e/pkg-with-1-dep')
|
||||
|
||||
// Second install - should read from lockfile
|
||||
resolveCallCount = 0
|
||||
await addDependenciesToPackage(
|
||||
manifest,
|
||||
[],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// Verify custom resolver resolve was not called again (using cached lockfile)
|
||||
expect(resolveCallCount).toBe(0)
|
||||
|
||||
project.has('@pnpm.e2e/pkg-with-1-dep')
|
||||
})
|
||||
|
||||
// Test 3: Validate shouldForceResolve can trigger re-resolution
|
||||
// TODO: Unskip when fixed - tests timeout with "Jest environment has been torn down" errors during dependency resolution
|
||||
test.skip('custom resolver: shouldForceResolve=true triggers re-resolution', async () => {
|
||||
const project = prepareEmpty()
|
||||
|
||||
let resolveCallCount = 0
|
||||
let shouldForceReturn = false
|
||||
|
||||
const timestampResolver: CustomResolver = {
|
||||
canResolve: (descriptor) => {
|
||||
return descriptor.alias === '@pnpm.e2e/foo'
|
||||
},
|
||||
|
||||
resolve: async (descriptor, _opts) => {
|
||||
resolveCallCount++
|
||||
|
||||
return {
|
||||
id: `@pnpm.e2e/foo@100.${resolveCallCount}.0`,
|
||||
resolution: {
|
||||
integrity: 'sha512-c3bT3gLTuSRfC0pbs4TgMGjeN1t7eJGwb2vWVx/zUYJp+CsVj3cMNWPanEjahorIUXpW/senCGjMHfyFWLiM4Q==',
|
||||
tarball: `http://localhost:${REGISTRY_MOCK_PORT}/@pnpm.e2e/foo/-/foo-100.0.0.tgz`,
|
||||
resolveCount: resolveCallCount, // Track how many times resolve was called
|
||||
},
|
||||
manifest: {
|
||||
name: '@pnpm.e2e/foo',
|
||||
version: '100.0.0',
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
shouldForceResolve: () => {
|
||||
return shouldForceReturn
|
||||
},
|
||||
}
|
||||
|
||||
// First install
|
||||
const { updatedManifest: manifest1 } = await addDependenciesToPackage(
|
||||
{},
|
||||
['@pnpm.e2e/foo@100.0.0'],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(resolveCallCount).toBe(1)
|
||||
|
||||
// Second install with shouldForceResolve returning false
|
||||
shouldForceReturn = false
|
||||
const { updatedManifest: manifest2 } = await addDependenciesToPackage(
|
||||
manifest1,
|
||||
[],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// resolve() should not be called again
|
||||
expect(resolveCallCount).toBe(1)
|
||||
|
||||
// Third install with shouldForceResolve returning true
|
||||
shouldForceReturn = true
|
||||
await addDependenciesToPackage(
|
||||
manifest2,
|
||||
[],
|
||||
testDefaults({
|
||||
hooks: {
|
||||
customResolvers: [timestampResolver],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
// resolve() should be called again due to forced re-resolution
|
||||
expect(resolveCallCount).toBe(2)
|
||||
|
||||
// Verify lockfile was updated with new resolveCount
|
||||
const lockfile = project.readLockfile()
|
||||
const depPath = Object.keys(lockfile.packages ?? {})[0]
|
||||
const pkgSnapshot = lockfile.packages?.[depPath]
|
||||
expect((pkgSnapshot?.resolution as any)?.resolveCount).toBe(2) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
})
|
||||
@@ -39,6 +39,7 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/fetcher-base": "workspace:*",
|
||||
"@pnpm/graceful-fs": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/package-is-installable": "workspace:*",
|
||||
"@pnpm/pick-fetcher": "workspace:*",
|
||||
"@pnpm/read-package-json": "workspace:*",
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type WantedDependency,
|
||||
} from '@pnpm/store-controller-types'
|
||||
import { type DependencyManifest, type SupportedArchitectures } from '@pnpm/types'
|
||||
import { type CustomFetcher } from '@pnpm/hooks.types'
|
||||
import { depPathToFilename } from '@pnpm/dependency-path'
|
||||
import { calcMaxWorkers, readPkgFromCafs as _readPkgFromCafs } from '@pnpm/worker'
|
||||
import { familySync } from 'detect-libc'
|
||||
@@ -102,6 +103,7 @@ export function createPackageRequester (
|
||||
verifyStoreIntegrity: boolean
|
||||
virtualStoreDirMaxLength: number
|
||||
strictStorePkgContentCheck?: boolean
|
||||
customFetchers?: CustomFetcher[]
|
||||
}
|
||||
): RequestPackageFunction & {
|
||||
fetchPackageToStore: FetchPackageToStoreFunction
|
||||
@@ -116,7 +118,7 @@ export function createPackageRequester (
|
||||
})
|
||||
|
||||
const getIndexFilePathInCafs = _getIndexFilePathInCafs.bind(null, opts.storeDir)
|
||||
const fetch = fetcher.bind(null, opts.fetchers, opts.cafs)
|
||||
const fetch = fetcher.bind(null, opts.fetchers, opts.cafs, opts.customFetchers)
|
||||
const fetchPackageToStore = fetchToStore.bind(null, {
|
||||
readPkgFromCafs: _readPkgFromCafs.bind(null, opts.storeDir, opts.verifyStoreIntegrity),
|
||||
fetch,
|
||||
@@ -694,13 +696,19 @@ async function tarballIsUpToDate (
|
||||
async function fetcher (
|
||||
fetcherByHostingType: Fetchers,
|
||||
cafs: Cafs,
|
||||
customFetchers: CustomFetcher[] | undefined,
|
||||
packageId: string,
|
||||
resolution: AtomicResolution,
|
||||
opts: FetchOptions
|
||||
): Promise<FetchResult> {
|
||||
const fetch = pickFetcher(fetcherByHostingType, resolution)
|
||||
try {
|
||||
return await fetch(cafs, resolution as any, opts) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// pickFetcher now handles custom fetcher hooks internally
|
||||
const fetch = await pickFetcher(fetcherByHostingType, resolution, {
|
||||
customFetchers,
|
||||
packageId,
|
||||
})
|
||||
const result = await fetch(cafs, resolution as any, opts) // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
return result
|
||||
} catch (err: any) { // eslint-disable-line
|
||||
packageRequestLogger.warn({
|
||||
message: `Fetching ${packageId} failed!`,
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
{
|
||||
"path": "../../fs/v8-file"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/core-loggers"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@pnpm/core-loggers": "workspace:*",
|
||||
"@pnpm/dependency-path": "workspace:*",
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/lockfile.preferred-versions": "workspace:*",
|
||||
"@pnpm/lockfile.pruner": "workspace:*",
|
||||
"@pnpm/lockfile.types": "workspace:*",
|
||||
|
||||
@@ -66,6 +66,7 @@ function toLockfileDependency (
|
||||
opts.registry,
|
||||
opts.lockfileIncludeTarballUrl
|
||||
)
|
||||
|
||||
const newResolvedDeps = updateResolvedDeps(
|
||||
opts.updatedDeps,
|
||||
opts.depGraph
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../../fetching/pick-fetcher"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../lockfile/preferred-versions"
|
||||
},
|
||||
|
||||
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
@@ -2157,6 +2157,9 @@ importers:
|
||||
'@pnpm/dependency-path':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/dependency-path
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/lockfile.fs':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/fs
|
||||
@@ -3220,19 +3223,47 @@ importers:
|
||||
version: 3.0.0
|
||||
|
||||
fetching/pick-fetcher:
|
||||
dependencies:
|
||||
'@pnpm/cafs-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/cafs-types
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/fetcher-base':
|
||||
specifier: workspace:*
|
||||
version: link:../fetcher-base
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/resolver-base':
|
||||
specifier: workspace:*
|
||||
version: link:../../resolving/resolver-base
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.0.5
|
||||
'@pnpm/fetcher-base':
|
||||
'@pnpm/create-cafs-store':
|
||||
specifier: workspace:*
|
||||
version: link:../fetcher-base
|
||||
version: link:../../store/create-cafs-store
|
||||
'@pnpm/fetch':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/fetch
|
||||
'@pnpm/pick-fetcher':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
'@pnpm/resolver-base':
|
||||
'@pnpm/tarball-fetcher':
|
||||
specifier: workspace:*
|
||||
version: link:../../resolving/resolver-base
|
||||
version: link:../tarball-fetcher
|
||||
'@pnpm/test-fixtures':
|
||||
specifier: workspace:*
|
||||
version: link:../../__utils__/test-fixtures
|
||||
nock:
|
||||
specifier: 'catalog:'
|
||||
version: 13.3.4
|
||||
tempy:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.0
|
||||
|
||||
fetching/tarball-fetcher:
|
||||
dependencies:
|
||||
@@ -3591,13 +3622,25 @@ importers:
|
||||
|
||||
hooks/types:
|
||||
dependencies:
|
||||
'@pnpm/cafs-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/cafs-types
|
||||
'@pnpm/fetcher-base':
|
||||
specifier: workspace:*
|
||||
version: link:../../fetching/fetcher-base
|
||||
'@pnpm/lockfile.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/types
|
||||
'@pnpm/resolver-base':
|
||||
specifier: workspace:*
|
||||
version: link:../../resolving/resolver-base
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.0.5
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
@@ -4065,6 +4108,12 @@ importers:
|
||||
'@pnpm/dependency-path':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/dependency-path
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/lockfile.types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
@@ -4852,6 +4901,9 @@ importers:
|
||||
'@pnpm/git-fetcher':
|
||||
specifier: workspace:*
|
||||
version: link:../../fetching/git-fetcher
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/network.auth-header':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/auth-header
|
||||
@@ -5669,6 +5721,9 @@ importers:
|
||||
'@pnpm/graceful-fs':
|
||||
specifier: workspace:*
|
||||
version: link:../../fs/graceful-fs
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/package-is-installable':
|
||||
specifier: workspace:*
|
||||
version: link:../../config/package-is-installable
|
||||
@@ -6151,6 +6206,9 @@ importers:
|
||||
'@pnpm/error':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/error
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/lockfile.preferred-versions':
|
||||
specifier: workspace:*
|
||||
version: link:../../lockfile/preferred-versions
|
||||
@@ -7189,6 +7247,9 @@ importers:
|
||||
'@pnpm/git-resolver':
|
||||
specifier: workspace:*
|
||||
version: link:../git-resolver
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/local-resolver':
|
||||
specifier: workspace:*
|
||||
version: link:../local-resolver
|
||||
@@ -7210,13 +7271,31 @@ importers:
|
||||
'@pnpm/tarball-resolver':
|
||||
specifier: workspace:*
|
||||
version: link:../tarball-resolver
|
||||
'@pnpm/types':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/types
|
||||
devDependencies:
|
||||
'@jest/globals':
|
||||
specifier: 'catalog:'
|
||||
version: 30.0.5
|
||||
'@pnpm/cafs-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../store/cafs-types
|
||||
'@pnpm/default-resolver':
|
||||
specifier: workspace:*
|
||||
version: 'link:'
|
||||
'@pnpm/fetch':
|
||||
specifier: workspace:*
|
||||
version: link:../../network/fetch
|
||||
'@pnpm/fetcher-base':
|
||||
specifier: workspace:*
|
||||
version: link:../../fetching/fetcher-base
|
||||
'@pnpm/tarball-fetcher':
|
||||
specifier: workspace:*
|
||||
version: link:../../fetching/tarball-fetcher
|
||||
node-fetch:
|
||||
specifier: 'catalog:'
|
||||
version: 3.3.2
|
||||
|
||||
resolving/deno-resolver:
|
||||
dependencies:
|
||||
@@ -8122,6 +8201,9 @@ importers:
|
||||
'@pnpm/fs.v8-file':
|
||||
specifier: workspace:*
|
||||
version: link:../../fs/v8-file
|
||||
'@pnpm/hooks.types':
|
||||
specifier: workspace:*
|
||||
version: link:../../hooks/types
|
||||
'@pnpm/package-requester':
|
||||
specifier: workspace:*
|
||||
version: link:../../pkg-manager/package-requester
|
||||
|
||||
@@ -175,24 +175,29 @@ function convertPackageSnapshot (inputSnapshot: PackageSnapshot, opts: ConvertOp
|
||||
let outputResolution: LockfileResolution
|
||||
if ('integrity' in inputResolution) {
|
||||
outputResolution = inputResolution
|
||||
} else if ('tarball' in inputResolution) {
|
||||
} else if ('tarball' in inputResolution && typeof inputResolution.tarball === 'string') {
|
||||
outputResolution = { ...inputResolution }
|
||||
if (inputResolution.tarball.startsWith('file:')) {
|
||||
const inputPath = inputResolution.tarball.slice('file:'.length)
|
||||
const resolvedPath = path.resolve(opts.lockfileDir, inputPath)
|
||||
const outputPath = normalizePath(path.relative(opts.deployDir, resolvedPath))
|
||||
outputResolution.tarball = `file:${outputPath}`
|
||||
if (inputResolution.path) outputResolution.path = outputPath
|
||||
if ('path' in inputResolution && typeof inputResolution.path === 'string') {
|
||||
outputResolution.path = outputPath
|
||||
}
|
||||
}
|
||||
} else if (inputResolution.type === 'directory') {
|
||||
const resolvedPath = path.resolve(opts.lockfileDir, inputResolution.directory)
|
||||
const dirResolution = inputResolution as DirectoryResolution
|
||||
const resolvedPath = path.resolve(opts.lockfileDir, dirResolution.directory)
|
||||
const directory = normalizePath(path.relative(opts.deployDir, resolvedPath))
|
||||
outputResolution = { ...inputResolution, directory }
|
||||
outputResolution = { ...dirResolution, directory }
|
||||
} else if (inputResolution.type === 'git' || inputResolution.type === 'variations') {
|
||||
outputResolution = inputResolution
|
||||
} else if (inputResolution.type && typeof inputResolution.type === 'string') {
|
||||
// Custom resolution type - pass through as-is
|
||||
outputResolution = inputResolution
|
||||
} else {
|
||||
const resolution: never = inputResolution // `never` is the type guard to force fixing this code when adding new type of resolution
|
||||
throw new Error(`Unknown resolution type: ${JSON.stringify(resolution)}`)
|
||||
throw new Error(`Unknown resolution type: ${JSON.stringify(inputResolution)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ProjectManifest, DEPENDENCIES_FIELDS } from '@pnpm/types'
|
||||
import { type BaseManifest, DEPENDENCIES_FIELDS } from '@pnpm/types'
|
||||
|
||||
export function deployHook (pkg: ProjectManifest): ProjectManifest {
|
||||
export function deployHook<Pkg extends BaseManifest> (pkg: Pkg): Pkg {
|
||||
pkg.dependenciesMeta = pkg.dependenciesMeta ?? {}
|
||||
for (const depField of DEPENDENCIES_FIELDS) {
|
||||
for (const [depName, depVersion] of Object.entries(pkg[depField] ?? {})) {
|
||||
|
||||
@@ -36,17 +36,24 @@
|
||||
"@pnpm/error": "workspace:*",
|
||||
"@pnpm/fetching-types": "workspace:*",
|
||||
"@pnpm/git-resolver": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/local-resolver": "workspace:*",
|
||||
"@pnpm/node.resolver": "workspace:*",
|
||||
"@pnpm/npm-resolver": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*",
|
||||
"@pnpm/resolving.bun-resolver": "workspace:*",
|
||||
"@pnpm/resolving.deno-resolver": "workspace:*",
|
||||
"@pnpm/tarball-resolver": "workspace:*"
|
||||
"@pnpm/tarball-resolver": "workspace:*",
|
||||
"@pnpm/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "catalog:",
|
||||
"@pnpm/cafs-types": "workspace:*",
|
||||
"@pnpm/default-resolver": "workspace:*",
|
||||
"@pnpm/fetch": "workspace:*"
|
||||
"@pnpm/fetch": "workspace:*",
|
||||
"@pnpm/fetcher-base": "workspace:*",
|
||||
"@pnpm/tarball-fetcher": "workspace:*",
|
||||
"node-fetch": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19"
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
import {
|
||||
type ResolveFunction,
|
||||
type ResolveOptions,
|
||||
type ResolveResult,
|
||||
type WantedDependency,
|
||||
} from '@pnpm/resolver-base'
|
||||
import { type TarballResolveResult, resolveFromTarball } from '@pnpm/tarball-resolver'
|
||||
import { type CustomResolver, checkCustomResolverCanResolve } from '@pnpm/hooks.types'
|
||||
|
||||
export type {
|
||||
PackageMeta,
|
||||
@@ -29,6 +31,10 @@ export type {
|
||||
ResolverFactoryOptions,
|
||||
}
|
||||
|
||||
export interface CustomResolverResolveResult extends ResolveResult {
|
||||
resolvedVia: 'custom-resolver'
|
||||
}
|
||||
|
||||
export type DefaultResolveResult =
|
||||
| NpmResolveResult
|
||||
| JsrResolveResult
|
||||
@@ -39,14 +45,49 @@ export type DefaultResolveResult =
|
||||
| NodeRuntimeResolveResult
|
||||
| DenoRuntimeResolveResult
|
||||
| BunRuntimeResolveResult
|
||||
| CustomResolverResolveResult
|
||||
|
||||
export type DefaultResolver = (wantedDependency: WantedDependency, opts: ResolveOptions) => Promise<DefaultResolveResult>
|
||||
|
||||
async function resolveFromCustomResolvers (
|
||||
customResolvers: CustomResolver[],
|
||||
wantedDependency: WantedDependency,
|
||||
opts: ResolveOptions
|
||||
): Promise<DefaultResolveResult | null> {
|
||||
if (!customResolvers || customResolvers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
for (const customResolver of customResolvers) {
|
||||
// Skip custom resolvers that don't support both canResolve and resolve
|
||||
if (!customResolver.canResolve || !customResolver.resolve) continue
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const canResolve = await checkCustomResolverCanResolve(customResolver, wantedDependency)
|
||||
|
||||
if (canResolve) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await customResolver.resolve(wantedDependency, {
|
||||
lockfileDir: opts.lockfileDir,
|
||||
projectDir: opts.projectDir,
|
||||
preferredVersions: (opts.preferredVersions ?? {}) as unknown as Record<string, string>,
|
||||
})
|
||||
return {
|
||||
...result,
|
||||
resolvedVia: 'custom-resolver',
|
||||
} as DefaultResolveResult
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createResolver (
|
||||
fetchFromRegistry: FetchFromRegistry,
|
||||
getAuthHeader: GetAuthHeader,
|
||||
pnpmOpts: ResolverFactoryOptions & {
|
||||
rawConfig: Record<string, string>
|
||||
customResolvers?: CustomResolver[]
|
||||
}
|
||||
): { resolve: DefaultResolver, clearCache: () => void } {
|
||||
const { resolveFromNpm, resolveFromJsr, clearCache } = createNpmResolver(fetchFromRegistry, getAuthHeader, pnpmOpts)
|
||||
@@ -57,9 +98,13 @@ export function createResolver (
|
||||
const _resolveNodeRuntime = resolveNodeRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, rawConfig: pnpmOpts.rawConfig })
|
||||
const _resolveDenoRuntime = resolveDenoRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, rawConfig: pnpmOpts.rawConfig, resolveFromNpm })
|
||||
const _resolveBunRuntime = resolveBunRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, rawConfig: pnpmOpts.rawConfig, resolveFromNpm })
|
||||
const _resolveFromCustomResolvers = pnpmOpts.customResolvers
|
||||
? resolveFromCustomResolvers.bind(null, pnpmOpts.customResolvers)
|
||||
: null
|
||||
return {
|
||||
resolve: async (wantedDependency, opts) => {
|
||||
const resolution = await resolveFromNpm(wantedDependency, opts as ResolveFromNpmOptions) ??
|
||||
const resolution = await _resolveFromCustomResolvers?.(wantedDependency, opts) ??
|
||||
await resolveFromNpm(wantedDependency, opts as ResolveFromNpmOptions) ??
|
||||
await resolveFromJsr(wantedDependency, opts as ResolveFromNpmOptions) ??
|
||||
(wantedDependency.bareSpecifier && (
|
||||
await resolveFromTarball(fetchFromRegistry, wantedDependency as { bareSpecifier: string }) ??
|
||||
|
||||
405
resolving/default-resolver/test/customResolver.ts
Normal file
405
resolving/default-resolver/test/customResolver.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/// <reference path="../../../__typings__/index.d.ts"/>
|
||||
import { jest } from '@jest/globals'
|
||||
import { createResolver } from '@pnpm/default-resolver'
|
||||
import { type WantedDependency, type CustomResolver } from '@pnpm/hooks.types'
|
||||
import { Response } from 'node-fetch'
|
||||
|
||||
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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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(() => ({
|
||||
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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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' }, { 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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { 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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
})
|
||||
|
||||
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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
})
|
||||
|
||||
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],
|
||||
rawConfig: {},
|
||||
cacheDir: '/tmp/test-cache',
|
||||
offline: false,
|
||||
preferOffline: false,
|
||||
retry: {},
|
||||
timeout: 60000,
|
||||
registries: { default: 'https://registry.npmjs.org/' },
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -12,6 +12,15 @@
|
||||
{
|
||||
"path": "../../env/node.resolver"
|
||||
},
|
||||
{
|
||||
"path": "../../fetching/fetcher-base"
|
||||
},
|
||||
{
|
||||
"path": "../../fetching/tarball-fetcher"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../network/fetch"
|
||||
},
|
||||
@@ -21,6 +30,12 @@
|
||||
{
|
||||
"path": "../../packages/error"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/types"
|
||||
},
|
||||
{
|
||||
"path": "../../store/cafs-types"
|
||||
},
|
||||
{
|
||||
"path": "../bun-resolver"
|
||||
},
|
||||
|
||||
@@ -43,6 +43,11 @@ export interface GitResolution {
|
||||
type: 'git'
|
||||
}
|
||||
|
||||
export interface CustomResolution {
|
||||
type: `custom:${string}` // e.g., 'custom:cdn', 'custom:artifactory'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PlatformAssetTarget {
|
||||
os: string
|
||||
cpu: string
|
||||
@@ -59,6 +64,7 @@ export type AtomicResolution =
|
||||
| DirectoryResolution
|
||||
| GitResolution
|
||||
| BinaryResolution
|
||||
| CustomResolution
|
||||
|
||||
export interface VariationsResolution {
|
||||
type: 'variations'
|
||||
|
||||
@@ -261,7 +261,7 @@ export async function readPackageIndexFile (
|
||||
packageResolution.integrity as string,
|
||||
parsedId.nonSemverVersion ?? `${parsedId.name}@${parsedId.version}`
|
||||
)
|
||||
} else if (!packageResolution.type && packageResolution.tarball) {
|
||||
} else if (!packageResolution.type && 'tarball' in packageResolution && packageResolution.tarball) {
|
||||
const packageDirInStore = depPathToFilename(parse(id).nonSemverVersion ?? id, opts.virtualStoreDirMaxLength)
|
||||
pkgIndexFilePath = path.join(
|
||||
opts.storeDir,
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@pnpm/create-cafs-store": "workspace:*",
|
||||
"@pnpm/fetcher-base": "workspace:*",
|
||||
"@pnpm/fs.v8-file": "workspace:*",
|
||||
"@pnpm/hooks.types": "workspace:*",
|
||||
"@pnpm/package-requester": "workspace:*",
|
||||
"@pnpm/resolver-base": "workspace:*",
|
||||
"@pnpm/store-controller-types": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ImportIndexedPackageAsync,
|
||||
type StoreController,
|
||||
} from '@pnpm/store-controller-types'
|
||||
import { type CustomFetcher } from '@pnpm/hooks.types'
|
||||
import { addFilesFromDir, importPackage, initStoreDir } from '@pnpm/worker'
|
||||
import { prune } from './prune.js'
|
||||
|
||||
@@ -29,6 +30,7 @@ export interface CreatePackageStoreOptions {
|
||||
virtualStoreDirMaxLength: number
|
||||
strictStorePkgContentCheck?: boolean
|
||||
clearResolutionCache: () => void
|
||||
customFetchers?: CustomFetcher[]
|
||||
}
|
||||
|
||||
export function createPackageStore (
|
||||
@@ -58,6 +60,7 @@ export function createPackageStore (
|
||||
verifyStoreIntegrity: initOpts.verifyStoreIntegrity,
|
||||
virtualStoreDirMaxLength: initOpts.virtualStoreDirMaxLength,
|
||||
strictStorePkgContentCheck: initOpts.strictStorePkgContentCheck,
|
||||
customFetchers: initOpts.customFetchers,
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
{
|
||||
"path": "../../fs/v8-file"
|
||||
},
|
||||
{
|
||||
"path": "../../hooks/types"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/logger"
|
||||
},
|
||||
|
||||
@@ -66,6 +66,8 @@ export async function createNewStoreController (
|
||||
)
|
||||
const { resolve, fetchers, clearResolutionCache } = createClient({
|
||||
customFetchers: opts.hooks?.fetchers,
|
||||
customResolvers: opts.hooks?.customResolvers,
|
||||
customFetcherHooks: opts.hooks?.customFetchers,
|
||||
userConfig: opts.userConfig,
|
||||
unsafePerm: opts.unsafePerm,
|
||||
authConfig: opts.rawConfig,
|
||||
@@ -128,6 +130,7 @@ export async function createNewStoreController (
|
||||
virtualStoreDirMaxLength: opts.virtualStoreDirMaxLength,
|
||||
strictStorePkgContentCheck: opts.strictStorePkgContentCheck,
|
||||
clearResolutionCache,
|
||||
customFetchers: opts.hooks?.customFetchers,
|
||||
}),
|
||||
dir: opts.storeDir,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user