UPDATE: I have since tried Turborepo and Nx. And both of those are probably better than this solution.
The information in this article is still valid, but probably not the best advice I can give.
This article is the first in a three-part series about monorepos.
We'll be looking at setting up a monorepo and use the following tools: Typescript, Lerna, Yarn Workspaces, Webpack, Nodemon. If my choices are not to your liking I've linked some articles in the notes section of this article.
If you don't want to read and want to skip stright to the code you can find the repository here.
What are we building?
We're going to be creating a monorepo for a fake dice roll application. So we'll have the following packages:
diceroll
- main logicapi
ui
Workspaces & Lerna
We will be using Lerna and Yarn Workspaces to help in creating, bootstrapping and running commands across our packages. As usual, it's a good idea to read the documentation. If a concept isn't explained here (and it probably isn't) the documentation should help.
The first thing we'll do is create a package.json
for our monorepo
// package.json
{
"name": "monorepo-template",
"version": "1.0.0",
"private": true,
"workspaces": ["packages/*"]
}
Install Lerna using
yarn add lerna -D -W
Create a lerna.json
// lerna.json
{
"packages": ["packages/**"],
"version": "independent",
"npmClient": "yarn",
"useWorkspaces": true
}
yarn add typescript -D -W
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node",
"target": "es2015",
"composite": true
}
}
These will serve as a base configuration for our packages so we don't have to repeat ourselves too much. Feel free to adjust this to your preferences. I've opted to use Project References to improve build times. You can chose to not use it if you so desire.
So far our project looks like this:
monorepo/
├─ lerna.json
├─ package.json
├─ tsconfig.json
Create Packages
Now it's time to create our three packages. First we'll create diceroll
and ui
packages.
Our goal is to end up with something that looks like:
monorepo/
├─ packages/
│ ├─ diceroll/
│ │ ├─ src/
│ │ │ ├─ index.ts
│ │ ├─ package.json
│ │ ├─ tsconfig.json
│ ├─ ui/
│ │ ├─ src/
│ │ │ ├─ index.ts
│ │ ├─ package.json
│ │ ├─ tsconfig.json
├─ lerna.json
├─ package.json
├─ tsconfig.json
Diceroll
The diceroll
package contains the core business logic and is going to not have any internal dependencies (actually any dependencies whatsoever). The following files do not contain anything of interest and are here to pad the reading time.
// packages/diceroll/package.json
{
"name": "@monorepo/diceroll",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"private": true,
"scripts": {
"build": "tsc -b"
}
}
// packages/diceroll/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"exclude": [
"node_modules",
"dist"
]
}
// packages/diceroll/src/index.ts
export function roll(roll: string): string {
return `I rolled a dice: ${roll}. Outcome grim`;
}
UI
First off, let's test out our wiring so let's keep this simple.
// packages/ui/package.json
{
"name": "@monorepo/ui",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc -b"
},
"dependencies": {
"@monorepo/diceroll": "^1.0.0"
}
}
We specify ES2019
as our target, feel free to change this to whatever you desire and add the dom
definitions. Do note the references
section. This tells Typescript that we will be referencing those files and when those change we will want to check/compile our code. This means we catch breaking changes fast.
// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"target": "ES2019",
"lib": [ "dom", "ES2019"]
},
"references": [
{ "path": "../diceroll" }
],
"exclude": [
"node_modules",
"dist"
]
}
// packages/ui/index.ts
import { roll } from '@monorepo/diceroll';
console.log(roll('1d20'));
Now I'm sure that as soon as you typed this into a file, your IDE started yelling at you that the package doesn't exist. It's correct. It doesn't yet exist! We have yet to build it.
Let's first ensure our dependencies are installed and linked by running:
lerna bootstrap
Now, let's build everything in one go:
lerna run build --stream
You'll notice a new file called tsconfig.buildinfo
in each package folder. This is where Typescript stores some information about the compile process for each package. You may have seen this before if you used the incremental
option.
Great, now let's add a clean
script to all our packages and one in our root package.json.
// packages/diceroll/package.json
"scripts": {
"build": "tsc -b",
+ "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
},
"devDependencies": {
// package.json
"files": [
"dist"
],
+ "scripts": {
+ "build": "lerna run build --stream",
+ "clean": "lerna run clean --parallel"
+ },
"devDependencies": {
"lerna": "^3.22.1",
"typescript": "^4.1.3"
Watch mode
What's a good build system if it doesn't have watch mode? Let's add it to the packages:
// packages/diceroll/package.json
"scripts": {
"build": "tsc -b",
"clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
+ "watch": "tsc -b -w --preserveWatchOutput"
},
"devDependencies": {
So let's take a step back and see what we have achieved thus far:
- We can have an arbitrary number of packages which work together
- Typescript compiles as fast as it can and we have a watch mode too
- We have a cleanup script should we ever need it
But our UI doesn't work, it's not a UI!
UI - for real!
Let's add Webpack. We have two choices. Either we run:
yarn add --dev html-webpack-plugin webpack webpack-cli webpack-dev-server
or
lerna add --scope=@monorepo/ui -D webpack
lerna add --scope=@monorepo/ui -D webpack-cli
lerna add --scope=@monorepo/ui -D webpack-dev-server
lerna add --scope=@monorepo/ui -D html-webpack-plugin
Now let's scaffold some files:
// packages/ui/src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>DiceRoll</title>
</head>
<body>
<div id="result"></div>
</body>
</html>
// packages/ui/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = function(env, argv) {
return {
mode: env.production ? 'production' : 'development',
devtool: env.production ? 'source-map' : 'eval',
devServer: {
open: true,
historyApiFallback: true
},
entry: {
index: './build/index.js',
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
]
}
};
If you were paying attention and not blindly copying you would have noticed the build
folder. Follow on.
// packages/ui/tsconfig.json
"extends": "../../tsconfig.json",
"compilerOptions": {
- "outDir": "dist",
+ "outDir": "build",
"rootDir": "src",
// Omitted for brevity
"exclude": [
"node_modules",
+ "build",
"dist"
]
Why are we doing this? It's because our final build artifacts bundled by Webpack not Typescript. We'll have to update our scripts to reflect this.
// packages/ui/package.json
"scripts": {
- "build": "tsc -b",
- "clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
- "watch": "tsc -b -w --preserveWatchOutput"
+ "start": "webpack serve",
+ "build": "yarn compile && webpack --env production",
+ "clean": "rm -rf ./dist && rm -rf ./build && rm tsconfig.tsbuildinfo",
+ "watch": "tsc -b -w --preserveWatchOutput",
+ "compile": "tsc -b"
},
This is where I'm going to do the first unorthodox thing: I won't use Babel or a typescript loader such as awesome-typescript-loader
or ts-loader
.This is so we have a better understanding of the role each tool has. Feel free to add these if you want them.
API
Ok great, we have a UI, but we also want an API! Let's get cracking!
For our API we'll use Nodemon to restart out application. We won't use ts-node
. In my experience, ts-node
is slow so I've decided to skip it this time.
Let's start by copying package.json
and tsconfig.json
from the diceroller
folder and applying the following modifications.
// packages/api/package.json
"private": true,
"scripts": {
"build": "tsc -b",
+ "start": "nodemon --inspect dist/index.js",
"clean": "rm -rf ./dist && rm tsconfig.tsbuildinfo",
"watch": "tsc -b -w --preserveWatchOutput"
},
// packages/api/tsconfig.json
"outDir": "dist",
"rootDir": "src",
+
+ "module": "commonjs",
+ "target": "ES2019",
+ "lib": [ "ES2019" ]
},
+ "references": [
+ { "path": "../diceroll" }
+ ],
Let's add our packages
lerna add --scope=@monorepo/api express
lerna add --scope=@monorepo/api -D nodemon
lerna add --scope=@monorepo/api -D @types/express
How do we start our api? This is where some people will groan. You have to run two commands: watch
and start
. You can add a helper like concurrently
if you so desire. But for the purposes of this example we will stick to running two commands manually.
Closing statements
We have created a monorepo using the minimum amount of tools. We have a stable, working base that provides a good developer experience. In part two of this series will focus on adding Jest, ESLint and more to our monorepo. Stay tuned!