pnpm 全局虚拟存储下本地目录依赖的 slot 隔离修复:从 `TypeError: Cannot read properties of undefined` 到按项目独立隔离

发布时间:2026/9/20 16:36:56
pnpm 全局虚拟存储下本地目录依赖的 slot 隔离修复:从 `TypeError: Cannot read properties of undefined` 到按项目独立隔离
pnpm 全局虚拟存储下本地目录依赖的 slot 隔离修复从TypeError: Cannot read properties of undefined到按项目独立隔离【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm导读本文聚焦 pnpm 在开启全局虚拟存储Global Virtual StoreGVS时安装本地目录依赖file:目录依赖与注入式 workspace 包的一次关键修复此前同一名称的本地目录依赖会在不同项目间共享同一个存储槽位slot导致项目可能被错误链接到另一个项目的依赖副本并触发TypeError: Cannot read properties of undefined (reading split)。通过阅读本仓库中 graph-hasher 的哈希实现、配置解析逻辑与端到端测试你将理解 GVS 槽位路径的生成原理、为什么目录依赖必须按项目隔离以及如何在自己的项目中规避与验证该问题。问题背景全局虚拟存储GVS与槽位slot机制pnpm 的默认布局是在每个项目的node_modules/.pnpm下维护项目级虚拟存储。而当开启全局虚拟存储后所有项目共享同一个位于存储区的虚拟存储目录包被按名称/版本/哈希摘要的目录结构排列再由node_modules中的符号链接指向对应槽位。相关的配置项在 pnpm11/config/reader/src/Config.ts 中定义enableGlobalVirtualStore?: boolean /** * The canonical spelling of Config.enableGlobalVirtualStore, derived * from it so pnpm config get answers either name. Nothing installs off * this field. */ virtualStoreType?: VirtualStoreType即enableGlobalVirtualStore是配置入口virtualStoreType是从它派生的规范拼写。从 pnpm11/config/reader/src/index.ts 的配置解析逻辑可以看到其默认行为if (pnpmConfig.enableGlobalVirtualStore null) { pnpmConfig.enableGlobalVirtualStore true }这行代码位于全局安装--global分支中全局安装模式下 GVS 默认开启。而普通项目安装则可以通过pnpm config set virtual-store-type global对应环境变量PNPM_CONFIG_VIRTUAL_STORE_TYPEglobal见 pnpm11/config/reader/src/index.ts来启用二者在配置层被统一映射pnpm11/config/reader/src/index.tsif (explicitlySetKeys.has(enableGlobalVirtualStore)) { pnpmConfig.virtualStoreType pnpmConfig.enableGlobalVirtualStore ? global : project }还有一个值得注意的细节在 CI 环境下如果用户未显式配置GVS 会被自动关闭pnpm11/config/reader/src/index.ts避免共享存储带来的状态耦合。故障现场两个典型症状本变更集对应的 gvs-local-directory-deps.md 描述了两个紧密关联的缺陷症状一TypeError: Cannot read properties of undefined (reading split)在开启全局虚拟存储的前提下安装本地file:目录依赖安装过程会以TypeError: Cannot read properties of undefined (reading split)失败。该问题在 pnpm 仓库中以 issue #13335 记录此处仅转述 issue 编号可到上游仓库检索原文。症状二同名目录依赖跨项目串味本地目录依赖——即file:目录依赖和注入式 workspace 包——此前在所有依赖了同名目录的项目之间共享同一个 GVS 槽位。由于这些依赖的解析结果只记录路径而不记录内容指纹共享槽位意味着先安装谁谁的内容就留在槽位里后安装的项目会被链接到其他项目的依赖副本上出现依赖内容串味。根因分析为什么目录依赖天然需要特殊处理要理解修复方案需要先看 GVS 槽位路径的生成逻辑。核心实现在 pnpm11/deps/graph-hasher/src/index.ts 的calcGraphNodeHashconst isLocalDirectory isLocalDirectoryResolution(graph[depPath]?.resolution) // Scoping the slot needs the projects identity; the segment only needs to // know that the package is a local directory, so a caller that leaves // lockfileDir out still gets a well-formed path. const project isLocalDirectory ? lockfileDir : undefined const hexDigest project null ? hashObjectWithoutSorting({ engine, deps }, { encoding: hex }) : hashObjectWithoutSorting({ engine, deps, project }, { encoding: hex }) return formatGlobalVirtualStorePath(name, isLocalDirectory ? LOCAL_DIRECTORY_SEGMENT : version, hexDigest)其中isLocalDirectoryResolution的判断逻辑pnpm11/deps/graph-hasher/src/index.ts给出了根因的权威解释/** * Whether the package came from a local directory — a file: directory * dependency or an injected workspace package. * * Such a package needs a slot of its own per project. A directory resolution is * the one resolution with no integrity: it is a path relative to the lockfile, * so file:dep hashes identically in every project that happens to depend on a * directory of that name. Sharing the slot would hand one project the files of * whichever project installed first, and because the source directory is * mutable pnpm re-imports it on every install — so the projects would go on * overwriting each others dependency. */ function isLocalDirectoryResolution (resolution: LockfileResolution | undefined): boolean { return resolution ! null type in resolution resolution.type directory }关键推理链目录解析没有完整性integrity信息。普通注册表包通过integrity字段内容哈希参与全包 ID 与槽位哈希的计算见createFullPkgIdpnpm11/deps/graph-hasher/src/index.ts因此内容不同则槽位不同。而file:目录依赖的解析结果是相对于 lockfile 的路径不携带任何内容指纹。同名路径在哈希上无法区分。file:dep在每一个恰好依赖了同名目录的项目中哈希结果相同——因为哈希输入engine、deps、name、version完全一致。源目录是可变的。与注册表包不同本地目录的内容会随开发而变pnpm 每次安装都会重新导入于是共享槽位下各项目会互相覆盖对方的依赖。这正是本变更集修复的核心把项目身份lockfileDir纳入哈希输入让每个项目的本地目录依赖各自获得独立槽位。修复方案按项目隔离槽位1. 项目维度进入哈希摘要修复后的calcGraphNodeHash在识别到目录解析时将lockfileDir作为project字段加入摘要计算const project isLocalDirectory ? lockfileDir : undefined const hexDigest project null ? hashObjectWithoutSorting({ engine, deps }, { encoding: hex }) : hashObjectWithoutSorting({ engine, deps, project }, { encoding: hex })于是两个项目即便依赖同名的file:dep只要它们位于不同的 lockfile 目录其槽位摘要就不同天然被分配到两个独立槽位互不干扰。普通非目录包的哈希输入保持不变不会引起既有无依赖项目的槽位路径变动。2. 版本段占位符directory目录快照在 lockfile 中不记录版本号pnpm 的目录解析省略 version 字段而槽位路径格式是名称/版本/摘要因此修复引入了一个固定占位符const LOCAL_DIRECTORY_SEGMENT directory源码注释pnpm11/deps/graph-hasher/src/index.ts解释了其必要性resolver 从 manifest 能读到版本、而 headless 安装从 lockfile 读不到版本若两者对版本段的取值不一致重装时槽位路径就会漂移导致包被搬走。使用固定占位符可保证两侧以及 TypeScript 版 pnpm 与 Rust 版 pacquet始终落在同一路径上。该段位只是槽位目录中的装饰性名称真正标识槽位身份的是其后的十六进制摘要。3. 槽位路径的统一出口与安全防护所有 GVS 槽位路径都经由formatGlobalVirtualStorePath生成pnpm11/deps/graph-hasher/src/index.ts// Use / prefix for unscoped packages to maintain uniform 4-level directory depth // Scoped: scope/pkg/version/hash // Unscoped: /pkg/version/hash function formatGlobalVirtualStorePath (name: string, version: string, hexDigest: string): string { assertNoPathTraversal(version) const prefix name.startsWith() ? : / return ${prefix}${name}/${version}/${hexDigest} }无作用域包统一加/前缀以维持 4 层目录深度的一致性同时assertNoPathTraversalpnpm11/deps/graph-hasher/src/index.ts会在版本段该段受 lockfile 控制包含..时直接拒绝构造路径防止槽位路径逃逸出 GVS 根目录。测试验证修复行为被端到端测试锁定仓库中与本次修复配套的测试位于 pnpm11/installing/deps-installer/test/install/globalVirtualStore.ts覆盖了本变更集描述的两个核心场景场景一本地目录依赖在 GVS 下可正常安装且重装不漂移对应测试local directory dependency works with global virtual storeglobalVirtualStore.tsconst manifest { dependencies: { dep: file:dep, }, } const opts testDefaults({ enableGlobalVirtualStore: true, virtualStoreDir: globalVirtualStoreDir, }) await install(manifest, opts) project.has(dep) const slotBeforeReinstall fs.realpathSync(path.resolve(node_modules/dep)) expect(slotBeforeReinstall.startsWith(globalVirtualStoreDir)).toBeTruthy() rimrafSync(node_modules) await install(manifest, { ...opts, frozenLockfile: true }) project.has(dep) // The resolver reads the version off the manifest and the headless install // reads it off the lockfile, where there is none — both have to land on the // same slot or the reinstall would relocate the package. expect(fs.realpathSync(path.resolve(node_modules/dep))).toBe(slotBeforeReinstall)该测试同时验证了三点file:dep在 GVS 开启时能成功安装对应原split崩溃的修复依赖被链接进全局虚拟存储目录删除node_modules后以--frozen-lockfile重装解析到的槽位与首次安装完全一致验证resolver 与 headless 安装读取不同来源的版本信息仍必须落在同一槽位这一设计约束。场景二同名目录依赖在不同项目间获得独立槽位对应测试two projects with a same-named local directory dependency get separate global virtual store slotsglobalVirtualStore.ts// Both resolve to depfile:dep with no recorded version — only the project // they belong to tells the two slots apart. expect(slotOfProject1).not.toBe(slotOfProject2) expect(fs.readFileSync(path.resolve(project-1/node_modules/dep/index.js), utf8)).toBe(module.exports project-1) expect(fs.readFileSync(path.resolve(project-2/node_modules/dep/index.js), utf8)).toBe(module.exports project-2)两个项目各自声明dep: file:deplockfile 中都不记录版本但各自读到的index.js内容互不串味——这正是按项目隔离槽位的验收标准。辅助函数installLocalDirectoryDependencyglobalVirtualStore.ts展示了复现该场景的最小步骤创建dep/package.json与dep/index.js然后用enableGlobalVirtualStore: true 指向共享目录的virtualStoreDir依次安装两个项目。更完整的 GVS 隔离链路相关配套修复本次修复不是孤立的仓库中一组相互关联的变更集共同完善了 GVS 下的依赖隔离gvs-link-deps-in-slots.md修复link:依赖在 GVS 下的处理保证被链接的子依赖被正确物化且槽位按解析后的链接目标resolved link target相互隔离。force-reimports-changed-slot.mdpnpm install --force现在会把每个包重新导入虚拟存储从而修复文件发生漂移drifted的包避免强制安装沿用上一次安装遗留的文件。repair-shared-slot-in-place.md共享同一个全局虚拟存储的安装不再删除另一个 importer 仍在写入的不完整包目录此前会报failed to remove existing directory ... prior to swap: Directory not empty而是就地修复被中断安装损坏的包文件也会被恢复而非保留。在哈希一致性层面仓库还提供了 fixtures/gvs-link-hash-parity.json记录link:依赖在 POSIX 与 Windows 下槽位路径的期望值含../shared相对路径解析、盘符大小写等边界情况用于保证 TypeScript 实现与 Rust 实现pnpm/crates下deps-inspection等 crate生成的槽位哈希保持一致。实践建议如何配置与验证启用方式普通项目执行pnpm config set virtual-store-type global或设置环境变量PNPM_CONFIG_VIRTUAL_STORE_TYPEglobalpnpm --global安装默认即启用 GVS。CI 环境未显式配置时 GVS 自动关闭。升级验证若你的 monorepo 开启 GVS 且包含file:目录依赖或injectWorkspacePackagespnpm-workspace.yaml中的inject-workspace-packages: true升级到包含本次修复的版本后可删除各项目的node_modules重新安装确认每个项目node_modules中的符号链接解析到各自独立的 GVS 槽位且各自读到本地目录的独立副本。兜底手段若遇到槽位中文件内容陈旧或漂移可使用pnpm install --force强制重新导入全部包到虚拟存储。跨实现一致性本文描述的行为在 TypeScript 版 pnpm 与 Rust 版 pacquet 中应保持一致涉及link:依赖槽位路径的跨平台一致性可参考 fixtures/gvs-link-hash-parity.json 中的期望值。小结本变更集修复了全局虚拟存储模式下本地目录依赖的两个缺陷安装崩溃splitof undefined与同名目录跨项目串味。其本质是把无完整性、纯路径化、内容可变的目录解析从按名称版本哈希共享槽位改为按项目身份lockfile 目录参与哈希、独立占槽并配套固定版本段占位符directory保证 resolver 与 headless 安装的路径一致。仓库中的 graph-hasher 实现 与 deps-installer 测试 为这一行为提供了源码级与端到端的双重证据。【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考