Injected dependencies
Injected dependencies are a PNPM feature allowing local project folders to be installed as if they were published to an NPM registry.
Background: Conventional workspace symlinking
Rush projects typically use the workspace:
specifier to depend on other projects within the monorepo workspace. For example, suppose my-project
and my-library
are projects in the Rush workspace:
my-repo/apps/my-project/package.json
{
"name": "my-project",
"version": "1.2.3",
"dependencies": {
"react": "^18.3.1",
"my-library": "workspace:*"
}
}
In the above example, the react
package will installed by downloading from the NPM registry and extracting into a node_modules
subfolder. By contrast, the workspace:*
specifier causes PNPM to create a node_modules
symlink pointing to the source code folder where my-library
is developed:
Symlink: my-repo/apps/my-project/node_modules/my-library
--> my-repo/libraries/my-library/
In this way, my-project
will always consume the latest locally built outputs for my-library
. It may even be the case that my-project
and my-library
are never published to an NPM registry at all.
Limitations of workspace symlinking
Suppose however that my-library
declares a peer dependency like this:
my-repo/libraries/my-library/package.json
{
"name": "my-library",
"version": "0.0.0",
"peerDependencies": {
"react": "^18.0.0 || ^17.0.0"
},
"devDependencies": {
"react": "17.0.0"
}
}
The my-library
project declares that it can use both React version 17 and 18. For local development, the devDependencies
install the oldest supported version 17.0.0, a common practice to validate backwards compatibility.
Why do we need peerDependencies
instead of dependencies
? With dependencies
, the package manager would be free to choose any version of react
matching "^18.0.0 || ^17.0.0"
. For example, if our app is using React 17, then my-library
could get React 18, which is wrong. The peer dependency avoids this possibility by stipulating that my-library
must get the same react
version as its consumer (and in fact the same installed disk folder).
What if two different apps depend on my-library
, and those apps have different versions of react
? For external NPM packages, PNPM would normally solve this by installing two copies of (the same version of) my-library
into two different subfolders of node_modules
. These copies are called "peer dependency doppelgangers". They are needed because of a design constraint of the Node.js module resolvers:
Context-free resolution: When a given file on disk imports an NPM package, the module resolver will always resolve the same way for that file.
In other words, the only way to cause my-library/lib/index.js
to import React 17 for app1
while importing React 18 for app2
is for the two apps to import from two different (doppelganger) copies of index.js
.
The package manager creates doppelgangers automatically as needed when extracting NPM packages into the node_modules
folder. However, in our example, my-project
used workspace:*
to create a symlink to the project folder for my-library
, instead of extracting an NPM package into the node_modules
folder. How will the peer dependency be satisfied? PNPM simply produces an incorrect installation in this situation:
- When
my-project
imports React, it will get version 18 - When
my-project
importsmy-library
andmy-library
imports React, it will get version 17 (as installed from thedevDependencies
)
The peerDependencies
are ignored.
Injected dependencies to the rescue
To solve this problem, PNPM supports a package.json setting called injected
that will cause my-library
to get installed as if it had been published to NPM. Here's how to enable it:
my-repo/apps/my-project/package.json
{
"name": "my-project",
"version": "1.2.3",
"dependencies": {
"react": "^18.3.1",
"my-library": "workspace:*"
},
"dependenciesMeta": {
"my-library": {
"injected": true
}
}
}
With this change, pnpm install
(in our case rush install
or rush update
) will install my-library
by copying the project contents into the node_modules
folder of my-project
. Because they are conventionally installed, injected dependencies can become doppelgangers and correctly satisfy peer dependencies.
A second benefit: Injected installation honors publishing filters such as .npmignore
, and so the copied contents accurately reflect what would happen if my-library
had been published to an NPM registry. For this reason, a test project that consumes the library can set injected: true
to catch mistakes in .npmignore
filters -- misconfigurations that are often missed when using workspace:
symlinking.
Sounds great -- so why doesn't PNPM use injected install for every workspace:
reference?
Syncing injected dependencies
We said that injected dependencies get copied into a node_modules
folder during rush install
. But what happens if we make changes to my-library
and then run rush build
? When my-project
imports my-library
, it will still find the old copy from node_modules
. To get a correct result, we would need to redo rush install
every time we rebuild my-library
. More precisely, we would need to redo rush install
after building any injected project but before the consumer gets built. In a worst case, that could mean redoing rush install
hundreds of times during rush build
. This is unrealistic.
PNPM currently doesn't yet include a built-in solution for this problem, and as a result injected dependencies have not yet gained wide adoption. However, a new tool called pnpm-sync provides a solution: Whenever my-library
is rebuilt, pnpm-sync
can copy its outputs to update the appropriate node_modules
subfolders.
Normally it would be up to each project to determine whether and how to invoke the pnpm-sync
command, but Rush integrates this feature and manages it automatically. To use pnpm-sync
with Rush, enable the usePnpmSyncForInjectedDependencies
experiment:
common/config/rush/experiments.json
/**
* (UNDER DEVELOPMENT) For certain installation problems involving peer dependencies, PNPM cannot
* correctly satisfy versioning requirements without installing duplicate copies of a package inside the
* node_modules folder. This poses a problem for "workspace:*" dependencies, as they are normally
* installed by making a symlink to the local project source folder. PNPM's "injected dependencies"
* feature provides a model for copying the local project folder into node_modules, however copying
* must occur AFTER the dependency project is built and BEFORE the consuming project starts to build.
* The "pnpm-sync" tool manages this operation; see its documentation for details.
* Enable this experiment if you want "rush" and "rushx" commands to resync injected dependencies
* by invoking "pnpm-sync" during the build.
*/
"usePnpmSyncForInjectedDependencies": true
This will enable the following behaviors:
rush install
andrush update
will automatically invokepnpm-sync prepare
to configure copying of injected dependencies such asmy-library
rush build
(and other Rush custom commands and phases) will automatically invokepnpm-sync copy
to resync the installed folders whenever a project likemy-library
is rebuiltrushx
will automatically invokepnpm-sync copy
after any operation performed under themy-library
folder
Injected dependencies for subspaces
If you are using Rush subspaces, consider enabling alwaysInjectDependenciesFromOtherSubspaces
as well:
common/config/subspaces/<subspace-name>/pnpm-config.json
/**
* When a project uses `workspace:` to depend on another Rush project, PNPM normally installs
* it by creating a symlink under `node_modules`. This generally works well, but in certain
* cases such as differing `peerDependencies` versions, symlinking may cause trouble
* such as incorrectly satisfied versions. For such cases, the dependency can be declared
* as "injected", causing PNPM to copy its built output into `node_modules` like a real
* install from a registry. Details here: https://rushjs.io/pages/advanced/injected_deps/
*
* When using Rush subspaces, these sorts of versioning problems are much more likely if
* `workspace:` refers to a project from a different subspace. This is because the symlink
* would point to a separate `node_modules` tree installed by a different PNPM lockfile.
* A comprehensive solution is to enable `alwaysInjectDependenciesFromOtherSubspaces`,
* which automatically treats all projects from other subspaces as injected dependencies
* without having to manually configure them.
*
* NOTE: Use carefully -- excessive file copying can slow down the `rush install` and
* `pnpm-sync` operations if too many dependencies become injected.
*
* The default value is false.
*/
"alwaysInjectDependenciesFromOtherSubspaces": true
See also
- pnpm-sync GitHub project
- dependenciesMeta.*.injected from PNPM documentation
- Rush subspaces