Create a monorepository with lerna & yarn workspaces

learn-and-yarn

Over the past few years, the concept of mono-repositories has successfully established itself, as it can significantly simplify the process of developing modular software projects, such as microservice-based infrastructures. The main advantages of this architectural approach are obvious in practice, therefore, I propose to create your own test mono-repository from scratch, simultaneously understanding the nuances of working with yarn workspaces and lerna . Well, let's get started!

Consider the structure of our project, which will be three libraries located in the packages / folder , as well as package.json in the root directory.

├── package.json
└── packages
    ├── app
    │   ├── index.js
    │   └── package.json
    ├── first
    │   ├── index.js
    │   └── package.json
    └── second
        ├── index.js
        └── package.json

It is understood that we have two independent libraries first and second , as well as an app library that will import functions from the first two. For convenience, all three packages are placed in the packages directory . You could leave them in the root folder or put them in a directory with any other name, but in order to follow generally accepted conventions, we will place them in this way.

The libraries first and second for simplicity of the experiment will contain only one function in index.js , each of which will return a hello string on behalf of the module. For the first example, it will look like this:

// packages/first/index.js
const first = () => 'Hi from the first module';
module.exports = first;

In the app module, we will display the message Hi from the app in the console , as well as greetings from two other packages:

// packages/app/index.js
const first = require('@monorepo/first');
const second = require('@monorepo/second');
const app = () => 'Hi from the app';
const main = () => {
  console.log(app());
  console.log(first());
  console.log(second());
};
main();
module.exports = { app, main };

So that first and second are available in the app , we denote them as dependencies in dependencies .

In addition, for each library we add the prefix @ monorepo / in the value name in front of the main name of the package to the local package.json .

// packages/app/package.json
{
  "name": "@monorepo/app",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
    "@monorepo/first": "^1.0.0",
    "@monorepo/second": "^1.0.0"
  }
}

Why do I need a prefix with a dog icon in front of the package name npm (@ monorepo /)?
Adding a prefix is ​​optional, but this is exactly the convention of package naming that many monorepositories adhere to: babel ,
material ui , angular, and others. The fact is that each user or organization has its own scope on the npm website , so there is a guarantee that all modules with the @ somescope / postfix are created by the somescope team , and not by attackers. Moreover, it becomes possible to call modules names that are already taken. For example, you can’t just take and create your own utils module , because such a library already exists. However, adding the postfix @ myscopename / we can get our utils ( @ myscopename / utils ) with blackjack and young ladies.

An analogue from real life for our test project can be various libraries for working with data, validation tools, analytics, or just a set of UI components. If we assume that we are going to develop a web and mobile application (for example, using React and React Native, respectively), and we have part of the reused logic, it may be worth putting it into separate components, for later use in other projects. Add to this the server on Node.js and you get a very real case from life.

Yarn workspaces


The final touch before creating a full-fledged mono - repository will be the design of package.json in the root of our repository. Pay attention to the workspaces property - we specified the value of packages / * , which means "all subkeys in the packages folder ". In our case, this is app , first , second .

// package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "main": "packages/app/index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

In addition, “private”: true must be specified in package.json , since workspaces are available only in private projects. In order for everything to take off, execute the yarn command (analogous to yarn install or npm install ) from the root directory. Since the dependencies that exist in the app module are defined as workspaces in the root package.json , in fact, we will not download anything from npm-registry , but simply bind ("link") our packages.



yarn

image

Now we can execute the node command . from the root directory that will run the script from the packages / app / index.js file .

node .

image

Let's see how it works. By calling yarn , we created symbolic links in node_modules to our directories in the packages folder .

image

Due to this relationship in dependencies, we got one big advantage - now, when changing in the first and second modules, our app will receive the current version of these packages without rebuilding. In practice, it is very convenient, because we can conduct local development of packages, still defining them as third-party dependencies (which they eventually become).

The next important benefit you can get from working with yarn workspaces, is the organization of storage of third-party dependencies.

Learn more about storing dependencies at the top level.
Suppose we wanted to use the lodash library in first and second . By running yarn add lodash from relevant directories, we got an upgrade local package.json - in dependencies will be the actual version of the package.
"dependencies": {
   "lodash": "^4.17.11"
 }

As for the lodash package itself - physically the library will be installed in node_modules at the root level once.
If the required version of the external package (in our case lodash ) is different for first and second (for example, first you need lodash v3.0.0 , and in second v4.0.0 ), then a package with a lower version ( 3.0.0 ) will get to the root node_modules , and the lodash version for the second module will be stored in local packages / second / node_modules .
In addition to the advantages, this approach may have minor disadvantages, which yarn allows to bypass with the help of additional flags. You can read more about such nuances in the official documentation .

Add Lerna


The first step in working with lerna is to install the package. Usually they perform a global installation ( yarn global add lerna or npm i -g lerna ), but if you are not sure if you want to use this library, you can use the call using npx .

We will initialize lerna from the root directory :

lerna init

image

In fact, we performed several actions at once with the help of one command: created a git repository (if it had not been initialized before that), created a lerna.json file and updated our root package.json .

Now in the newly created lerna.json file , add two lines - “npmClient”: “yarn” and “useWorkspaces”: true . The last line says that we already use yarn workspaces and there is no need to create the app / node_modules folder with symbolic links to first and second .

// lerna.json
{
  "npmClient": "yarn",
  "packages": [
    "packages/*"
  ],
  "version": "1.0.0",
  "useWorkspaces": true
}

Tests with Lerna


In order to show the convenience of working with lerna add tests for our libraries.
From the root directory, we install the package for testing - jest . Run the command:

yarn add -DW jest

Why do I need the -DW flag?
The -D (- dev) flag is needed so that the jest package is installed as a dev dependency, and the -W flag (- ignore-workspace-root-check) allows installation at the root level (which we need).

The next step is to add one test file to our package. For the convenience of our example, we will make all tests similar. For the first example, the test file will look like this:

// packages/first/test.js
const first = require('.');
describe('first', () => {
  it('should return correct message', () => {
    const result = first();
    expect(result).toBe('Hi from the first module');
  });
});

We also need to add a script to run tests in package.json of each of our libraries:


  // packages/*/package.json
  ...
  "scripts": {
    "test": "../../node_modules/.bin/jest --colors"
  },
  ...

The final touch will be updating the root package.json . Add a test script that will call lerna run test --stream . The parameter following lerna run defines the command that will be called in each of our packages from the packages / folder , and the --stream flag will allow us to see the output of the results in the terminal.

As a result, package.json from the root directory will look like this:

// package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "main": "packages/app/index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "test": "lerna run test --stream"
  },
  "devDependencies": {
    "jest": "^24.7.1",
    "lerna": "^3.13.2"
  }
}

Now, to run the tests, we just need to run the command from the root of our project:

yarn test

image

Version Upgrade with Lerna


The next popular task, with which lerna can cope with quality, will be to update package versions. Imagine that after implementing the tests, we decided to upgrade our libraries from 1.0.0 to 2.0.0. In order to do this, just add the line “update: version”: “lerna version --no-push” to the scripts field of the root package.json , and then run yarn update: version from the root directory. The --no-push flag is added so that after updating the version the changes are not sent to the remote repository, which lerna does by default (without this flag). As a result, our root package.json

will look like this:

// package.json
{
  "name": "monorepo",
  "version": "1.0.0",
  "main": "packages/app/index.js",
  "license": "MIT",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "scripts": {
    "test": "lerna run test --stream",
    "update:version": "lerna version --no-push"
  },
  "devDependencies": {
    "jest": "^24.7.1",
    "lerna": "^3.13.2"
  }
}

Run the version update script:

yarn update:version

Next, we will be asked to select the version that we want to switch to:

image

By clicking Enter we get a list of packages in which the version is updated.

image

We confirm the update by entering y and we get a message about the successful update.

image

If we try to run the git status command , we get the message nothing to commit, working tree clean , because lerna version not only updates package versions, but also creates a git commit and tag indicating the new version (v2.0.0 in our case).

Features of working with the lerna version team
If you add the line “version”: “lerna version --no-push” instead of “update: version”: “lerna version --no-push” in the scripts field of the root package.json , you can most likely come across unexpected behavior and red console. The fact is that npm-scripts by default calls the version command (reserved script) immediately after updating the package version, which leads to a recursive call to lerna version . To avoid this situation, it is enough to give the script a different name, for example update: version , as was done in our example.

Conclusion


These examples show one hundredth of all the possibilities that lerna has in conjunction with yarn workspaces . Unfortunately, so far I have not found detailed instructions for working with monorepositories in Russian, so we can assume that the beginning has been made!

Link to the test project repository.

Also popular now: