pnpm 为什么装到了旧版本:npmmirror 时间字段与依赖解析排查
给博客接入 textlint时,规则还没来得及检查正文,Markdown 解析器先报了错:ERR_PACKAGE_PATH_NOT_EXPORTED。错误指向 micromark 的一个内部文件。
从子路径错误追到版本选择
这次运行环境是 Node.js 25.8.1、pnpm 11.17.0,registry 使用 npmmirror。textlint 及其 Markdown 解析包都是 15.8.0。相关依赖可以简化为:
textlint
└─ @textlint/textlint-plugin-markdown
└─ @textlint/markdown-to-ast
└─ remark-gfm@1.0.0
└─ micromark-extension-gfm@0.3.3
└─ micromark@~2.11.0GFM 扩展读取了 micromark/dist/util/combine-extensions。但 micromark@2.11.0 的 package.json使用了旧式目录导出:
{
"exports": {
"./": "./"
}
}这只是导出字段的节选。Node.js 从 17 起移除了这种以斜杠结尾的目录映射,新运行时因此拒绝这个子路径。micromark@2.11.3 已移除旧的 exports 字段,这条 CommonJS 路径能够正常解析;2.11.4 也可以。

图中固定使用 Node.js 25.8.1 与 micromark/dist/util/combine-extensions 子路径。2.11.4 代表依赖范围内已修复的 2.x 补丁版本。
通过 override 固定到已修复的补丁版本,能够让解析器恢复运行。
但依赖声明本来就是 ~2.11.0,允许选择 2.11.4;这个补丁也早已发布。为什么 pnpm 仍然安装了 2.11.0?
npmmirror 是怎样参与这次故障的
同一个 pnpm 11.17.0,解析同一个 micromark@~2.11.0,只把 registry 从 npmmirror 换成官方 npm,结果就从 2.11.0 变成了 2.11.4。区别不在于镜像少了某个版本,而在于精简响应的时间字段形态不同:官方完全不提供 time,npmmirror 提供了这个对象,却没有填齐其中的版本时间。

旧 pnpm 能处理「完全没有时间」,却把「有时间对象」都当成可以直接过滤版本的数据。npmmirror 的响应恰好落在中间:版本列表完整,时间映射残缺。于是缺少时间记录的 2.x 被排除,随后回退到最低匹配版本。
完整接口里有数据,不等于安装器已经去请求了那份数据。 本次镜像的完整响应有全部发布时间,但旧 pnpm 没发现精简响应不足以判断版本年龄,因此没有进入缺少时间时的补取处理。官方响应完全不带 time,反而能进入相应处理路径;本例的 modified 已足够早,还可以省掉完整请求。
npmmirror 在本例提供了触发漏洞的数据形态,旧 pnpm 则承担了误判和错误选版。换源能避开这份输入,升级 pnpm 能正确处理这份输入;任何其他源若返回同类残缺映射,也可能触发这个旧漏洞。
同一个包,完整响应和精简响应不同
registry 不只是提供 tarball 下载地址。安装之前,包管理器还要获取版本列表、依赖声明和发布时间等元数据,再决定采用哪个版本。
一个包的多版本元数据文档常被称为 packument。它既可以是完整文档,也可以是为安装流程裁剪后的精简文档。
两种文档使用同一个包 URL,例如 https://registry.npmmirror.com/micromark。客户端通过 Accept 请求头告诉服务端希望获得哪种表示,这种机制称为 HTTP 内容协商:
Accept 请求头 |
请求的表示 | 主要用途 |
|---|---|---|
application/json |
完整元数据 | 查询发布时间、README 和其他完整字段 |
application/vnd.npm.install-v1+json |
精简元数据 | 获取安装所需的版本、依赖和下载信息,减少传输量 |
npm 的格式说明把 time 列在完整元数据中。精简格式的顶层字段包括 name、modified、dist-tags 和 versions,并不要求提供 time。所以,官方精简响应没有逐版本时间,本身是正常行为。
请求头也可以声明多个可接受的格式。pnpm 12.11.2 的请求实现使用下面两种取值:
Accept: application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*
Accept: application/json; q=1.0, */*这两行分别属于精简请求和完整请求,不是同一次请求里重复发送的两个头。q 表示偏好权重;第一种虽然也写了 application/json,仍优先要求精简文档,服务端不支持时才接受其他格式。因此,判断请求的是哪一种文档,要看完整的 Accept 值,不能只搜索有没有 application/json。
在 Windows PowerShell 中,可以直接向 npmmirror 请求完整文档,再读取某个版本的时间:
$metadata = Invoke-RestMethod `
-Uri 'https://registry.npmmirror.com/micromark' `
-Headers @{ Accept = 'application/json' }
$metadata.time.'2.11.4'
# 2021-02-04T09:11:17.707Z这里改变的是单次元数据请求的表示,不需要换 registry 地址,也不会改变项目配置。安装器是否请求完整文档,则由它的解析策略决定。
直接对照实际响应
2026 年 10 月 15 日,用两种请求头分别读取两个源的 micromark,得到下面的结果。「时间覆盖」只统计 versions 中的版本,不把 created、modified 算成版本:
| registry | 响应格式 | 版本数 | 有发布时间的版本数 | 2.11.4 的发布时间 |
|---|---|---|---|---|
| 官方 npm | 完整 | 43 | 43 | 有 |
| 官方 npm | 精简 | 43 | 0 | 没有 time 字段 |
| npmmirror | 完整 | 43 | 43 | 有,与官方一致 |
| npmmirror | 精简 | 43 | 2 | 缺失 |
下面的样例取于 2026 年 10 月 17 日 00:13(UTC+08:00),时间覆盖与上表 15 日的结果一致。两次精简请求使用相同的 Accept: application/vnd.npm.install-v1+json,只更换 registry 地址。
为了便于对照,两个 JSON 都从真实响应中筛选字段后格式化:顶层保留 name、modified、versions 和原本存在的 time;versions 只留 2.11.0、2.11.4,每个版本只留 version 字段。其余字段与版本省略,键名和值未改动;npmmirror 的 time 对象完整保留,没有省略任何条目。
官方 npm 的精简响应节选(原始响应):
{
"name": "micromark",
"modified": "2026-09-26T15:43:36.718Z",
"versions": {
"2.11.0": {
"version": "2.11.0"
},
"2.11.4": {
"version": "2.11.4"
}
}
}npmmirror 的精简响应节选(原始响应):
{
"name": "micromark",
"modified": "2026-09-26T15:43:55.042Z",
"versions": {
"2.11.0": {
"version": "2.11.0"
},
"2.11.4": {
"version": "2.11.4"
}
},
"time": {
"3.0.0-beta.1": "2021-06-20T12:12:36.198Z",
"4.0.3": "2026-09-26T15:43:36.558Z"
}
}两边的版本列表都有 2.11.4。官方原文完全没有 time 字段;镜像原文则有这个对象,但全部条目只有上面两条,既没有 2.11.0,也没有 2.11.4。「字段不存在」和「对象存在但缺少版本记录」是两种不同的输入,旧 pnpm 恰好对它们采取了不同处理。
再把请求头换成 Accept: application/json,读取官方完整响应和镜像完整响应。两份响应都覆盖 43 个版本;只保留 name 和 time 中这两个 2.x 记录后,得到相同的节选:
{
"name": "micromark",
"time": {
"2.11.0": "2020-12-08T17:03:51.438Z",
"2.11.4": "2021-02-04T09:11:17.707Z"
}
}这说明历史发布时间能从镜像的完整表示中取得。缺口出现在本次返回的精简表示里,不能由此推断镜像根本没有这些时间。
在 Windows PowerShell 中,可以用下面四条命令重新取得原始 JSON。curl.exe 明确调用 curl,避免落到 Windows PowerShell 的同名别名:
curl.exe -fsS -H 'Accept: application/vnd.npm.install-v1+json' 'https://registry.npmjs.org/micromark' -o npm-abbreviated.json
curl.exe -fsS -H 'Accept: application/vnd.npm.install-v1+json' 'https://registry.npmmirror.com/micromark' -o npmmirror-abbreviated.json
curl.exe -fsS -H 'Accept: application/json' 'https://registry.npmjs.org/micromark' -o npm-full.json
curl.exe -fsS -H 'Accept: application/json' 'https://registry.npmmirror.com/micromark' -o npmmirror-full.json这些命令读取执行当时的在线数据。上面的原始响应链接固定保留本次取样,采集记录另列请求时间、Accept、HTTP 状态、响应头和 SHA-256;记录里的 observation 是派生统计,不属于服务端返回。

四格均来自 2026 年 10 月 17 日的 micromark 样本。两边版本列表都齐全,差异集中在精简响应的时间映射;官方不带 time 是格式允许的行为。
这里的异常不是镜像少了 2.11.4,也不是整个镜像库丢失了历史时间,而是精简文档列出了版本,却只为部分版本提供时间。只在浏览器地址栏打开包 URL,通常看到的是完整响应,很容易漏掉这个差异。
精简格式的标准要求与 npmmirror 的时间扩展
精简接口存在,并不能推出它应该提供逐版本时间。time 是完整元数据的标准字段;服务端也可以在精简格式上额外提供它,让客户端少请求一次完整文档。这里要区分格式的标准要求、镜像新增功能的目标,以及实际返回的数据:
| 层次 | 对 time 的预期 |
本例中的情况 |
|---|---|---|
| npm 标准精简格式 | 不要求提供逐版本时间 | 官方精简响应完全不提供,属于正常行为 |
| cnpmcore 的时间扩展 | 在精简文档中提供所列版本的发布时间,减少完整请求 | 全量重建代码会随每个版本写入时间 |
| npmmirror 的实际响应 | 需要检查扩展数据是否覆盖所列版本 | micromark 列出 43 个版本,只为其中 2 个提供时间 |
cnpmcore 曾专门讨论过在精简元数据中增加时间字段,目的是减少 pnpm 的 time-based 解析模式获取完整文档的开销。这与本次触发问题的 minimumReleaseAge 是不同设置,只是都需要逐版本发布时间。因此,镜像的时间扩展确实希望提供这些信息,但这个预期来自新增功能的用途,不能推广成所有精简响应都必须带时间。
对应的 PR #834于 2025 年 10 月合并,同时增加了精简文档重建和增量更新时的时间写入。排查当天,npmmirror 的公开首页报告 app_version: 4.35.3;同版本重建精简文档的逻辑先枚举所有版本,再在同一次迭代中写入版本内容和时间。下面是其中的赋值节选:
data.versions[packageVersion.version] = manifest;
if (data.time) {
data.time[packageVersion.version] = packageVersion.publishTime;
}按这段逻辑全量重建精简文档,versions 中的每个版本应当都有对应时间。本例却有 41 个版本缺少时间记录,所以问题是扩展映射没有覆盖齐全,而不是完全没有 time 字段,也不是 npm 标准要求所有精简响应必须提供它。
服务还存在优先读取已保存文档和局部更新指定版本的路径。代码具备新能力,并不表示每份历史文档都已重建;完整响应与精简响应也可能保留不同状态。
pnpm 的修复说明也把这种形态描述为:镜像开始记录时间之后同步的版本有记录,较早版本没有。它与本次实测相符。但仅凭公开响应和源码,仍无法确认 micromark 在线上具体经历了哪次增量更新,或哪层缓存保留了这份文档。历史数据未完整回填是有依据的解释,具体服务端过程仍需镜像维护者核查。
pnpm 11 已有补取机制,为何本例没有触发
pnpm 11 的 minimumReleaseAge默认是 1440 分钟,也就是 24 小时。它在选择版本时排除刚发布的包,让新版本经过一段等待期再进入依赖树。
这项检查需要知道候选版本的发布时间,pnpm 11.17.0 已经具备按需补取完整文档的能力。该版本的 pickPackage.ts先判断精简文档是否缺少 time,再检查整包的 modified 是否足以证明所有版本都已过等待期。下面是保留判断顺序的简化伪代码:
if (publishedBy && !fullMetadata && meta.time == null && !excluded) {
if (!validModified || modified > publishedBy) {
meta = await fetchMetadata({ fullMetadata: true });
}
}这里的 publishedBy 是允许安装的最晚发布时间,即当前时间减去等待期;excluded 表示整个包被豁免。modified 缺失、无效,或者晚于这个截止时间时,仅靠精简文档不能确定所有版本是否成熟,于是发起完整请求。内存和磁盘缓存的读取路径也有相应的补取处理。
问题出在 meta.time == null 这个条件。npmmirror 返回的 time 对象确实存在,只是没有覆盖所有版本;旧版因此认为已经拿到逐版本时间,跳过了缺少时间时的处理路径,后续过滤又把缺失时间的版本排除出去。补取能力已经存在,缺的是对时间映射完整性的判断。
于是,原本存在的 2.11.3、2.11.4 与其他 2.x 一起消失在过滤结果中。过滤后没有合格版本,解析逻辑进入最低版本回退路径,~2.11.0 最终选中了 2.11.0。它刚好又带着前面那个不兼容的导出配置。
2.11.0 自己也没有发布时间记录,并不是它通过了等待期检查。它和其他 2.x 一样被过滤,最后由最低版本回退策略重新选中。区分这两步,才能理解为什么一个没有时间的版本仍然被安装。
完全不带 time 的官方精简响应反而绕开了这个问题:pnpm 进入缺少时间元数据的处理路径。本例的整包 modified 已早于等待期,能够正常选中 2.11.4。这里不能推广成「官方源永远不用获取完整文档」;遇到最近更新的包,安装器仍可能需要进一步获取时间。
在相同 Node.js、相同解析包、没有 lockfile 和 micromark override 的隔离目录中,pnpm 11.17.0 的结果如下:
| 唯一改变的条件 | GFM 分支使用的 micromark | Markdown 解析 |
|---|---|---|
| npmmirror,24 小时检查 | 2.11.0 | 子路径导出错误 |
| 官方 npm,24 小时检查 | 2.11.4 | 正常 |
| npmmirror,实验中把等待期设为 0 | 2.11.4 | 正常 |
最后一行用来定位时间过滤的影响。它证明这项设置参与了故障,并不意味着关闭检查就是合适的长期修复。
完整性判断的修复同时适用于 pnpm 11 和 12
这个问题已经有准确对应的 pnpm issue #13741。上游案例发生在 style-to-js,也是 npmmirror、默认发布时间检查和传递依赖退回最低版本。包名不同,失败机制相同。
PR #13750于 2026 年 8 月 19 日合并。修复放在读取元数据的边界:检查时间是否完整,丢弃不完整映射,让后续逻辑按需获取完整元数据,而不是给某个包写死版本。
在 pnpm 12.11.2 的 Rust 实现中,对应的判断是:
pub fn drop_incomplete_publish_times(&mut self) {
let Some(time) = self.time.as_ref() else { return };
let complete = self.versions
.keys()
.all(|version| {
time.get(version)
.and_then(serde_json::Value::as_str)
.is_some_and(|at| !at.is_empty())
});
if !complete {
self.time = None;
}
}修复后的内部约定是:time 要么覆盖所有版本,要么不存在。缺失条目和空时间字符串都不能伪装成完整数据;磁盘缓存和网络读取也要遵守这个约定。这里检查的是条目覆盖与非空字符串,后续过滤仍需解析具体日期。PR 同时覆盖了 pnpm 11 的 TypeScript 实现与 Rust 实现,并补上相应回归测试。

对比固定 npmmirror、micromark@~2.11.0 和 24 小时等待期,均从无 lockfile、无 override 的目录重新解析。本例 modified 已足够早,修复版不必发起完整请求,也能选中 2.11.4。
pnpm 12 怎样决定是否补取完整文档
pnpm 默认的解析模式是 highest。普通依赖解析通常先请求精简元数据,以减少传输和解析开销;可选依赖等路径可能为了平台字段直接请求完整文档。pnpm 12.11.2 的请求调度实现会把所选格式传给请求头和缓存逻辑,因此一次安装中的不同包不一定使用同一种响应格式。
拿到精简文档后,对仍受发布时间策略约束的联网解析,pnpm 12.11.2 的补取逻辑可以按下面的顺序理解:

- 从网络或缓存读入元数据时,先清除不完整的
time映射,使它进入「缺少时间」的处理路径。 - 如果
time完整,直接按逐版本时间过滤候选版本。 - 如果缺少
time,但有效的modified已早于或等于截止时间,整包最后一次修改都已过等待期,可以跳过完整请求。 - 如果
modified更近、缺失或无效,就把请求切换到完整格式,获取逐版本时间后再选择版本。
成功补取的数据还会写回缓存,供后续解析复用。它按需补齐时间信息,不会把每次安装、每个包的请求都强制改成完整格式;命中已有 lockfile 或缓存时,也可能不需要重新请求。
为确认这些分支,用本地模拟 registry 做了一组隔离安装:两个候选版本为 1.0.0 和 1.0.1,都已发布多年;完整响应包含两个版本的时间,部分时间响应只记录 1.0.0。依赖范围固定为 ^1.0.0,等待期为 24 小时,并记录实际收到的 Accept 请求头:
| pnpm | 精简响应的 time |
modified |
是否补取完整文档 | 选中版本 |
|---|---|---|---|---|
| 11.17.0 | 完全没有 | 最近更新 | 是 | 1.0.1 |
| 11.17.0 | 部分缺失 | 最近更新 | 否 | 1.0.0 |
| 11.28.2 | 部分缺失 | 最近更新 | 是 | 1.0.1 |
| 12.11.2 | 部分缺失 | 最近更新 | 是 | 1.0.1 |
| 12.11.2 | 部分缺失 | 已过等待期 | 否 | 1.0.1 |
第一行直接证明旧版已有补取能力。最后一行则说明:即使没有完整请求,修复后的解析也可能正确,因为它先排除了不完整映射,再通过 modified 证明整个版本集合已足够老。本次 micromark 的 modified 就已早于等待期,不能仅凭升级后装到 2.11.4,断言一定发生过完整请求。
registrySupportsTimeField 及其按源配置 supportsTimeField声明的是「精简响应是否携带时间」,不是强制完整请求的开关,默认值为 false。拿到完整时间不需要把它设成 true。对本例,使用已修复的 pnpm,保留 minimumReleaseAge: 1440 即可。
截至 2026 年 10 月 15 日,pnpm 11 的稳定版本为 11.28.2,当前稳定主线为 12.11.2。这次分别用两个版本重新安装最小项目,保留 npmmirror 与 minimumReleaseAge: 1440,不带 lockfile 和 override:
| pnpm | 解析到的 micromark | 导入解析器并解析 Markdown |
|---|---|---|
| 11.17.0 | 2.11.0 | 失败 |
| 11.28.2 | 2.11.4 | 通过 |
| 12.11.2 | 2.11.4 | 通过 |
上表说明,升级到已验证的 pnpm 11.28.2 或 12.11.2 可以解决本例;它没有确定最早包含修复的版本。
元数据格式与 time-based 解析模式
在 pnpm 12.11.2 中,没有只负责把安装请求切成完整格式的独立公开开关。源码中的 fullMetadata 是解析器内部参数,不能直接照搬成项目配置。实测 pnpm install --full-metadata 会报告不支持这个参数;在 pnpm-workspace.yaml 写入 fullMetadata: true,也没有改变默认请求精简文档的行为。
resolutionMode: time-based会改变解析所需的信息:在默认的 registrySupportsTimeField: false 下,它直接请求完整元数据,以取得逐版本时间。但它同时改变选版本的规则——直接依赖选择满足范围的最低版本,传递依赖也受发布时间限制。因此,它不能当成单纯切换 HTTP 响应格式的配置。
复用本地模拟源,仍提供 1.0.0 和 1.0.1 两个旧版本,依赖范围为 ^1.0.0。这组实验让精简文档完全不带 time,并使 modified 早于等待期,因此默认解析不需要补取完整文档。三个隔离目录都使用 pnpm 12.11.2:
| 项目配置 | 实际请求的格式 | 选中的直接依赖版本 |
|---|---|---|
| 默认配置 | 精简 | 1.0.1 |
额外写入 fullMetadata: true |
精简,没有起到格式切换作用 | 1.0.1 |
设置 resolutionMode: time-based |
完整 | 1.0.0 |

最后一行同时改变了请求格式和安装版本,正好说明它是一种解析策略。仅为处理镜像残缺时间时,使用已修复的 pnpm 并保留原解析模式即可;需要单独检查完整数据时,通过 Accept: application/json 发起只读请求。
override 什么时候可以删除
在旧环境下,把 micromark 2.x 固定到已验证的补丁是一种临时兼容措施:
overrides:
'micromark@>=2 <3': 2.11.3切换到已验证的 pnpm 后,可以在隔离副本中移除这项覆盖,重新生成依赖树,再运行真实正文检查与站点构建。还需要确认本地、CI 和其他安装入口使用相同版本,避免某个环境重新走进旧解析路径。
已有 lockfile 记录了正常版本时,即使暂时移除 override,运行也可能继续成功。它只能证明这份锁定结果可用;要验证问题已解决,还要覆盖无 lockfile 的重新解析。不要为做实验删除正在工作的项目锁文件,另建目录复现即可。
发布时间检查建议继续保留。它延迟引入刚发布的版本,不能保证包一定安全,但这次错误已经有解析器修复,没有必要为了兼容缺失字段全局取消等待期。确需提前使用某个经过核查的新版本时,再按项目政策考虑局部例外。
把元数据检查变成可以重跑的小实验
这类问题的证据容易过期:镜像可能刷新缓存,dist-tag 可能变化,包管理器也可能修复算法。与其只留下当时的截图,更适合保存请求方式和最小输入。
下面的工作区提供一个无第三方依赖的只读脚本,以及仅安装 @textlint/markdown-to-ast@15.8.0 的复现项目。元数据脚本用 Node.js 20+ 运行:
node copy/inspect-registry-metadata.mjs它分别请求两个源的完整与精简文档,输出版本数、时间覆盖数和缺失版本列表。缺失时间只是观测结果,不直接判为网络错误;请求或解析失败才返回非零退出码。它不修改 registry、缓存或项目配置,也不尝试替服务端重建数据。
读取 /files/pnpm-npmmirror-incomplete-publish-times/kits/registry-metadata-probe/。
先读 README.md、AGENT_PROMPT.md 和 FILES.json,运行 copy/ 中的只读元数据脚本。
需要复现解析问题时,将 examples/repro/ 复制到独立目录,分别使用待测 pnpm 安装并运行 probe。
保留完整结果;不要改动原项目 lockfile,也不要全局关闭发布时间检查。