feat: loading ESM pnpmfiles (#9730)

This commit is contained in:
Zoltan Kochan
2025-10-10 09:50:21 +02:00
committed by GitHub
parent 7d075325b9
commit e146e988ea
7 changed files with 65 additions and 30 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/pnpmfile": minor
"pnpm": minor
---
Added support for pnpmfiles written in ESM. They should have the `.mjs` extension: `.pnpmfile.mjs` [#9730](https://github.com/pnpm/pnpm/pull/9730).

View File

@@ -43,7 +43,7 @@ export async function getConfig (
const configModulesDir = path.join(config.lockfileDir ?? config.rootProjectManifestDir, 'node_modules/.pnpm-config')
pnpmfiles.unshift(...calcPnpmfilePathsOfPluginDeps(configModulesDir, config.configDependencies))
}
const { hooks, finders, resolvedPnpmfilePaths } = requireHooks(config.lockfileDir ?? config.dir, {
const { hooks, finders, resolvedPnpmfilePaths } = await requireHooks(config.lockfileDir ?? config.dir, {
globalPnpmfile: config.globalPnpmfile,
pnpmfiles,
tryLoadDefaultPnpmfile: config.tryLoadDefaultPnpmfile,

View File

@@ -45,14 +45,14 @@ export interface RequireHooksResult {
resolvedPnpmfilePaths: string[]
}
export function requireHooks (
export async function requireHooks (
prefix: string,
opts: {
globalPnpmfile?: string
pnpmfiles?: string[]
tryLoadDefaultPnpmfile?: boolean
}
): RequireHooksResult {
): Promise<RequireHooksResult> {
const pnpmfiles: PnpmfileEntry[] = []
if (opts.globalPnpmfile) {
pnpmfiles.push({
@@ -77,11 +77,11 @@ export function requireHooks (
}
const entries: PnpmfileEntryLoaded[] = []
const loadedFiles: string[] = []
for (const { path, includeInChecksum, optional } of pnpmfiles) {
await Promise.all(pnpmfiles.map(async ({ path, includeInChecksum, optional }) => {
const file = pathAbsolute(path, prefix)
if (!loadedFiles.includes(file)) {
loadedFiles.push(file)
const requirePnpmfileResult = requirePnpmfile(file, prefix)
const requirePnpmfileResult = await requirePnpmfile(file, prefix)
if (requirePnpmfileResult != null) {
entries.push({
file,
@@ -93,7 +93,7 @@ export function requireHooks (
throw new PnpmError('PNPMFILE_NOT_FOUND', `pnpmfile at "${file}" is not found`)
}
}
}
}))
const mergedFinders: Finders = {}
const cookedHooks: CookedHooks & Required<Pick<CookedHooks, 'readPackage' | 'preResolution' | 'afterAllResolved' | 'filterLog' | 'updateConfig'>> = {

View File

@@ -1,6 +1,8 @@
import assert from 'assert'
import fs from 'fs'
import path from 'path'
import util from 'util'
import { pathToFileURL } from 'url'
import { createRequire } from 'module'
import { PnpmError } from '@pnpm/error'
import { logger } from '@pnpm/logger'
@@ -37,9 +39,17 @@ export interface Pnpmfile {
finders?: Finders
}
export function requirePnpmfile (pnpmFilePath: string, prefix: string): { pnpmfileModule: Pnpmfile | undefined } | undefined {
export async function requirePnpmfile (pnpmFilePath: string, prefix: string): Promise<{ pnpmfileModule: Pnpmfile | undefined } | undefined> {
try {
const pnpmfile: Pnpmfile = require(pnpmFilePath)
let pnpmfile: Pnpmfile
// Check if it's an ESM module (ends with .mjs)
if (pnpmFilePath.endsWith('.mjs')) {
const url = pathToFileURL(path.resolve(pnpmFilePath)).href
pnpmfile = await import(url)
} else {
// Use require for CommonJS modules
pnpmfile = require(pnpmFilePath)
}
if (typeof pnpmfile === 'undefined') {
logger.warn({
message: `Ignoring the pnpmfile at "${pnpmFilePath}". It exports "undefined".`,
@@ -89,7 +99,7 @@ export function requirePnpmfile (pnpmFilePath: string, prefix: string): { pnpmfi
}
function pnpmFileExistsSync (pnpmFilePath: string): boolean {
const pnpmFileRealName = pnpmFilePath.endsWith('.cjs')
const pnpmFileRealName = pnpmFilePath.endsWith('.cjs') || pnpmFilePath.endsWith('.mjs')
? pnpmFilePath
: `${pnpmFilePath}.cjs`
return fs.existsSync(pnpmFileRealName)

View File

@@ -7,32 +7,32 @@ import { requirePnpmfile } from '../src/requirePnpmfile.js'
const defaultHookContext: HookContext = { log () {} }
const f = fixtures(import.meta.dirname)
test('ignoring a pnpmfile that exports undefined', () => {
const { pnpmfileModule: pnpmfile } = requirePnpmfile(path.join(import.meta.dirname, '__fixtures__/undefined.js'), import.meta.dirname)!
test('ignoring a pnpmfile that exports undefined', async () => {
const { pnpmfileModule: pnpmfile } = (await requirePnpmfile(path.join(import.meta.dirname, '__fixtures__/undefined.js'), import.meta.dirname))!
expect(pnpmfile).toBeUndefined()
})
test('readPackage hook run fails when returns undefined', () => {
test('readPackage hook run fails when returns undefined', async () => {
const pnpmfilePath = path.join(import.meta.dirname, '__fixtures__/readPackageNoReturn.js')
const { pnpmfileModule: pnpmfile } = requirePnpmfile(pnpmfilePath, import.meta.dirname)!
const { pnpmfileModule: pnpmfile } = (await requirePnpmfile(pnpmfilePath, import.meta.dirname))!
return expect(
pnpmfile!.hooks!.readPackage!({}, defaultHookContext)
).rejects.toEqual(new BadReadPackageHookError(pnpmfilePath, 'readPackage hook did not return a package manifest object.'))
})
test('readPackage hook run fails when returned dependencies is not an object', () => {
test('readPackage hook run fails when returned dependencies is not an object', async () => {
const pnpmfilePath = path.join(import.meta.dirname, '__fixtures__/readPackageNoObject.js')
const { pnpmfileModule: pnpmfile } = requirePnpmfile(pnpmfilePath, import.meta.dirname)!
const { pnpmfileModule: pnpmfile } = (await requirePnpmfile(pnpmfilePath, import.meta.dirname))!
return expect(
pnpmfile!.hooks!.readPackage!({}, defaultHookContext)
).rejects.toEqual(new BadReadPackageHookError(pnpmfilePath, 'readPackage hook returned package manifest object\'s property \'dependencies\' must be an object.'))
})
test('filterLog hook combines with the global hook', () => {
test('filterLog hook combines with the global hook', async () => {
const globalPnpmfile = path.join(import.meta.dirname, '__fixtures__/globalFilterLog.js')
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/filterLog.js')
const { hooks } = requireHooks(import.meta.dirname, { globalPnpmfile, pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { globalPnpmfile, pnpmfiles: [pnpmfile] })
expect(hooks.filterLog).toBeDefined()
expect(hooks.filterLog!).toHaveLength(2)
@@ -49,55 +49,55 @@ test('filterLog hook combines with the global hook', () => {
})).toBeFalsy()
})
test('ignoring the default pnpmfile if tryLoadDefaultPnpmfile is not set', () => {
const { hooks } = requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), {})
test('ignoring the default pnpmfile if tryLoadDefaultPnpmfile is not set', async () => {
const { hooks } = await requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), {})
expect(hooks.readPackage?.length).toBe(0)
})
test('loading the default pnpmfile if tryLoadDefaultPnpmfile is set to true', () => {
const { hooks } = requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), { tryLoadDefaultPnpmfile: true })
test('loading the default pnpmfile if tryLoadDefaultPnpmfile is set to true', async () => {
const { hooks } = await requireHooks(path.join(import.meta.dirname, '__fixtures__/default'), { tryLoadDefaultPnpmfile: true })
expect(hooks.readPackage?.length).toBe(1)
})
test('calculatePnpmfileChecksum is undefined when pnpmfile does not exist', async () => {
const { hooks } = requireHooks(import.meta.dirname, {})
const { hooks } = await requireHooks(import.meta.dirname, {})
expect(hooks.calculatePnpmfileChecksum).toBeUndefined()
})
test('calculatePnpmfileChecksum resolves to hash string for existing pnpmfile', async () => {
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/readPackageNoObject.js')
const { hooks } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
expect(typeof await hooks.calculatePnpmfileChecksum?.()).toBe('string')
})
test('calculatePnpmfileChecksum is undefined if pnpmfile even when it exports undefined', async () => {
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/undefined.js')
const { hooks } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
expect(hooks.calculatePnpmfileChecksum).toBeUndefined()
})
test('updateConfig throws an error if it returns undefined', async () => {
const pnpmfile = path.join(import.meta.dirname, '__fixtures__/updateConfigReturnsUndefined.js')
const { hooks } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
const { hooks } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile] })
expect(() => hooks.updateConfig![0]!({})).toThrow('The updateConfig hook returned undefined')
})
test('requireHooks throw an error if one of the specified pnpmfiles does not exist', async () => {
expect(() => requireHooks(import.meta.dirname, { pnpmfiles: ['does-not-exist.cjs'] })).toThrow('is not found')
await expect(requireHooks(import.meta.dirname, { pnpmfiles: ['does-not-exist.cjs'] })).rejects.toThrow('is not found')
})
test('requireHooks throws an error if there are two finders with the same name', async () => {
const findersDir = f.find('finders')
const pnpmfile1 = path.join(findersDir, 'finderFoo1.js')
const pnpmfile2 = path.join(findersDir, 'finderFoo2.js')
expect(() => requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })).toThrow('Finder "foo" defined in both')
await expect(requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })).rejects.toThrow('Finder "foo" defined in both')
})
test('requireHooks merges all the finders', async () => {
const findersDir = f.find('finders')
const pnpmfile1 = path.join(findersDir, 'finderFoo1.js')
const pnpmfile2 = path.join(findersDir, 'finderBar.js')
const { finders } = requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })
const { finders } = await requireHooks(import.meta.dirname, { pnpmfiles: [pnpmfile1, pnpmfile2] })
expect(finders.foo).toBeDefined()
expect(finders.bar).toBeDefined()
})

View File

@@ -314,8 +314,8 @@ export async function recursive (
limitInstallation(async () => {
const hooks = opts.ignorePnpmfile
? {}
: (() => {
const { hooks: pnpmfileHooks } = requireHooks(rootDir, opts)
: await (async () => {
const { hooks: pnpmfileHooks } = await requireHooks(rootDir, opts)
return {
...opts.hooks,
...pnpmfileHooks,

View File

@@ -335,6 +335,25 @@ module.exports = {
expect(nodeModulesFiles).toContain('is-number')
})
test('loading an ESM pnpmfile', async () => {
prepare()
fs.writeFileSync('.pnpmfile.mjs', `
export const hooks = {
updateConfig: (config) => ({
...config,
nodeLinker: 'hoisted',
}),
}`, 'utf8')
writeYamlFile('pnpm-workspace.yaml', { pnpmfile: ['.pnpmfile.mjs'] })
await execPnpm(['add', 'is-odd@1.0.0'])
const nodeModulesFiles = fs.readdirSync('node_modules')
expect(nodeModulesFiles).toContain('kind-of')
expect(nodeModulesFiles).toContain('is-number')
})
test('loading multiple pnpmfiles', async () => {
prepare()