Setting up a monorepo with RushJS and PNPM - Part 1

Eugène Duvenage | Apr 12, 2023 | Reading time: 8 minutes
images/rushjs-monorepo.png

I’ve been using a Rush based monorepo for around 3 years now. The codebase consists of a high volume marketplace website, some B2B APIs and a few CLI apps; somewhere around 20 or so individual projects accessed by two dev teams. We have had the felt the general benefits of using a monorepo such as:

  • Simple initial code checkout, limited to one repository clone
  • Easy dependency management, dependencies can be kept in sync at the repository level across all projects
  • Easy refactoring, IDEs can search across all projects for changes
  • Simplified testing and deployment, all projects can be tested and deployed as a unit when changes are made

Using Rush as the monorepo manager and PNPM as the package manager gave us a valuable productivity boost on top of these such as:

  • Super fast builds, due to Rush’s parallel and incremental builds
  • Big repository size reduction, due to PNPMs efficient module dependency management
  • Consistent dependency versions across projects, using Rush’s check command before commits
  • Easy package build management, using Rush’s deploy command
  • Simplified local execution, using Rush’s custom global commands

While there has been little need for major changes since the initial configuration, I recently setup a new repository and found some nicer ways of configuring rush that I thought I’d share. You can find a example demo monorepo in this github repository.

Initialise a Rush monorepo

In order to initialise the monorepo we need two tools installed, Rush and a Node.js package manager. You can use the default NPM package manager, but since this article is about the benefits of using PNPM we will use PNPM, you can read about the trade-off of using NPM vs PNPM vs Yarn with Rush in the Rush documentation.

Follow the installation instructions in the PNPM documentation for your platform, I am using Linux and ran:

1
2
# fetch and run the latest pnpm install script
curl -fsSL https://get.pnpm.io/install.sh | sh -

Once PNPM is installed we can install Rush globally with:

1
2
# install rush globally
pnpm install -g @microsoft/rush

Then create the directory you will be working in and initialise the repository as a Rush and git repository using the following commands:

1
2
3
4
mkdir rush-monorepo-demo # make the new repository folder
cd rush-monorepo-demo # change directory into the new folder
rush init # this initialises the repository to be managed by rush
git init # this initialises the new folder as a git repository

The initialised repository will have a “common” directory where Rush will store it’s configuration and a “rush.js” file that is the main configuration file for rush, see the screenshot below for reference:

initialised rush folder

Setup rush repository defaults

Before adding projects to the repository I like to configure some global options to help the teams using the repository have a consistent experience.

Define the tool versions

In the “rush.js” config file set the rushVersion and pnpmVersion properties to the latest versions you want to use, this will ensure that when a user pulls the repo for the first time that the same version or Rush and PNPM will be configured for each user. It is also worthwhile setting the nodeSupportedVersionRange property to specify a narrow range of node versions that a developer should be using to avid surprises if you are using newer Node.js features. I used the following to get the versions I had installed. There are a few other settings that are worth configuring but I’ll leave those to future posts in this series.

1
2
pnpm lg -g @microsoft/rush # list the global version of rush we have installed
pnpm --version # get the version of PNPM installed
rush tool versions configuration

Standardise commit messages

When you have many people working on a repository, limiting the format of git commit messages can help ensure everyone can understand the changes that are occurring in the repository over time. Conventional Commits provides a light weight “convention” for commit messages that you can use, it’s just a specification, to actually check the commit messages we can use a git-hook that fires on commit and the commitlint node package. We can also get some guidance on how to structure our commit messages using the prepare-commit git hook, this runs when you type git commit, using commitizen, commitizen will give you an interactive cli to create your git commit message. For those using visual tools there are plugins like this one for VS Code that provide the same message guidance.

Rush provides a structured mechanism to create git hooks and to install monorepo wide tooling, the rush term is an auto-installer. Create one using the command below:

1
2
# use rush to create a new autoinstaller
rush init-autoinstaller --name conventional-commits

This will create a folder in common/autoinstallers/conventional-commits with a package.json you can configure. Change directory into this folder and use PNPM to install the following packages:

1
2
# install commitlint, commitizen conventional-commits configuration and commitizen
pnpm i @commitlint/cli @commitlint/config-conventional commitizen

You need to add a configuration file for commitlint in the autoinstaller directory called commitlint.config.js containing the following and any additional config you choose:

1
2
3
module.exports = { 
    extends: ["@commitlint/config-conventional"] 
};

We also need to tell commitizen which standards it must follow, place a file named .czrc in the root of the project, same level as rush,json, with the following contents:

1
2
3
{
  "path": "cz-conventional-changelog"
}

We now have the required packages installed, we just need to tell Rush that we are done changing the auto-installer package.json file by running the following, run it each time you make changes to the autoinstaller:

1
rush update-autoinstaller --name conventional-commits

Now we can configure our git hooks. I found for the next steps to work I had to have made one initial commit, so start by running:

1
2
git add .
git commit -m "initial commit"

Now lets start with the commit-msg hook that runs after you try commit, we can use the git hook to check if the provided message meets our standards. Add a file called “commit-msg” in the common/git-hooks folder with the following content:

1
2
3
4
#!/bin/sh
# run commit lint to check the commit message against our conventions
./common/autoinstallers/conventional-commits/node_modules/.bin/commitlint \
--config ./common/autoinstallers/conventional-commits/commitlint.config.js --edit $1

If you now run rush install Rush will install the git hooks for you, re-run it each time you make a change to a hook. You can see what rush installed by looking in your hidden .git/hooks folder in the project root. Now when you try commit with a message that doesn’t follow the convention then the git command will fail, for example:

1
2
git add -A
git commit -m "some unformatted commit message"

will result in the following failure:

commit failure example

We have successfully limited commit messages to follow the convention but it’s not yet that user friendly, next we will add commitizen support to prompt us for commit messages in the correct format. This time we will use the prepare-commit git hook. Add a file called “prepare-commit-msg” in the common/git-hooks folder with the following content:

1
2
3
4
5
6
7
8
#!/bin/sh

# Check if dev/tty is available for the commitizen prompt guidance, this is so that we don't run commitizen 
# if the git commit is not coming from a terminal window that does not need an interactive prompt
if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then
    # A /dev/tty is available and usable so delegate to commitizen
    exec < /dev/tty && common/autoinstallers/conventional-commits/node_modules/.bin/cz --hook || true
fi

Now run rush install again, Rush will install the updated git hooks for you.

You can now try commit these changes by trying to commit and follow the commitizen prompts:

1
2
git add -A
git commit # don't use -m or --message to add a message automatically

This time you get a nice interactive prompt to help you create the correctly formatted commit message.

interactive commit prompt

Enforce common dependency versions

Forcing developers to consider the impact of their changes on the rest of the repository, in terms of new or updated package dependencies, before they commit code can be very useful. Rush has a rush check command that can tell you if package versions are inconsistent across projects. There are valid scenarios when you need to have different versions, and rush supports this need with the allowedAlternativeVersions section in the common-versions.json file but in many projects I’ve worked on this feature has been handy.

This can be enforced with a pre-commit hook, add a file called “pre-commmit” in the common/git-hooks folder with the following content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/sh

# a simple bash function to write out the failure message neatly
print_error () {
    printf "[ERROR(git-pre-commit)] Rush has found mismatching package dependencies in your project.\n"
    printf "\tPlease fix them before committing your work.\n"
    printf "\t(Run 'rush check' to identify conflicts.)\n"
}

# run rush check and use the above print_error function if the command has a non zero exit code
rush check>/dev/null || ( print_error && exit 1 )

Now run rush install again, Rush will install the updated git hooks for you. Each time you commit rush will let you know if any dependencies are out of date. If your team finds this too invasive there is a newer setting that can be enabled in the rush.json config file ensureConsistentVersions that will run rush check when running any of the following rush commands:

  • rush install
  • rush update
  • rush link
  • rush version
  • rush publish

Enforce consistent syntax style

Lastly we can configure prettier to format all code in the repository in a consistent manner, we can use the rush autoinstaller and git hooks once again, but as the process is a little more complex I will leave the explanation to another post in this series once we have some code to format.

Next steps

In the next article I’ll discuss how to add projects to rush using conventions and how to configure rush to build them.

comments powered by Disqus