In this post, I’m showing a way to setup a monorepo with Lerna, taking into account some pitfalls when publishing to npm.
Lerna is a tool for managing JavaScript projects with multiple packages. In my very simple example, I have a project with 3 packages: a library, a CLI, and a VS Code Extension. The library package is used by both the CLI and the VS Code Extension packages.
Lerna makes the following things easy in my workflow:
- Linking dependent packages together for local development. Normally, you’d
have to use the
npm linkcommand (multiple times, depending on how many packages you need to link). With Lerna,lerna bootstraptakes care of that when you first clone a project (together with doingnpm installfor you). - Version management. Conceptually, my packages are all part of the same
project, so I would like all of them to have the same version. Lerna takes
care of that easily, the version is declared in the
lerna.jsonfile and controls the version for all packages. Bumping the version is also easily done with thelerna versioncommand. - Publishing to npm. For my workflow, I like to publish all packages to npm,
even if they didn’t have any changes since the previous release. I’ll dive
into npm publishing in more details later, but this is done in two steps. On
my local machine, I use the
lerna versioncommand to bump the version. This creates and pushes a git tag. On the CI side, I run thelerna publishcommand whenever a new tag is pushed.
Repo structure
Things get a bit more complicated because I use TypeScript instead of JavaScript. I’m going to show a little bit the structure of my repo:
[packages]
|- [html-fmt-cli]
| |- [src]
| | |- index.ts
| | \- index.test.ts
| |- package.json
| |- tsconfig.json
|- [html-fmt-core]
\- [html-fmt-vscode]
README.md
lerna.json
package.json
On the top level, there’s the root package.json and lerna.json files. The
root package.json just lists Lerna as a dev dependency. It’s flagged as
private to prevent accidentally publishing this to npm. lerna.json holds the
version of the packages and indicates that packages are to be found under the
packages folder.
The three packages underneath the packages folder have a similar structure.
Inside you’ll find a package.json and a tsconfig.json. To avoid mixing the
code with other files, code is kept in a separate sub-directory, src. This
will make life a bit more complicated when publishing to npm, which I’ll discuss
later. Tests are kept side by side with the code they’re testing (e.g.
index.test.ts is the unit test file for index.ts). I like this option more
compared to keeping tests on a separate directory because it’s easy to locate
and easier to write the import statement without trying to figure out how many
../ to add to match the directory structure.
tsconfig
Moving further to TypeScript, this is how my tsconfig.json looks like for the
library project, html-fmt-core:
{
"compilerOptions": {
"declaration": true,
"outDir": "./out",
"allowJs": false,
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"strict": true
},
"include": ["src/**/*.ts"]
}
The key elements here are:
- Source code is in
srcfolder - Output JavaScript code will be in
outfolder (sibling ofsrc) - Generate declaration files (
"declaration": true), this makes VS Code happy
Main script and subdirectories
This brings us to the next point, specifying the entry point in package.json:
{
"main": "out/index.js"
}
The complications start from the fact that the main file is in a subdirectory.
Let’s say that index.js exports a function named hello. You can use it in a
different package like you would expect:
import { hello } from "html-fmt-core";
Let’s say now that we have a file named Formatter.ts (compiled into
Formatter.js).
You would expect this might work:
import { whatever } from "html-fmt-core/Formatter";
but unfortunately it doesn’t. What does work is specifying the out directory:
import { whatever } from "html-fmt-core/out/Formatter";
To avoid this altogether, what I did is to re-export what I want in the
index.ts file like this:
export * from "./Formatter";
and use it as if it was part of the index file:
import { hello, whatever } from "html-fmt-core";
Note that this complication is happening because I’ve got the source code in the
src folder and the generated code in the out folder. It is also possible to
avoid this pain by simply having the code at the root level of the package (side
by side with package.json and tsconfig.json) and output the generated code
also there (with some rules in .gitignore to avoid committing it to git
accidentally).
Publishing to npm
As I said in the beginning, it’s easy to publish all lerna packages with one command. With my current setup, I will have some problems:
- It will publish not only the JavaScript code but also the TypeScript code. I
would like it to publish only the JavaScript code (together with the
declaration files for TypeScript users). This can be solved with some
.npmignorelines but I’ll do it a bit differently. - It will publish not only the code, but also the tests.
- My
READMEfile will be missing, because it’s at the root folder of the project and I don’t have (and don’t want to have) aREADMEper package.
The thing is, my JavaScript code is already nicely contained in the out
folder, so I’d like to publish just that subdirectory. Lerna
supports this,
with a very spot on description:
If you’re into unnecessarily complicated publishing, this will give you joy.
So as per the docs, I need to write a custom script that will run in the
prepack step and:
- create an artificial
package.json - copy the
README.mdfrom the project root into the generated package root
My custom prepack script looks like this:
const fs = require("fs");
fs.copyFileSync("../../README.md", "out/README.md");
if (fs.existsSync(".npmignore")) {
fs.copyFileSync(".npmignore", "out/.npmignore");
}
let packageJson = JSON.parse(
fs.readFileSync("package.json", { encoding: "utf8" })
);
packageJson.main = "index.js";
if (packageJson.bin) {
packageJson.bin = "index.js";
}
packageJson.scripts = {};
fs.writeFileSync("out/package.json", JSON.stringify(packageJson));
- It copies over the README file from the project root into the output folder of the package
- If an
.npmignorefile exists on the package root, copy it over to the out folder (I use the.npmignorefile to ignore the tests) - Create an
out/package.jsonbased on the original with the following alterations:- the main entry point will be
index.jsinstead ofout/index.js - same for the
binscript, if one exists (it exists for the CLI project) - clear out the
scriptsbecause why not
- the main entry point will be
The publish command for this is lerna publish -y --contents out from-package.
Summary
Looking back at this, perhaps the easiest way is to leave the TypeScript code at the package root and let the output JavaScript code live there too. However, if you like to have a separate source subdirectory and a separate output subdirectory, it’s definitely possible, with a bit of extra work.