由 pnpm 处理 peerDependencies 的机制导致的 nestjs 依赖重复问题

450

排查问题

使用 pnpm workspace 搭建 nestjs 项目,在启动服务时遇到如下报错,意思是 nestjs 的 DI 系统无法找到要注入的依赖:

ERROR [ExceptionHandler] Nest can't resolve dependencies of the FooModule (FooModule, ?). Please make sure that the argument ModuleRef at index [1] is available in the FooModule context.

Nestjs 官方文档在 FAQ 中对该问题作为常见问题进行了解释:

Common errors - FAQ | NestJS - A progressive Node.js framework

If you are in a monorepo setup, you may face the same error as above....
This likely happens when your project end up loading two Node modules of the package @nestjs/core

如果不是真的缺少对应 provider 的话,最常见的可能性就是在 monorepo 中 resolve 到了两个不同的 @nestjs/core 包。

这里我们用的是 pnpm workspace ,并且指定的所有包版本都是匹配的,理论上来说最终都会 link 到同一份 @nestjs/core 代码。

但是打开 node_modules/.pnpm 会发现,里面的 @nestjs/core 有两个 版本完全一致 但是 带有不同后缀 hash 的目录,这就是问题所在了:

image.png

寻找问题原因

检索 pnpm discussions ,发现有相关问题的讨论:

原来这是由 pnpm 处理 peer dependencies 的机制导致的,官方文档详细介绍了这个机制:

How peers are resolved | pnpm

简而言之,如果一个 package 声明了 peerDependencies ,那么如果在不同的 workspace 下安装的 peerDependencies 不完全一致,就会导致有两份不同的 hash 被生成。

拿我们这里遇到的 @nestjs/core 的问题为例,其 9.1.2 版本的 package.json 声明的 peerDependenciespeerDependenciesMeta 如下:

{
  "peerDependencies": {
    "@nestjs/common": "^9.0.0",
    "@nestjs/microservices": "^9.0.0",
    "@nestjs/platform-express": "^9.0.0",
    "@nestjs/websockets": "^9.0.0",
    "reflect-metadata": "^0.1.12",
    "rxjs": "^7.1.0"
  },
  "peerDependenciesMeta": {
    "@nestjs/websockets": {
      "optional": true
    },
    "@nestjs/microservices": {
      "optional": true
    },
    "@nestjs/platform-express": {
      "optional": true
    }
  }
}

可以看到,除了 @nestjs/common, reflect-metadata, rxjs 以外,另外三个其实都是可选的,分别在当前服务要提供 ws / microservices / http 时才需要安装。

此时,如果我们有一个 packages/foo ,要提供 http 服务,安装 @nestjs/platform-express

# packages/foo
pnpm i @nestjs/core @nestjs/common rxjs reflect-metadata @nestjs/platform-express

我们还有另一个 packages/bar ,要提供微服务,安装 @nestjs/microservices

# packages/bar
pnpm i @nestjs/core @nestjs/common rxjs reflect-metadata @nestjs/microservices

这时候,由于 foobar 安装的 @nestjs/corepeerDependencies 并不一致, pnpm 为了严谨起见就会将他们 resolve 到两份不同的 @nestjs/core 中。这看起来也没什么问题,因为两个服务是独立运行的。

但是,此时我们还有一个 packages/common 存放一些公共模块,同样也依赖了 @nestjs/core ,不过那三个可选的依赖在这个包里都不需要:

# packages/common
pnpm i @nestjs/core @nestjs/common rxjs reflect-metadata

显然,这里的依赖和另外两个包都不一样,此时就会出现三个版本相同但 hash 不同的文件夹了:

image.png

foobar 引入 common 时,就会出现有两个不同的 @nestjs/core 被 resolve 到,引发上面的问题。

如何解决

首先一种可行的解决方案就是把所有包都装上相同的 peerDependencies ,那么最终解析到的 hash 就都一样了。不过这显然并不是个好办法,毕竟我们每个包所需的依赖就是不一样的。

所以,另一个 workaround 就是在 Discussion #4051 中提到的,通过 .pnpmfile.cjs 提供的 hook 能力,在读取 @nestjs/core 包信息的时候,把其中声明的 peerDependenciespeerDependenciesMeta 全部删除,来避免 pnpm 的 peer 机制:

// .pnpmfile.cjs
module.exports = {
  hooks: {
    readPackage(pkg) {
      // @see https://github.com/pnpm/pnpm/discussions/4051
      if (pkg.name === '@nestjs/core' || pkg.name === '@nestjs/common') {
        delete pkg.peerDependencies;
        delete pkg.peerDependenciesMeta;
      }
      return pkg;
    },
  },
};

然后删除 node_modulespnpm-lock.yaml 重新安装依赖,pnpm 就会认为 @nestjs/core 并没有声明 peerDependencies ,从而不会生成带 hash 的文件夹了。

image.png

这个问题相对比较少见,基本只有同时符合以下条件才会发生:

  • 声明了 peerDependencies
  • 部分 peerDependencies 是可选安装
  • 你需要在同一个 monorepo 的不同 workspace 下安装不同的 peerDependencies 组合
  • 解析到两个不同路径的包会导致出错(很多包即使解析到不同路径也没事,比如只提供工具类函数)

而 nestjs 刚好符合这几个特点,于是就踩到了这个坑。

说个题外话,提到同一个依赖被解析到多份,我就想起来了之前在 Vite 踩到的相似的问题:

在一年多之前提出之后一直没解决,前段时间终于因为大客户 @sveltejs/kit 也遇到这个问题才推动解决了,泪目。