RFC #4320: Rush Subspaces
RFC maintainers: @chengcyber, @octogonz
The PNPM package manager provides a workspace feature that allows multiple projects to be managed as a group. The workspace is defined by pnpm-workspace.yaml, which in a Rush monorepo is generated from rush.json. That workspace has a package lockfile pnpm-lock.yaml that is essentially an installation plan, tracking the installed version of every dependency for your projects.
When projects share a lockfile, the versions of NPM dependencies are centrally coordinated, which mostly involves choosing the right version numbers to avoid problems such as side-by-side versions, doppelgangers, and unsatisfied peer dependencies. For a crash course in these topics, see the Lockfile Explorer docs.
Centrally coordinating lockfiles brings some challenges:
-
Consistency assumption: In a healthy monorepo, most projects will use a consistent set of toolchains and versions, or at least a small number of such sets (perhaps one set of versions for experimental projects, one for stable projects, etc.). The
.pnpmfile.cjsoverride rules can then mostly involve forcing projects to conform to one of those established sets. This assumption does not apply very well for projects whose dependencies wildly different from the rest of the monorepo, such as a project that was developed externally and then moved into the monorepo. (This RFC was originally proposed by TikTok, whose monorepo has an abundance of such projects.) -
Collateral effects: When someone updates a version and regenerates
pnpm-lock.yaml, this may affect the version choices for shared dependencies being used by other unrelated projects. Those projects must then be tested, and fixed if a break occurs. (TikTok has many projects that rely primarily on manual testing, which is costly.) -
Git merge conflicts: Git pull requests will encounter merge conflicts if multiple PRs have modified the same NPM dependencies in
pnpm-lock.yaml. If the file is frequently churned, it can become a "mutex" that requires each PR to be built and merged one at a time, greatly reducing parallelization. -
Unrealistic library tests: When publishing NPM packages for external consumption, it can be beneficial for a test project to use a real installation of the library project rather than relying on
workspace:*linking. Using a real installation can reveal bugs such as incorrect.npmignoreglobs, that otherwise would not be discovered until after the release is published. (A prototype of this idea was implemented by the install-test-workspace project in the Rush Stack monorepo, discussed in pnpm#3510.)
All of the above problems can be reduced by breaking apart the single centrally coordinated file into multiple decoupled lockfiles; however, doing so creates new problems whose trouble increases according to the number of lockfiles. In the subsequent sections, we'll be contrasting 3 different models:
- 1 lockfile: the established convention for PNPM workspaces
- 700+ lockfiles: our current "split workspace" fork of Rush, roughly one lockfile per project
- 20 lockfiles: this new "subspaces" proposal, roughly one lockfile per team
PNPM supports a feature called split workspace enabled via the .npmrc setting shared-workspace-lockfile=false. With this model, every project gets its own pnpm-lock.yaml file, and workspace:* dependencies are installed as symlinks pointing to the corresponding library project folder. Such links are equivalent to what npm link would create, and therefore do not correctly satisfy package.json dependencies. For that reason PNPM has deprecated this feature. Nonetheless, TikTok has been using it privately via a forked version of Rush tracked by Rush Stack PR #3481 (split workspace). Our fork adapts Rush to support multiple lockfiles while also preserving the usual "common" lockfile, with the goal that over time split lockfiles would be eliminated by eventually migrating all projects into the common lockfile.
Note that with the "split workspace" feature there is still only one
pnpm-workspace.yamlfile, so this terminology is a bit misleading -- the lockfile is being split, not the workspace file.
The split workspace feature has two major drawbacks:
-
Not scalable: We currently have over 700 split lockfiles. Because each lockfile gets installed separately, our total install time is approximately 700x slower than a conventional single-lockfile monorepo. This cost is somewhat hidden in our setup because each CI pipeline only installs a small subset of lockfiles, distributed across hundreds of VMs, one for each pipeline. But even if the runtime cost is acceptable, consuming so many VM resources is not financially acceptable.
-
Incorrect installation model: As mentioned, this installation does not correctly satisfy
package.jsonversion requirements. For example, ifmy-appdepends on[email protected]andmy-libraryalso depends on[email protected], two distinct copies ofreactwill be installed in thenode_modulesfolder, which we call a split workspace doppelganger. This happens because eachpnpm-lock.yamlis processed essentially as an independent installation. Attempts to fix this problem are equivalent to reverting to a centralized lockfile.
For these reasons, the Rush maintainers have been reluctant to accept PR #3481 as an official feature.
Let's propose a new feature called subspaces that divides the workspace into named groups of projects. (In earlier discussions, we called this same feature "injected workspaces.") Each project belongs to exactly one subspace, and each subspace has one lockfile and associated configuration such as .pnpmfile.cjs, common-versions.json, etc. This can solve both of the problems identified above:
-
Scalable: Whereas the split workspace feature introduced one lockfile for every project, subspaces allow splitting conservatively, according to meaningful purposes. For example, we might define one subspace for "bleeding edge projects," one for "testing libraries," and one for "everything else." Or perhaps one subspace per team. A large monrepo could have 20 subspaces but not 700+.
-
Correct installation model: When projects belong to separate lockfiles, instead of treating
workspace:*as a blindnpm linkinto a separate universe of versions, subspaces will instead perform an "injected" install. This terminology comes from PNPM's injected: true feature. It simulates what would happen if the library project was first published to an NPM registry (for example Verdaccio onlocalhost) and then installed normally by the dependent project. Rush'sinstall-test-workspaceproject achieves the same result by runningpnpm packin the library folder to produce a tarball, then using.pnpmfile.cjsto replaceworkspace:*with afile:reference to that tarball.
Whichever way that injected installs are implemented, an important consequence is that the library project gets copied instead of symlinked into the consumer's node_modules folder. This fundamentally changes the developer workflow:
A conventional workflow only needs to perform rush install once:
# 1. this will link my-app/node_modules/my-lib --> libraries/my-lib
rush install
# 2. Make some changes to my-lib, which is a dependency of my-app
# 3. Rebuild my-lib and my-app
rush build --to my-app
# 4. everything is now good, repeat from step 2Whenever my-lib is modified and rebuilt, my-app automatically reflects those changes, because my-app/node_modules/my-lib is a symlink pointing to the build outputs. By contrast, with an injected install, step #1 makes a copy of libraries/my-lib, which does not update automatically. In step #3 we must redo this copy, and copying must occur AFTER my-lib is built, but BEFORE my-app is built.
How to accomplish that? It implies a new project lifecycle event such as postbuild (for my-lib) or prebuild (for my-app).
-
prebuild challenges: If each project syncs its injected folders before building, then the main problem is change detection. "Building" could mean any operation in that folder, for example any
npm run do-somethingcommand, and such commands can be chained together. Efficient filesystem change detection is very difficult without a live process such as chokidar or watchman, and every such project needs this logic. With PNPM symlinking, two different projects may have an injectednode_modulessubfolder that ends up symlinking to the same final target; in this case, a mutex may be required to prevent two concurrent prebuild actions from overwriting each other's outputs. -
postbuild challenges: On the other hand, if the library itself updates all of its injected copies after building, then watching is not necessary; it's relatively easy to know when the library has finished building. The mutex problem is also simpler, or avoided entirely if we don't allow concurrent builds in the same folder. The main challenge is registering/unregistering the folders to be updated, since in theory any PNPM workspace could introduce a new injected install relationship, or the project folder might get moved, or abandoned but left on disk.
Our proposal chooses the "postbuild" approach because it seems to be easier to implement and more efficient for Rush's use case, but perhaps ultimately both approaches can be supported.
This is a nontrivial change: PNPM's "injected" feature is not widely used today, for the exact reason that it provides no event for updating the injected copies, and thus is only practical for non-built projects such as plain .js source files without any transformations. The PNPM maintainers perhaps hesitated to introduce such an event as it is unconventional and may break assumptions of existing tools. Rush monorepos are in a better position to adopt such a model, given to our focus on centrally managed monorepos with a more formalized structure.
Consider the following diagram:
NPM package dependencies must form a directed graph without cycles, and this determines build order. This suggests that our lockfiles should also avoid cycles: for example, if S1 depends on S2 via A->B then perhaps we should not allow S2 to depend on S1 via C->D. Surprisingly, it turns out that this constraint is unnecessary. Injected dependencies are installed as if the tarball was fetched from an NPM registry, and recall that NPM registries store package.json files but not lockfiles. In this way each subspace lockfile is essentially a self-contained installation plan that gets generated based on the package.json file of the library project, but without ever consulting the other lockfile. Thus, the above diagram poses no problems. The project build order must of course still follow the directed acyclic graph, with the "postbuild" event copying the package contents.
In PNPM's implementation, there is only one lockfile, and injected: true is manually configured in package.json for specific dependency package names. How should an engineer know when to enable this setting? In other words, when should a workspace:* dependency get installed via injecting instead of folder symlinking?
As a clue to this problem, recall that PNPM's installation model has a longstanding limitation that workspace:* dependencies do not correctly satisfy peer dependencies, because peer dependencies are satisfied by making copies of the package folder (peer doppelgangers). In practice this can usually be mitigated for example by enforcing consistent versions across the monorepo, or by using Webpack aliases to override module resolution, but these mitigations are hacks. The injected: true feature originally arose as a correct solution.
Here is the complete list of cases where injected copying is required (assuming we are unwilling to mitigate the problem in some other way):
- If a local project depends on another local project via
workspace:*and needs to satisfy a peer dependency (including implicit peer dependencies resulting from transitive dependencies) - In our new subspaces proposal, injecting is required wherever a
workspace:*dependency refers to a project in a separate subspace - Even if it is not theoretically required, injecting can be enabled manually for more accurate testing of published libraries (the
install-test-workspacescenario mentioned earlier)
Note that cases #1 and #2 could be automatically inferred -- we don't really need to require engineers to manually configure an injected: true setting. In fact it would not be theoretically incorrect to always inject every dependency, except that in practice copying is significantly more expensive than symlinking, and of course it also requires our unconventional "postbuild" lifecycle event.
Thinking more deeply about that last point, we are proposing two entirely separate features:
- Multiple lockfiles which are defined using subspaces
- Injected installation with a "postbuild" lifecycle event to that updates the folder copies under
node_modules
Each feature could be used by itself:
- #1 without #2: Subspaces could be used without injected installation, instead handling
workspace:*by creating simple symlinks as was done with the split workspace feature. This is undesirable because it produces an incorrect solution. But we should implement it, since it will help with migrating from a split workspace, by allowing split lockfiles to be replaced by equivalent subspaces. - #2 without #1: Injected installation could be used without subspaces, as exemplified by PNPM's
injected: truefeature. We should support this in the final design, however doing so probably requires designing config files and policies to manage such settings in a large scale problem domain. In order to postpone that work, for our initial implementation we will make a simplifying assumption:
Initial implementation: A dependency will be injected if-and-only-if it is a workspace:* reference that refers to a project external to the lockfile/subspace that is being installed.
Subspaces will be enabled using a new config file common/config/rush/subspaces.json, whose format will be:
common/config/rush/subspaces.json
{
"useSubspaces": true,
// Names must be lowercase and separated by dashes.
// To avoid mistakes, common/config/subspaces/ subfolder
// cannot be used unless its name appears in this array.
"subspaceNames": [ "default", "react19", "install-test" ]
}The lockfile and associated config files for each subspace will be common/config/subspaces folder:
common/config/subspaces/<subspace-name>/pnpm-lock.yaml
common/config/subspaces/<subspace-name>/.pnpmfile.cjs
common/config/subspaces/<subspace-name>/.npmrc
common/config/subspaces/<subspace-name>/common-versions.json
Subspaces will also allow for a global configuration file for npmrc settings to apply for all subspaces. This global configuration file will be located in the common rush directory: common/config/rush/.npmrc-global
As noted in the PR #3481 discussion, Rush's current strategy of installing directly into common/temp makes it difficult to introduce additional PNPM installations without phantom dependency folders. To address this problem, when useSubspaces=true, the top-level common/temp/node_modules folder will not be created at all. Instead, lockfiles will get installed to subfolders with the naming pattern common/temp/subspaces/<subspace-name>/.
Rush projects will be mapped to a subspace using a new project-specific field in rush.json:
rush.json
"projects": [
"my-project": {
"packageName": "@acme/my-project",
"projectFolder": "apps/my-project",
"subspace": "react18"
}
]If the "subspaceNames" array in subspaces.json includes the name "default", then the "subspace" field can be omitted for rush.json projects; in that case the project will be mapped to the default subspace.
We propose to introduce a new command line tool called pnpm-sync that should be invoked for any library projects that have been installed as an injected dependency. Doing so updates the installed copies of this library's outputs. This command should be invoked whenever the library project has been rebuilt in any way, at the end of that operation, and before building any dependent projects. The command is so-named because we eventually hope to contribute it back to the PNPM project as a package manager feature, rather than making it a Rush-specific tool.
In a vanilla PNPM workspace, we could introduce "postbuild" as an actual NPM lifecycle event to invoke pnpm-sync:
package.json
{
"name": "my-library",
"version": "1.0.0",
"scripts": {
"build": "heft build --clean",
"postbuild": "pnpm-sync"
}
. . .In a Rush monorepo, it is probably better invoked via a dedicated Rush phase.
The pnpm-sync command will perform the following operations:
- Look for a machine-generated file
<project-folder>/node_modules/.pnpm-sync.jsonwhich contains an inventory of injected folders to be updated. In our initial implementation, this file will get generated byrush installorrush update. Later,pnpm installwill manage it natively. - Calculate the list of files to be copied, using the same logic as
pnpm packwhich consults the .npmignore file and/orfilesfield of package.json. - Copy those files into each target folder.
Here's a suggested format for .pnpm-sync.json:
<project-folder>/node_modules/.pnpm-sync.json
{
"postbuildInjectedCopy": {
/**
* The project folder to be copied, relative to the folder containing ".pnpm-sync.json".
* The "pnpm-sync" command will look for package.json and .npmignore in this folder
* and apply the same filtering as "pnpm pack".
*/
"sourceFolder": "../..",
"targetFolders": [
{
/**
* The target path containing an injected copy that "pnpm-sync" should update.
* This path is relative to the folder containing pnpm-sync.json, and typically
* should point into the physical ".pnpm" subfolder for a given PNPM lockfile.
*/
"folderPath": "../../node_modules/.pnpm/file+..+shared+my-library/node_modules/my-library"
},
{
// Here's an example for a hypothetical peer doppelganger of our "my-library"
"folderPath": "../../node_modules/.pnpm/[email protected]/node_modules/my-library"
}
]
}
}@chengcyber has created this repository to help with studying pnpm-sync folder structures:
It illustrates how two PNPM workspaces could be configured to automatically perform injection for cross-space dependencies, by using .pnpmfile.cjs to automatically rewrite workspace:* to links to file: links, similar to the approach of his PR #3481. It also illustrates how this might be adapted to a Rush feature. He found that PNPM 6 has different handling of file: from later versions of PNPM.
Our experience with Rush Stack PR #3481 (split workspace) found that operational changes to pnpm install have relatively little impact on other Rush features. For example:
- The
rush buildcache works with PR #3481 and correctly calculates cache keys based onnode_modulesdependencies. rush publishdoes some rewriting ofworkspace:*dependencies, but he heuristic that it uses does not seem to assume that the referenced project is really in the same pnpm-workspace.yaml file.rush deployshould work essentially the with injected installations for subspaces, since the underlying @rushstack/package-extractor engine is driven by Node.js module resolution, and is largely independent of how thosenode_modulesfolders or symlinks were created.- PR #3481 integrated with Rush project selectors, for example
rush list --only split:true. For subspaces, we can implement something similar, for examplerush list --only space:my-subspace-name.
PR #3481 did not attempt to apply Rush policies to projects with split lockfile; policies only applied to the so-called "common" lockfile. Generalizing these policies across subspaces will be nontrivial work, so we've proposed to implement that as a secondary stage of work.
