pnpm11 @pnpm/installing.modules-yaml:node_modules/.modules.yaml 状态文件的读写实现详解
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
node_modules/.modules.yaml是 pnpm 安装器在每次安装后落盘的一份“modules 状态清单”,记录虚拟存储位置、提升策略、跳过/待构建的包等关键信息;@pnpm/installing.modules-yaml(pnpm11 工作区包)就是负责读取与写入这份文件的唯一权威模块。本文以 包 README 为骨架,结合 源码实现 与 测试用例,完整讲解其文件命名由来、数据字段、向后兼容迁移逻辑、写入时的字段裁剪策略,以及它在 pnpm11 安装流程中的下游消费方。
包的定位与安装方式
README 给出的安装与用法非常直接:
pnpm add @pnpm/installing.modules-yamlimport {write, read} from '@pnpm/installing.modules-yaml' await write('node_modules', { hoistedAliases: {}, layoutVersion: 1, packageManager: 'pnpm@1.0.0', pendingBuilds: [], shamefullyFlatten: false, skipped: [], storeDir: '/home/user/.pnpm-store', }) const modulesYaml = await read(`node_modules`)README 的 API 概览只有两条:
read(pathToDir): Promise<ModulesObject>—— 从指定目录读取.modules.yaml;write(pathToDir, ModulesObject): Promise<void>—— 向指定目录写入.modules.yaml。
需要说明的是:README 中的read/write是对外语义的简化写法,源码实际导出的函数名 是readModulesManifest(modulesDir)与writeModulesManifest(modulesDir, modules)。从 package.json 可以确认该包的信息:
- 包名
@pnpm/installing.modules-yaml,描述为 “Reads/writesnode_modules/.modules.yaml”,当前版本1101.0.2; - 运行入口为
lib/index.js("main"),纯 ESM("type": "module"); - 运行时依赖只有 5 个:
@pnpm/fs.graceful-fs、@pnpm/types、is-windows、ramda、read-yaml-file; - 引擎要求
"node": ">=22.13"。
文件命名与存储格式:为什么叫 .modules.yaml 却常是 JSON
源码中文件名是一个常量,并附带了关键的注释解释(src/index.ts#L14-L16):
// The dot prefix is needed because otherwise `npm shrinkwrap` // thinks that it is an extraneous package. const MODULES_FILENAME = '.modules.yaml'点前缀的作用是让npm shrinkwrap这类工具不会把该文件误判为一个“多余的安装产物”。
更值得注意的是存储格式。虽然文件名是.yaml,但当前版本写出的内容其实是JSON(JSON 本身是合法 YAML 子集):
await fs.mkdir(modulesDir, { recursive: true }) await fs.writeFile(modulesYamlPath, JSON.stringify(saveModules, null, 2))而读取端采用“先 JSON、后 YAML”的双路解析策略(src/index.ts#L55-L69):
- 先按 UTF-8 读入文件内容,尝试
JSON.parse; - 若 JSON 解析失败,回退到
read-yaml-file按 YAML 解析——源码注释明确写道 “Manifests written by old pnpm versions are YAML”,即旧版 pnpm 写的是真正的 YAML,这一回退就是为兼容历史文件而保留的; - 文件不存在(
ENOENT)时返回null,而不是抛错; - 空文件(0 字节)会返回
undefined而不报错,这一点由 fixtures/empty-modules-yaml 中那个空的.modules.yaml对应测试用例(test/index.ts#L131-L134)验证; - 但解析不了的非空文件必须抛错。测试用例里有一段很有信息量的注释(test/index.ts#L136-L142):
// Callers must not mistake an unreadable state file for a missing one: // that reads as layout drift and purges node_modules on every install. const modulesDir = temporaryDirectory() fs.writeFileSync(path.join(modulesDir, '.modules.yaml'), 'not: [valid') await expect(readModulesManifest(modulesDir)).rejects.toThrow()换言之,如果读取端把“损坏的状态文件”误当成“没有状态文件”,上层安装器会把它理解为 node_modules 布局漂移,进而每次安装都清空重建 node_modules——所以这里必须 fail loudly。
另外还有一条针对 JSON 特性的防御:重复 key 的 JSON 对象按“最后一个值生效”处理,对应测试 构造了同一条超长 dep path 出现两次的 JSON,断言读取结果保留的是后一个值('public')。
Modules 数据结构:字段逐项解析
ModulesRaw接口(src/index.ts#L22-L46)定义了状态文件的完整字段集,导出的Modules类型在其基础上把ignoredBuilds从字符串数组改成了Set。逐字段说明如下:
| 字段 | 类型 | 含义 |
|---|---|---|
hoistedDependencies | HoistedDependencies | 核心字段:dep path → 别名到public/private提升位置的映射,决定哪些包出现在 node_modules 根 |
hoistPattern/publicHoistPattern | string[](可选) | 提升模式与公共提升模式,与 pnpm 的hoist-pattern/public-hoist-pattern配置对应 |
included | Record<DependenciesField, boolean> | 记录本次安装包含了哪几类依赖(dependencies/devDependencies/optionalDependencies) |
layoutVersion | number | node_modules 布局版本号,是上层判断“能否复用现有 node_modules”的关键依据 |
nodeLinker | 'hoisted' \| 'isolated' \| 'pnp'(可选) | 记录本次安装使用的 node_modules 链接策略 |
packageManager | string | 写入该文件时的包管理器版本,如pnpm@5.1.8 |
pendingBuilds | string[] | 尚未执行构建脚本的包列表 |
ignoredBuilds | DepPath[](读入后为Set<DepPath>) | 被忽略构建脚本的 dep path 集合 |
skipped | string[] | 因平台不匹配等原因被跳过的包 |
prunedAt | string | 最近一次修剪时间(UTC 字符串) |
storeDir | string | 全局内容寻址存储(CAFS store)路径 |
virtualStoreDir | string | 项目内虚拟存储目录,默认<modulesDir>/.pnpm |
virtualStoreDirMaxLength | number | 虚拟存储路径长度上限,缺省值 120 |
injectedDeps | Record<string, string[]>(可选) | 注入依赖的映射记录 |
hoistedLocations | Record<string, string[]>(可选) | 提升位置的补充记录 |
allowBuilds | Record<string, boolean \| string>(可选) | 构建脚本的审批状态 |
virtualStoreOnly | boolean(可选) | 标记该 node_modules 由virtualStoreOnly安装(如pnpm fetch)产生:此时记录的提升模式被强制置空,下次安装时不得与用户配置比较 |
hoistedAliases/shamefullyHoist | — | 仅作向后兼容保留,读取旧文件时用于迁移(见下一节) |
读取端的字段规范化逻辑(src/index.ts#L70-L112)会补齐若干默认值:
virtualStoreDir缺失时补为path.join(modulesDir, '.pnpm');若是相对路径则相对modulesDir解析为绝对路径;prunedAt缺失时补为当前 UTC 时间;virtualStoreDirMaxLength缺失时补为120。
向后兼容:从 pnpm 5 的 shamefully-hoist 到 hoistedDependencies
.modules.yaml的历史格式经历过一次重要变化:旧版(如 pnpm 5)使用shamefullyHoist布尔值加hoistedAliases映射来表达提升关系;新版改用结构化的hoistedDependencies。readModulesManifest中专门有一段switch (modules.shamefullyHoist)迁移逻辑(src/index.ts#L79-L105):
shamefullyHoist: true时:若publicHoistPattern缺失则补为['*'];若只有hoistedAliases而没有hoistedDependencies,则把每个别名映射为'public';shamefullyHoist: false时:若publicHoistPattern缺失则补为[];同样把hoistedAliases迁移为hoistedDependencies,但所有别名标为'private'。
仓库里保留了真实的旧版 fixture 文件,可以直接对照阅读。old-shamefully-hoist/.modules.yaml 是一个由pnpm@5.1.8写出的 YAML 文件:
hoistPattern: - '*' hoistedAliases: /accepts/1.3.7: - accepts /array-flatten/1.1.1: - array-flatten /body-parser/1.19.0: - body-parser included: dependencies: true devDependencies: true optionalDependencies: true layoutVersion: 4 packageManager: pnpm@5.1.8 pendingBuilds: [] registries: default: 'https://registry.npmjs.org/' shamefullyHoist: true skipped: [] storeDir: /home/zoli/.pnpm-store/v3 virtualStoreDir: .pnpmold-no-shamefully-hoist/.modules.yaml 则是shamefullyHoist: false的对应版本。两条向后兼容测试(test/index.ts#L80-L104)断言:读入前者后publicHoistPattern变为['*']且三个包全部标记为public;读入后者后publicHoistPattern为[]且全部标记为private。这两个 fixture 也顺带展示了旧文件的两个特征:内容是纯 YAML 而非 JSON,以及包含一个新版写入时会被主动丢弃的registries字段(见下文)。
写入逻辑:字段裁剪、排序与 Windows 特判
writeModulesManifest(src/index.ts#L115-L149)在落盘前做了一系列确定性处理,保证同一状态反复写入时字节稳定、且不带入过期信息:
ignoredBuilds从Set还原为数组——Set无法被 JSON 序列化,落盘前Array.from展开;skipped排序——if (saveModules.skipped) saveModules.skipped.sort(),消除数组顺序带来的无意义 diff;- 删除空值字段:
hoistPattern为null或空串时删除(注释说明 YAML 写作者无法处理undefined字段);publicHoistPattern为null时删除;virtualStoreOnly为假时删除;hoistedAliases在“为 null,或两个 hoist pattern 均为 null”时删除; - 主动丢弃
registries字段——源码注释解释得很清楚(src/index.ts#L136-L139):pnpm 11 及更早版本会把上次安装使用的 registry 记录在文件里,而现在 registry 直接从项目配置读取;因此旧文件里残留的registries会在首次重写时丢失,避免持有一份配置变更后立即过期的拷贝。这一点有专门的测试验证(test/index.ts#L144-L171):写入一个带registries: { default: ... }的 manifest 后,读回原始文件断言registries字段已不存在; virtualStoreDir的绝对/相对路径处理(src/index.ts#L140-L146):
// We should store the absolute virtual store directory path on Windows // because junctions are used on Windows. Junctions will break even if // the relative path to the virtual store remains the same after moving // a project. if (!isWindows()) { saveModules.virtualStoreDir = path.relative(modulesDir, saveModules.virtualStoreDir) }非 Windows 平台写相对路径(通常是.pnpm),Windows 平台保留绝对路径。原因是 Windows 上 pnpm 使用 junction,junction 在“项目目录被移动而虚拟存储相对位置不变”时同样会失效,所以必须记录绝对路径才能正确校验。测试用例(test/index.ts#L36-L39)正验证了这一点:path.isAbsolute(raw.virtualStoreDir)应当等于isWindows()的返回值; 6. 最后fs.mkdir(modulesDir, { recursive: true })确保目录存在,再以JSON.stringify(saveModules, null, 2)写入 2 空格缩进的 JSON。
测试矩阵:从往返一致到长路径场景
test/index.ts 覆盖了该包的全部关键行为,可以视为一份“可验证的规格书”:
- 往返一致性:
writeModulesManifest+readModulesManifest的结果与输入深度相等(round-trip),包括node_modules目录不存在的场景(L106-L129); - 超长 dep path:构造
@scope/package@1.0.0(${'p'.repeat(1001)})这样的 dep path,验证带 1000+ 字符 peer 依赖片段的条目也能完整往返(L41-L67)——这类长路径正是 pnpm 虚拟存储目录名长度管理(virtualStoreDirMaxLength缺省 120)要面对的现实; - 损坏文件必须拒绝与空文件容错:见前文;
- 旧格式迁移:两个 pnpm 5 的 fixture 验证
shamefullyHoist/hoistedAliases到hoistedDependencies的迁移正确性; registries字段剥离:验证新写入不再携带旧版的 registry 快照。
谁在消费这个包:pnpm11 中的下游引用
@pnpm/installing.modules-yaml是 pnpm11 中多处安装链路的公共依赖,从各包的package.json依赖关系看(均为workspace:*依赖),主要消费方包括:
- installing/deps-installer/src/install/validateModules.ts 与 installing/deps-installer/src/install/link.ts:安装器在安装前后读取/重写该文件,校验现有 node_modules 与当前配置的兼容性;
- building/after-install/src/index.ts 与 building/commands/src/policy/approveBuilds.ts、getAutomaticallyIgnoredBuilds.ts:安装后构建脚本的审批/忽略策略会读写
pendingBuilds、ignoredBuilds、allowBuilds等字段; - workspace/injected-deps-syncer/src/index.ts:工作区注入依赖的同步逻辑依赖其中的
injectedDeps记录; - installing/deps-restorer/src/index.ts 与 installing/context/src/index.ts:锁文件快速恢复与安装上下文构建时读取该状态以决定能否复用 node_modules;
- 此外
deps/graph-builder、deps/inspection/tree-builder、global/commands、patching/commands等包也在依赖列表中(见 pnpm11/deps/graph-builder/package.json 等),说明它同时服务于构建依赖图与检查类命令。
从源码结构看,.modules.yaml实际上是 pnpm 判断“node_modules 是否仍有效”的唯一事实来源:layoutVersion、hoistedDependencies、included等字段共同决定了安装器是走完整重建、增量修补还是直接跳过。理解这个包的读写行为,也就理解了 pnpm 增量安装机制的底层契约。
参考文件索引
- README:包的对外文档(安装、用法、API 概览,MIT 许可)
- src/index.ts:
readModulesManifest/writeModulesManifest完整实现与Modules类型定义 - test/index.ts:往返、长路径、损坏文件、旧格式迁移等全部测试
- package.json:包元信息、依赖与引擎要求
- test/fixtures/old-shamefully-hoist/.modules.yaml / old-no-shamefully-hoist/.modules.yaml / empty-modules-yaml:pnpm 5 时代的真实 YAML 样本与空文件样本
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考