With a global virtual store, package directories live outside the project, so Node's upward node_modules walk from their real paths never reaches the project's hoisted node_modules — undeclared (phantom) dependencies stop resolving, and Node ignores NODE_PATH entirely for ESM imports (Related to pnpm/pnpm#9618). When enableGlobalVirtualStore is on (and extendNodePath is not disabled), every child process pnpm spawns — run, exec, lifecycle scripts, dlx — now receives NODE_PATH pointing at the private hoist dir and the root node_modules, plus a NODE_OPTIONS --import flag that registers a resolve hook restoring NODE_PATH lookups for ESM, replacing the plugin-esm-node-path config dependency. The flag is a self-contained constant: the registration module and the hook are nested data: URLs, so no loader file has to exist on disk and the flag stays valid regardless of which project or pnpm version spawned the child. It prefers module.registerHooks() (in-thread, no DEP0205 deprecation warning) and falls back to module.register() on Node >=18.19 <22.15. Everything outside the RFC 3986 unreserved set is percent-encoded: encodeURIComponent alone leaves single quotes bare, and the NODE_OPTIONS tokenizer treats those as quote delimiters. Both stacks embed identical hook sources and each asserts its derived flag against the shared golden file pnpm11/exec/esm-node-path-loader/test/import-flag.txt, so they cannot drift apart silently. Where the nodeOptions setting overwrites NODE_OPTIONS (run, exec, recursive run, install lifecycle), the flag is re-applied. pacquet's dlx and global installs force GVS off for their self-contained trees and now strip the injected env from the caller's cloned config, so a tool never resolves phantoms from the invoking project's tree; the TypeScript dlx (GVS on by default) adds the flag and lets the bin shim supply NODE_PATH. This deliberately does not address the TypeScript-compiler side of pnpm/pnpm#13210: tsc, tsserver, and bundlers implement their own resolution and honor neither NODE_PATH nor Node loader hooks. That class remains packageExtensions / compat-DB territory.
153 lines
6.3 KiB
TypeScript
153 lines
6.3 KiB
TypeScript
import { spawnSync } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import os from 'node:os'
|
|
import path from 'node:path'
|
|
|
|
import { describe, expect, test } from '@jest/globals'
|
|
import {
|
|
addEsmNodePathLoaderOption,
|
|
esmNodePathLoaderImportFlag,
|
|
keepEsmNodePathLoaderOption,
|
|
} from '@pnpm/exec.esm-node-path-loader'
|
|
|
|
test('the flag is a self-contained data: URL import', () => {
|
|
expect(esmNodePathLoaderImportFlag).toMatch(/^--import=data:text\/javascript,/)
|
|
// The flag must never contain characters that Node's NODE_OPTIONS
|
|
// tokenizer would split or unquote.
|
|
expect(esmNodePathLoaderImportFlag).not.toMatch(/[\s"'\\]/)
|
|
})
|
|
|
|
test('the flag matches the golden copy shared with the Rust CLI', () => {
|
|
// The Rust CLI derives the same flag from its own embedded copy of the
|
|
// hook sources and asserts against the same file
|
|
// (pnpm/crates/config/src/esm_node_path_loader/tests.rs), so the two
|
|
// stacks cannot drift apart without one of the tests failing.
|
|
const golden = fs.readFileSync(new URL('./import-flag.txt', import.meta.url), 'utf8')
|
|
expect(esmNodePathLoaderImportFlag).toBe(golden)
|
|
})
|
|
|
|
describe('addEsmNodePathLoaderOption', () => {
|
|
test('returns just the flag when NODE_OPTIONS is empty', () => {
|
|
expect(addEsmNodePathLoaderOption(undefined)).toBe(esmNodePathLoaderImportFlag)
|
|
expect(addEsmNodePathLoaderOption('')).toBe(esmNodePathLoaderImportFlag)
|
|
})
|
|
|
|
test('appends the flag to existing NODE_OPTIONS', () => {
|
|
expect(addEsmNodePathLoaderOption('--max-old-space-size=4096'))
|
|
.toBe(`--max-old-space-size=4096 ${esmNodePathLoaderImportFlag}`)
|
|
})
|
|
|
|
test('does not duplicate the flag', () => {
|
|
const once = addEsmNodePathLoaderOption('--enable-source-maps')
|
|
expect(addEsmNodePathLoaderOption(once)).toBe(once)
|
|
})
|
|
})
|
|
|
|
describe('keepEsmNodePathLoaderOption', () => {
|
|
test('reapplies the flag when the replaced NODE_OPTIONS carried it', () => {
|
|
expect(keepEsmNodePathLoaderOption('--no-warnings', addEsmNodePathLoaderOption(undefined)))
|
|
.toBe(`--no-warnings ${esmNodePathLoaderImportFlag}`)
|
|
})
|
|
|
|
test('leaves NODE_OPTIONS alone when the previous value did not carry the flag', () => {
|
|
expect(keepEsmNodePathLoaderOption('--no-warnings', undefined)).toBe('--no-warnings')
|
|
expect(keepEsmNodePathLoaderOption('--no-warnings', '--enable-source-maps')).toBe('--no-warnings')
|
|
})
|
|
})
|
|
|
|
describe('the registered loader', () => {
|
|
test('resolves a phantom ESM import through NODE_PATH', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
|
|
const phantomDir = path.join(tmp, 'store', 'node_modules', 'phantom-dep')
|
|
fs.mkdirSync(phantomDir, { recursive: true })
|
|
fs.writeFileSync(path.join(phantomDir, 'package.json'), JSON.stringify({ name: 'phantom-dep', version: '1.0.0', main: 'index.js' }))
|
|
fs.writeFileSync(path.join(phantomDir, 'index.js'), 'module.exports = "phantom-resolved"')
|
|
const appDir = path.join(tmp, 'app')
|
|
fs.mkdirSync(appDir)
|
|
const script = path.join(appDir, 'main.mjs')
|
|
fs.writeFileSync(script, 'import dep from "phantom-dep"\nconsole.log(dep)')
|
|
|
|
const env = {
|
|
...process.env,
|
|
NODE_PATH: path.join(tmp, 'store', 'node_modules'),
|
|
}
|
|
const withoutLoader = spawnSync(process.execPath, [script], { env: { ...env, NODE_OPTIONS: '' } })
|
|
expect(withoutLoader.status).not.toBe(0)
|
|
|
|
const withLoader = spawnSync(process.execPath, [script], { env: { ...env, NODE_OPTIONS: esmNodePathLoaderImportFlag } })
|
|
expect(withLoader.stderr.toString()).toBe('')
|
|
expect(withLoader.status).toBe(0)
|
|
expect(withLoader.stdout.toString().trim()).toBe('phantom-resolved')
|
|
})
|
|
|
|
test('the fallback preserves import conditions for dual-condition packages', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
|
|
const dualDir = path.join(tmp, 'store', 'node_modules', 'dual-dep')
|
|
fs.mkdirSync(dualDir, { recursive: true })
|
|
fs.writeFileSync(path.join(dualDir, 'package.json'), JSON.stringify({
|
|
name: 'dual-dep',
|
|
version: '1.0.0',
|
|
exports: {
|
|
'.': {
|
|
import: './esm.mjs',
|
|
require: './cjs.js',
|
|
},
|
|
},
|
|
}))
|
|
fs.writeFileSync(path.join(dualDir, 'esm.mjs'), 'export default "esm-target"')
|
|
fs.writeFileSync(path.join(dualDir, 'cjs.js'), 'module.exports = "cjs-target"')
|
|
const script = path.join(tmp, 'main.mjs')
|
|
fs.writeFileSync(script, 'import dep from "dual-dep"\nconsole.log(dep)')
|
|
|
|
const result = spawnSync(process.execPath, [script], {
|
|
env: {
|
|
...process.env,
|
|
NODE_PATH: path.join(tmp, 'store', 'node_modules'),
|
|
NODE_OPTIONS: esmNodePathLoaderImportFlag,
|
|
},
|
|
})
|
|
expect(result.stderr.toString()).toBe('')
|
|
expect(result.status).toBe(0)
|
|
expect(result.stdout.toString().trim()).toBe('esm-target')
|
|
})
|
|
|
|
test('a resolution error from a NODE_PATH entry surfaces instead of the not-found error', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
|
|
const depDir = path.join(tmp, 'store', 'node_modules', 'exported-dep')
|
|
fs.mkdirSync(depDir, { recursive: true })
|
|
fs.writeFileSync(path.join(depDir, 'package.json'), JSON.stringify({
|
|
name: 'exported-dep',
|
|
version: '1.0.0',
|
|
exports: { '.': './index.js' },
|
|
}))
|
|
fs.writeFileSync(path.join(depDir, 'index.js'), 'module.exports = "main"')
|
|
const script = path.join(tmp, 'main.mjs')
|
|
fs.writeFileSync(script, 'await import("exported-dep/unexported")')
|
|
|
|
const result = spawnSync(process.execPath, [script], {
|
|
env: {
|
|
...process.env,
|
|
NODE_PATH: path.join(tmp, 'store', 'node_modules'),
|
|
NODE_OPTIONS: esmNodePathLoaderImportFlag,
|
|
},
|
|
})
|
|
expect(result.status).not.toBe(0)
|
|
expect(result.stderr.toString()).toContain('ERR_PACKAGE_PATH_NOT_EXPORTED')
|
|
})
|
|
|
|
test('still fails cleanly when the specifier is nowhere on NODE_PATH', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-esm-node-path-loader-'))
|
|
const script = path.join(tmp, 'main.mjs')
|
|
fs.writeFileSync(script, 'await import("truly-missing-dep")')
|
|
const result = spawnSync(process.execPath, [script], {
|
|
env: {
|
|
...process.env,
|
|
NODE_PATH: tmp,
|
|
NODE_OPTIONS: esmNodePathLoaderImportFlag,
|
|
},
|
|
})
|
|
expect(result.status).not.toBe(0)
|
|
expect(result.stderr.toString()).toContain('truly-missing-dep')
|
|
})
|
|
})
|