피어가 해결되는 방법

pnpm의 가장 좋은 기능 중 하나는 한 프로젝트에서 패키지의 특정 버전이 항상 하나의 의존성 세트를 갖는다는 것입니다. There is one exception from this rule, though - packages with peer dependencies.

피어 의존성은 그들의 부모와 동일한 버전을 공유하기 때문에 의존성 그래프에서 더 높게 설치된 의존성으로부터 해결됩니다. 즉, 만약 foo@1.0.0 에 두 개의 피어(bar@^1 및 baz@^1)가 있는 경우, 동일한 프로젝트에서 복수 개의 서로 다른 의존성 세트가 존재할 수 있음을 의미합니다.

- foo-parent-1
  - bar@1.0.0
  - baz@1.0.0
  - foo@1.0.0
- foo-parent-2
  - bar@1.0.0
  - baz@1.1.0
  - foo@1.0.0

위의 예에서 foo@1.0.0은 foo-parent-1 및 foo-parent-2에서 설치됩니다. Both packages have bar and baz as well, but they depend on different versions of baz. 결과적으로 foo@1.0.0 는 서로 다른 의존성 세트를 가집니다: baz@1.0.0 가 있는 것과 baz@1.1.0가 있는 것. 이러한 유스케이스를 지원하려면, pnpm이 foo@1.0.0 를 다른 의존성 세트 개수만큼 하드 링크해야 합니다.

일반적으로, 패키지에 피어 의존성이 없으면, 다음과 같이 의존성의 심볼릭 링크 옆에 있는 node_modules 폴더에 하드 링크됩니다.

node_modules
└── .pnpm
    ├── foo@1.0.0
    │   └── node_modules
    │       ├── foo
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── qux@1.0.0
    ├── plugh@1.0.0

그러나 foo 에 피어 의존성이 있는 경우, 이에 대한 의존성 세트가 여러 개 있을 수 있으므로 서로 다른 피어 의존성을 해결하기 위해 서로 다른 세트를 생성합니다.

node_modules
└── .pnpm
    ├── foo@1.0.0_bar@1.0.0+baz@1.0.0
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.0.0/node_modules/baz
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── foo@1.0.0_bar@1.0.0+baz@1.1.0
    │   └── node_modules
    │       ├── foo
    │       ├── bar   -> ../../bar@1.0.0/node_modules/bar
    │       ├── baz   -> ../../baz@1.1.0/node_modules/baz
    │       ├── qux   -> ../../qux@1.0.0/node_modules/qux
    │       └── plugh -> ../../plugh@1.0.0/node_modules/plugh
    ├── bar@1.0.0
    ├── baz@1.0.0
    ├── baz@1.1.0
    ├── qux@1.0.0
    ├── plugh@1.0.0

foo@1.0.0_bar@1.0.0+baz@1.0.0 또는 foo@1.0.0_bar@1.0.0+baz@1.1.0 안에 있는 foo 심볼릭 링크를 만듭니다. 결과적으로 Node.js 모듈 resolver는 올바른 피어를 찾습니다.

패키지에 피어 의존성이 없지만 그래프에서 상위 해결된 피어와의 의존성이 있는 경우, 해당 전이 패키지는 의존성 세트가 다른 프로젝트에 나타날 수 있습니다. 예를 들어, 단일 의존성인 b@1.0.0 를 갖는 패키지 a@1.0.0 가 있습니다. b@1.0.0 에는 피어 의존성 c@^1 이 있습니다. a@1.0.0 은 b@1.0.0의 피어를 절대로 해결하지 않으므로, b@1.0.0 의 피어에서도 종속이 됩니다.

이 구조가 node_modules에서 어떻게 보일지 보여줍니다. 이 예시에서, a@1.0.0 는 프로젝트의 node_modules에서 c@1.0.0 로 한 번, c@1.1.0로 다시 resolve되어 두 번 나타나야 합니다.

node_modules
└── .pnpm
    ├── a@1.0.0_c@1.0.0
    │   └── node_modules
    │       ├── a
    │       └── b -> ../../b@1.0.0_c@1.0.0/node_modules/b
    ├── a@1.0.0_c@1.1.0
    │   └── node_modules
    │       ├── a
    │       └── b -> ../../b@1.0.0_c@1.1.0/node_modules/b
    ├── b@1.0.0_c@1.0.0
    │   └── node_modules
    │       ├── b
    │       └── c -> ../../c@1.0.0/node_modules/c
    ├── b@1.0.0_c@1.1.0
    │   └── node_modules
    │       ├── b
    │       └── c -> ../../c@1.1.0/node_modules/c
    ├── c@1.0.0
    ├── c@1.1.0

Cyclic dependencies#

Added in: v12.0.0-rc.5 (pnpm v12 only)

Packages may depend on each other in a cycle: a depends on b, and b — directly, or through further packages — depends back on a. When a package inside such a cycle has peer dependencies, there is no single point "higher in the graph" to resolve them from, because entering the cycle at a and entering it at b lead to different parents.

pnpm cuts every cycle at a fixed place. The packages that form a cycle are ordered by their package IDs, and the edges that close it are cut at the same point no matter where the installation walks into it. The cut edge is still a real dependency — it is resolved against an occurrence of its target taken at the project level — but the walk never travels around the cycle, so a package inside a cycle is no longer resolved once per path that reaches it. Peer dependencies of packages inside a cycle still resolve to the nearest match along that order.

Because the cut no longer depends on the path the installation takes, the lockfile is a function of the dependency graph alone. Repeated installs, reordered packages globs in pnpm-workspace.yaml, and reordered entries in package.json all produce a byte-identical lockfile. Projects with cycle-heavy dependency graphs also end up with a smaller lockfile, since packages inside a cycle no longer get a separate peer variant per path that reaches them.

Existing lockfiles keep working: --frozen-lockfile installs consume them unchanged, and installs that skip resolution leave them untouched. The first install that re-resolves — after a dependency change, for instance — re-keys the peer variants of cyclic packages once, which shows up as a one-time lockfile diff.

In pnpm v11, where the cut depends on the order the graph is walked, the same dependencies can produce different lockfiles depending on the order projects and dependencies are listed in.