Build Yarn Monorepo with Workspaces š
Lucas Martin
Posted on April 24, 2023
Hi everyone ! Today I'll try to help you setting up your own monorepo using Yarn Workspaces, Express.js & Webpack.
š¦ Source Code : https://github.com/0x2A-git/monorepo-tutorial
š„ Video :
Goal ā½
Building reusable code across our applications. Following this tutorial, you should end up with this project structure :
.
|-- apps/
| [YOUR APPS]
`-- packages/
[YOUR SHARED PACKAGES]
Initialization šļø
Create a new folder that will contain your monorepo and cd
into it.
First we'll have to upgrade Yarn to the Berry stable version let's run :
$ yarn set version stable
Then check your version with :
$ yarn --version
It should display a version number like 3.x.x.
You can optionally set the nodeLinker to node-modules, it prevents Yarn PnP from being used ( had some troubles with it in the past :S ) :
$ yarn config set nodeLinker node-modules
It's now time to initialize our monorepo ! Let's run these two commands :
$ yarn init
$ yarn
Next we'll define which folders will act as our workspaces, open your package.json and add these attributes :
...
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
...
Great, now we can create these folders :
.
|-- .yarn/
|-- apps/ +
|-- node_modules/
|-- packages/ +
|-- .editorconfig
|-- .gitattributes
|-- .gitignore
|-- .yarnrc.yml
|-- package.json
|-- README.md
`-- yarn.lock
Applications š±
It is now time to initialize our applications we'll have a Back-end called back and a Front-end called web :
$ cd apps
$ mkdir back web
Back setup š¤
Let's start with the back :
$ cd back
$ yarn init
$ yarn add -D @types/express nodemon ts-node typescript
$ yarn add express
$ mkdir src
$ touch src/main.ts tsconfig.json
In the tsconfig.json file you can paste this configuration :
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./src",
"outDir": "./dist"
},
"include": ["./**/**.ts"]
}
Then in src/main.ts you can paste this code in order to have minimal HTTP server setup that you can run :
import express, { Router } from 'express'
const app = express()
const router = Router()
router.get('/person', (req, res) => {
// TODO
})
app.use('/api', router)
app.listen(3000, () => {
console.log('Server is running !')
})
Web setup š„ļø
$ cd ../web
$ yarn init
$ yarn add -D compression-webpack-plugin ts-loader typescript webpack webpack-cli webpack-dev-server
$ touch tsconfig.json webpack.config.js webpack.dev.js
$ mkdir src
$ touch src/index.ts
$ mkdir public
$ touch public/index.html
You can paste this configuration in tsconfig.json :
{
"compilerOptions": {
"lib": ["es6", "DOM"],
"module": "commonjs",
"target": "es6",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./src",
"outDir": "./dist"
},
"include": ["./**/**.ts"]
}
As for webpack we've created two files :
- webpack.dev.js - serve the content on a dev server as we develop
- webpack.config.js - generate a bundle for our production code.
webpack.dev.js content :
const path = require('path');
module.exports = {
entry: './src/index.ts',
mode: 'development',
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
proxy: {
'/api': 'http://localhost:3000',
},
compress: true,
port: 8080,
devMiddleware: {
index: true,
mimeTypes: { phtml: 'text/html' },
},
},
devtool: 'inline-source-map',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist/'),
publicPath: '/dist/',
},
};
webpack.config.js content :
const path = require('path');
const CompressionPlugin = require('compression-webpack-plugin');
module.exports = {
entry: './src/index.ts',
mode: 'production',
module: {
rules: [
{
test: /\.ts?$/,
use: 'ts-loader',
exclude: /node_modules/,
},
],
},
plugins: [
new CompressionPlugin(),
],
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist/'),
publicPath: '/dist/',
},
};
Here's a default HTML template to paste in public/index.html :
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="/dist/bundle.js"></script>
</body>
</html>
Packages š¦
We are going to create our first package, cd
into the packages folder of your monorepo
entities
package š§
$ mkdir entities
$ cd entities
$ yarn init
$ touch tsconfig.json
$ mkdir src
$ touch src/person.ts src/index.ts
Content of tsconfig.json :
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "./src",
"outDir": "./dist",
"declaration": true,
"declarationMap": true
},
"include": ["./**/**.ts"]
}
In entities' package.json we'll add the main and typings attributes to locate our js files & typings.
I will also rename the package to @shared/entities, it will prevent conflicts with remote packages :
{
"name": "@shared/entities",
"packageManager": "yarn@3.5.0",
"main": "./dist/index.js",
"typings": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^5.0.4"
}
}
Let's define the Person class in src/person.ts :
class Person {
private firstName: string | null = null;
private lastName: string | null = null;
public constructor(firstName: string, lastName: string) {
this.firstName = firstName
this.lastName = lastName
}
public toString(): string {
return `${this.lastName} ${this.firstName}`
}
}
export { Person }
Don't forget to export it in src/index.ts :
export { Person } from './person'
Finally compile your package by running :
$ yarn run tsc
Import entities
package in your apps š„ļø
Back
First cd
into apps/back folder
Then edit package.json as follows :
...
"dependencies": {
"@shared/entities": "workspace:^",
...
},
...
The syntax workspace:
tells Yarn to look for the package in your monorepo and prevents Yarn from trying to fetch it from the registry.
Link the package to your back app by running :
$ yarn
Alright, we can edit src/main.ts and code the missing endpoint :
import express, { Router } from 'express'
import { Person } from '@shared/entities'
const app = express()
const router = Router()
router.get('/person', (req, res) => {
const person = new Person('John', 'Doe')
return res.status(200).json(person)
})
app.use('/api', router)
app.listen(3000, () => {
console.log('Server is running !')
})
Compile the project and run it :
$ yarn run tsc
$ node dist/main.js
Open your browser and go to http://localhost:3000/api/person, you should see the following response :
Web
Let's cd
into apps/web, then edit package.json :
...
"dependencies": {
"@shared/entities": "workspace:^"
},
...
Run yarn to link package :
$ yarn
Edit public/index.html to add a span tag that will be used to display the person's fullname ( or add the HTML tag dynamically through JS ) :
<body>
...
<span id="person"></span>
...
</body>
Then edit src/index.ts to retrieve JSON data from the person's endpoint running on our server :
import { Person } from "@shared/entities"
const displayPerson = async () => {
const response = await fetch('http://localhost:8080/api/person', {
method: 'GET'
})
const jsonResponse = await response.json()
const person: Person = new Person(jsonResponse.firstName, jsonResponse.lastName)
const personSpan: HTMLSpanElement | null = document.body.querySelector("#person")
personSpan && (personSpan.innerText = `Welcome ${person.toString()} !`)
}
displayPerson().then(() => {
console.log('Person displayed !')
})
Run webpack to serve your front :
$ yarn run webpack serve --config=./webpack.dev.js
Open your browser and go to http://localhost:8080 you should now see this page appear :
Finally you can bundle your code by running :
$ yarn run webpack
This will output a dist/ folder with your bundle inside.
Congratulations you now have a working monorepo š
Optional : Add scripts š
In packages/entities/package.json add the following lines :
...
"scripts": {
"build": "tsc",
"dev": "tsc --watch"
}
...
Add these lines to apps/back/package.json :
...
"scripts": {
"build": "tsc",
"dev": "nodemon ./src/main.ts"
}
...
Then paste the following lines in apps/web/package.json :
...
"scripts": {
"build": "webpack",
"dev": "webpack serve --config=./webpack.dev.js"
}
...
Finally in the root folder of the monorepo edit the package.json add these lines :
"scripts": {
"back:build": "yarn workspace back run build",
"back:dev": "yarn workspace back run dev",
"web:build": "yarn workspace web run build",
"web:dev": "yarn workspace web run dev"
}
You're now able to run yarn run back:build
, yarn run back:dev
, yarn run web:build
and yarn run web:dev
from wherever you are in your monorepo !
Conclusion
You are now able to use packages across your applications.
Are you wondering about building a CI / CD with Yarn Workspaces ? You should take a look at yarn workspaces focus it allows you to install packages for a single app / package hence decreasing your jobs execution time šš»
If you have any question about this blog post feel free to ask it in the comments.
Have a nice day :)
Posted on April 24, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.