
pnpm 全局虚拟仓库下的 ESM NODE_PATH 解析esm-node-path-loader 设计与实现解析【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm导读pnpm 开启enableGlobalVirtualStore全局虚拟仓库后项目的提升hoisted依赖存放在项目目录之外CommonJS 依赖 Node.js 原生支持NODE_PATH环境变量尚可解析但 ESM 导入会无视NODE_PATH导致脚本中出现幽灵依赖解析失败。本文基于 pnpm 仓库中的 changeset 变更记录.changeset/gvs-esm-node-path-loader.md与双栈实现源码完整讲解 pnpm 如何通过注入NODE_OPTIONS --import的 resolve hook在 ESM 场景下恢复NODE_PATH查找覆盖pnpm run、pnpm exec、生命周期脚本与pnpm dlx的全部路径。读完你将掌握该机制的工作原理、hook 的实现细节、双 CLI 一致性保障以及它如何替代此前的pnpm/plugin-esm-node-path配置依赖方案。背景全局虚拟仓库为什么需要 ESM NODE_PATH 补丁全局虚拟仓库global virtual store的布局在默认布局下pnpm 把依赖安装到项目自己的node_modules/.pnpm虚拟仓库项目目录内的向上查找天然可达。而启用enableGlobalVirtualStore或在 workspace 中设置virtualStoreType: global后包目录被集中放置到项目目录之外每个 importer 只保留符号链接symlink与私有提升区private hoist即.pnpm/node_modules依赖的真实目录位于外部全局虚拟仓库中提升hoisted的幽灵依赖phantom dependencies只通过NODE_PATH可见。Rust CLI 侧的集成测试 pnpm/crates/cli/tests/suite/global_virtual_store/layout.rs#L327-L333 精确描述了这一困境在全局虚拟仓库下包目录位于项目之外Node 从它们的真实路径向上查找node_modules永远无法到达项目的私有提升区。脚本必须同时收到NODE_PATH和NODE_OPTIONS中的 ESMNODE_PATHloader flagCJS 与 ESM 的幽灵导入才能继续解析。Node.js 对 ESM 忽略 NODE_PATHNode.js 官方行为是解析 ESM 导入import时忽略NODE_PATH环境变量只有 CommonJS 的require会原生考虑NODE_PATH。这意味着在全局虚拟仓库布局下一个通过NODE_PATH才能找到的提升依赖在.mjs/import场景下会直接抛出ERR_MODULE_NOT_FOUND。TypeScript CLI 侧的实现源文件 pnpm11/exec/esm-node-path-loader/src/index.ts 与 Rust CLI 侧的嵌入式副本 pnpm/crates/config/src/esm_node_path_loader.rs 的开头注释均明确指出本机制构建的 resolve hook正是为了为默认 ESM 解析失败的裸说明符bare specifier恢复NODE_PATH查找。解决方案总览NODE_PATH NODE_OPTIONS --import 双管齐下changeset 描述的完整方案分两层层内容作用对象NODE_PATH指向项目私有提升区.pnpm/node_modulesCommonJSrequire原生解析NODE_OPTIONS --import注入一个 resolve hook恢复NODE_PATH查找ESMimport解析changeset 原文要点当enableGlobalVirtualStore开启时pnpm 为项目衍生的每个进程pnpm run、pnpm exec、生命周期脚本都设置指向项目提升node_modules的NODE_PATH并附加一个NODE_OPTIONS的--import标志注册一个恢复 ESM 导入NODE_PATH查找的 resolve hook。导入未声明幽灵依赖的依赖在全局虚拟仓库下依然可以解析——无论 CommonJS 还是 ESM——且无需安装pnpm/plugin-esm-node-path配置依赖。也就是说该方案彻底取代了此前的pnpm/plugin-esm-node-path配置依赖方案对应 issue pnpm/pnpm#9618 中讨论的幽灵依赖解析问题不再要求用户额外安装插件。Hook 实现原理逐行拆解解析策略先默认解析失败后按 NODE_PATH 条目重试无论 TypeScript 还是 Rust 版本hook 的核心策略完全一致先交给默认解析器只有抛ERR_MODULE_NOT_FOUND且是裸说明符时才依次用每个NODE_PATH条目重试。重试时通过合成父路径synthetic parent构造parentURL再走默认解析器const nodePaths (process.env.NODE_PATH ?? ).split(delimiter).filter(Boolean) const isBareSpecifier (specifier) !specifier.startsWith(.) !specifier.startsWith(/) !specifier.startsWith(#) !specifier.includes(:) // 伪代码示意完整实现见 src/index.ts 与 esm_node_path_loader.rs export async function resolve (specifier, context, nextResolve) { try { return await nextResolve(specifier, context) } catch (originalError) { if (originalError?.code ! ERR_MODULE_NOT_FOUND || !isBareSpecifier(specifier)) throw originalError for (const nodePath of nodePaths) { try { return await nextResolve(specifier, { ...context, parentURL: pathToFileURL(nodePath /x).href }) } catch (fallbackError) { if (fallbackError?.code ! ERR_MODULE_NOT_FOUND) throw fallbackError } } throw originalError } }几个关键设计点只拦截裸说明符isBareSpecifier排除以.、/、#开头以及包含:的说明符后者的典型是node:内置模块与data:/file:URL避免干扰相对导入与内置模块。合成父路径nodePath /xpnpm 放进NODE_PATH的每个条目都以node_modules结尾解析器检查parent dir/node_modules于是nodePath /x的父目录正好映射回该条目本身从而命中提升区的包。这解释了为何parentURL要指向条目内部而非条目本身。保留调用方的导出条件重试仍走默认解析器nextResolve而非手写查找逻辑因此调用方的 export conditionsimportvsrequire被原样保留双条件包dual-condition package不会解析错目标。只吞ERR_MODULE_NOT_FOUND如果某个条目命中但包内部导出子路径不合法例如ERR_PACKAGE_PATH_NOT_EXPORTED该错误会原样抛出而不是被误吞为找不到。版本适配registerHooks / register / 无 hook 三级回退hook 的注册逻辑针对不同 Node.js 版本做了分级回退详见 pnpm11/exec/esm-node-path-loader/src/index.ts 与 Rust 副本的注释Node.js 版本可用 API行为 22.15module.registerHooks()注册同步风格的registerHooks({ resolve }) 18.19 且 22.15只有module.register()通过data:URL 注册一个 off-thread hooks 模块ASYNC_LOADER_TEMPLATE其他--import从 18.18/19.0 起才可解析两者皆无不安装任何 hook——CJS 的NODE_PATH解析在此类运行时仍原生可用module.register()的 fallback 需要把 hooks 模块放到独立线程加载因此它被单独做成一个导出了resolve函数的模块ASYNC_LOADER_SOURCE/ASYNC_LOADER_TEMPLATE通过data:text/javascriptURL 传给register()。自包含的 data: URL 标志最精巧的设计在于整个注册模块被内联inline进NODE_OPTIONS标志本身做成一个data: URLexport const esmNodePathLoaderImportFlag --importdata:text/javascript,${strictUriEncode(REGISTRATION_SOURCE)}这样做的好处代码注释明确说明标志是自包含常量子进程加载 hook 时不需要磁盘上存在任何文件无论子进程由哪个项目、哪个 pnpm 版本拉起标志始终有效当NODE_PATH为空时注册模块直接退出不安装任何 hook见REGISTRATION_SOURCE开头的if (process.env.NODE_PATH)守卫。特殊编码为什么不能用 encodeURIComponent由于标志要放进NODE_OPTIONS环境变量Node 的 tokenizer 会把单引号当作引号分隔符并从标志中剥掉因此编码函数必须比 JS 内置的encodeURIComponent更严格。两套实现都采用strict URI encode百分号编码所有非 RFC 3986 unreserved 字符其中额外编码!()*——单引号尤其关键TypeScript 侧pnpm11/exec/esm-node-path-loader/src/index.ts#L89-L91在encodeURIComponent结果上再替换[!()*]Rust 侧pnpm/crates/config/src/esm_node_path_loader.rs#L99-L115手写strict_uri_encode逐个字节判定是否属于A-Za-z0-9-_.~否则输出%XX。Rust 版本还为此专门加了一条测试断言标志绝不包含NODE_OPTIONStokenizer 会拆分或反引号的字符空白与\见 pnpm/crates/config/src/esm_node_path_loader/tests.rs#L23-L28。双 CLI 一致性与 golden 测试pnpm 同时维护 TypeScript CLIpnpm11/目录与 Rust CLIpnpm/目录二进制名pacquet两套实现必须注入完全相同的NODE_OPTIONS值。为此两处源码互为完全一致的副本并用同一份 golden 文件锁死TypeScript 侧测试读取 pnpm11/exec/esm-node-path-loader/test/import-flag.txt断言esmNodePathLoaderImportFlag与其逐字相等pnpm11/exec/esm-node-path-loader/test/index.ts#L20-L27Rust 侧测试读取同一个文件相对路径pnpm11/exec/esm-node-path-loader/test/import-flag.txt断言 Rust 生成的标志相等pnpm/crates/config/src/esm_node_path_loader/tests.rs#L10-L21。因此任何一侧对 hook 源码的改动都会导致至少一个测试失败从机制上杜绝了双栈漂移drift。标志注入与合并逻辑addEsmNodePathLoaderOption向 NODE_OPTIONS 追加标志export function addEsmNodePathLoaderOption (nodeOptions: string | undefined): string { if (!nodeOptions) return esmNodePathLoaderImportFlag if (nodeOptions.includes(esmNodePathLoaderImportFlag)) return nodeOptions return ${nodeOptions} ${esmNodePathLoaderImportFlag} }NODE_OPTIONS为空/未定义时只返回标志本身已包含标志时原样返回去重幂等否则在既有选项如--max-old-space-size4096之后追加。keepEsmNodePathLoaderOption配置替换后保活当用户的nodeOptions配置覆盖了 pnpm 先前构造的NODE_OPTIONS时如果旧值里携带过 loader 标志新值需要重新补上如果旧值从未携带过则保持新值不动pnpm11/exec/esm-node-path-loader/src/index.ts#L106-L111 与 pnpm/crates/config/src/esm_node_path_loader.rs#L130-L142。Rust CLI 在extra_env_with_node_options中实际调用它把nodeOptions设置应用为NODE_OPTIONS并保留 loader 标志见 pnpm/crates/config/src/layout.rs#L190-L203。两条函数的单元测试在 pnpm11/exec/esm-node-path-loader/test/index.ts#L29-L56 与 pnpm/crates/config/src/esm_node_path_loader/tests.rs#L31-L58双栈行为一一对应。覆盖范围run / exec / lifecycle / dlxchangeset 明确列出了该机制覆盖的进程类别TypeScript CLI 与 Rust CLI 各自落实pnpm run 与 pnpm execTypeScript CLI 在构建任务图时将nodeOptions与extraEnv合并进NODE_OPTIONS并调用keepEsmNodePathLoaderOption防止标志丢失pnpm11/exec/commands/src/exec.ts#L224-L227pnpm execpnpm11/exec/commands/src/run.ts#L239-L242pnpm runpnpm exec --shell-mode、递归执行-r等路径同样经过此合并逻辑。生命周期脚本lifecycle scriptsRust CLI 的端到端测试完整复现了该场景pnpm/crates/cli/tests/suite/global_virtual_store/layout.rs#L335-L389 的scripts_resolve_phantom_esm_imports_through_the_private_hoist测试构造 workspace设置privateHoistPattern: [*]安装pnpm.e2e/pkg-with-1-dep其自身又依赖pnpm.e2e/dep-of-pkg-with-1-dep后者未在 package.json 中声明check.mjs脚本记录NODE_PATH与NODE_OPTIONS后直接await import(pnpm.e2e/dep-of-pkg-with-1-dep)执行pnpm run check成功后断言NODE_PATH的某个条目以node_modules/.pnpm/node_modules结尾即私有提升区、NODE_OPTIONS包含 ESM loader 标志、幽灵导入成功写出resolved。这证明即使依赖从未被声明只要它被提升到私有提升区pnpm run拉起的脚本进程在 CJS 与 ESM 下都能解析到它。pnpm dlxchangeset 对 dlx 的描述需要区分两个 CLIpnpm dlx运行的工具也能解析此类依赖JS CLI 给它们传递相同环境而 Rust CLI 的 dlx 缓存是自包含的其布局天然暴露了这些依赖。TypeScript CLI在 pnpm11/exec/commands/src/dlx.ts#L241-L250 中当enableGlobalVirtualStore开启时extraEnv会携带NODE_OPTIONS: addEsmNodePathLoaderOption(process.env.NODE_OPTIONS)。注释说明bin 的命令 shim 负责提供NODE_PATHloader 则让这些查找在 ESM 导入下同样生效。Rust CLI从 pnpm/crates/cli/src/cli_args/dlx/cache.rs 的注释可以推断其 dlx 缓存采用自包含布局包已按提升后的布局实体化因此依赖天然对工具可见无需额外注入。用测试验证行为边界pnpm11/exec/esm-node-path-loader/test/index.ts 提供了一套行为完备的集成测试其中前三个用例直接 spawn 真实 Node.js 进程验证幽灵 ESM 导入通过 NODE_PATH 解析#L58-L81临时构造一个store/node_modules/phantom-depmain.mjs中import dep from phantom-dep。不携带 loader 标志时进程退出码非 0ESM 无视NODE_PATH携带NODE_OPTIONSesmNodePathLoaderImportFlag后 stderr 为空、退出码为 0、stdout 输出phantom-resolved。fallback 保留双条件包的 import 条件#L83-L112包exports同时声明import: ./esm.mjs与require: ./cjs.jsmain.mjs中import解析结果必须是esm-target而非cjs-target——证明合成父路径的重试没有破坏导出条件语义。错误边界命中条目但子路径未导出时抛出ERR_PACKAGE_PATH_NOT_EXPORTED而非被吞掉#L114-L136说明符在所有NODE_PATH条目中都找不到时仍然干净地失败并报出原说明符#L138-L151。从配置到行为的完整调用链综合以上源码可以得到该特性从配置到进程环境的完整链路用户在.npmrc/pnpm-workspace.yaml设置enableGlobalVirtualStoretrueRust CLI 侧还支持virtualStoreType: global安装期将包实体化到全局虚拟仓库并为项目建立私有提升区.pnpm/node_modules配置层RustConfig::extra_env为脚本进程导出NODE_PATH指向私有提升区当nodeOptions设置存在时通过keep_esm_node_path_loader_option把 loader 标志保活进NODE_OPTIONSpnpm/crates/config/src/layout.rs#L190-L203pnpm run/pnpm exec/lifecycle 脚本进程继承该环境Node.js 启动时--import加载 data: URL 注册模块按运行时能力选择registerHooks/register/无 hookESM 解析失败时resolve hook 逐个NODE_PATH条目重试恢复幽灵依赖解析。适用前提与边界该机制只在enableGlobalVirtualStore全局虚拟仓库开启时注入默认的 project 级虚拟仓库布局下Node 的向上node_modules查找已足够无需此补丁。该机制解决的是依赖解析问题幽灵依赖不是依赖声明规范——不声明依赖仍不推荐只是不再因此中断运行。老运行时无registerHooks且无register即--import解析之前的版本不会安装任何 hook此时 ESM 幽灵依赖解析不可用但 CJS 仍原生可用。此方案取代了此前的pnpm/plugin-esm-node-path配置依赖插件方案无需再安装该插件。参考实现与测试路径变更记录.changeset/gvs-esm-node-path-loader.mdTypeScript CLI 实现pnpm11/exec/esm-node-path-loader/src/index.tsTypeScript CLI 测试pnpm11/exec/esm-node-path-loader/test/index.ts双栈共享 golden 文件pnpm11/exec/esm-node-path-loader/test/import-flag.txtRust CLI 嵌入式副本pnpm/crates/config/src/esm_node_path_loader.rsRust CLI 测试pnpm/crates/config/src/esm_node_path_loader/tests.rs全局虚拟仓库端到端测试pnpm/crates/cli/tests/suite/global_virtual_store/layout.rs环境变量合并逻辑pnpm11/exec/commands/src/exec.ts、pnpm11/exec/commands/src/run.ts、pnpm11/exec/commands/src/dlx.ts【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考