Software Tech

Create a monorepo using PNPM workspace

Objective To create a monorepo using PNPM package manager and it's workspace feature. The main advantage of PNPM workspace

Create a monorepo using PNPM workspace

Objective

To a monorepo using PNPM and it's workspace feature.

The main advantage of PNPM workspace when compared to the workspace is common packages are not hoisted to the root thereby making all the workspace packages completely isolated.

Technologies/Features used

The monorepo we are going to build will have the following features. Again this is my set of tools feel free to change it based on your preference.

Feature
Technology used

Package manager
PNPM

Programming
Typescript

linting
ES Lint

Code formatting
Prettier

Pre-commit hook validator
Husky

Linting only staged files
lint-staged

Lint git commit subject
commitlint

Prerequisites

Tools

You will need the following things properly installed on your computer.

Git
Node.js

PNPM install

If you have installed latest v16.x or greater node version in your system, then enable the pnpm using the below cmd

corepack enable
corepack prepare pnpm@latest –activate

If you are using lower version of node in your local system then check this page for additional installation methods https://pnpm.io/installation

Repo basic setup

Initialise git if you want and enforce the node version with some info in the .md.

mkdir pnpm-monorepo
pnpm-monorepo
pnpm init
git init
echo -e “node_modules” > .gitignore
pkg set engines.node=“>=18.16.1” // Use the same node version you installed
echo “#PNPM monorepo” > README.md

Code formatter

I'm going with Prettier to format the code. Formatting helps us to keep our code uniform for every developer.

Installation

Let's install the plugin and set some defaults. Here I'm setting the single quote to be true, update it according to your preference.

pnpm add -D prettier
echo ‘{n “singleQuote”: truen}' > .prettierrc.json
echo -e “.huskyn.vscoden.gitncoveragendistnnode_modulesnpublicnpackage.jsonnpnpm-.yaml” > .prettierignore

I also took some liberty in including some of the packages path in the prettier ignore file that we are going to install and use later for convenience.

VS Code plugin

If you are using VS Code, then navigate to the Extensions and search for Prettier – Code formatter and install the extension.

Extension link: https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode

Let's update the workspace to use the prettier as the default formatter and automatically format the file on save.

Create the workspace settings json and update it with the following content.

mkdir .vscode && touch .vscode/settings.json
{
“editor.formatOnSave”: true,
“editor.defaultFormatter”: “esbenp.prettier-vscode”,
}

Linting

Linter statically analyses your code to quickly find problems. ES Lint is the most preferred tool for linting the Javascript code.

EsLint

pnpm create @eslint/config

The eslint will ask you a set of questions to set up the linter as per your needs.
This is the configuration I've chosen for this project.

? How would you like to use ESLint? …
To check syntax only
❯ To check syntax and find problems
To check syntax, find problems, and enforce code style

? What type of modules does your project use? …
❯ JavaScript modules (import/export)
CommonJS (require/exports)
None of these

? Which framework does your project use? …
React
Vue.js
❯ None of these

Does your project use TypeScript? › No / Yes
– Yes

Where does your code run?
✔ Browser
Node

? What format do you want your config file to be in? …
❯ JavaScript
YAML
JSON

The config that you`ve selected requires the following dependencies:
@typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest

? Would you like to install them ? › No / Yes
– Yes

? Which package manager do you want to use? …
npm
yarn
❯ pnpm

Set the root property to true in the ES Lint config. This will make sure the linting config bubbling is stopped here.

module.exports = {
root: true,

}

Create the eslintignore file to let the eslint know which files to not format.

touch .eslintignore
echo -e “.huskyn.vscoden.gitncoveragendistnnode_modulesn*.config.tsn.eslintrc.cjsnpackage.jsonnpnpm-lock.yaml” > .eslintignore

Integrating Prettier with ESLint

Linters usually contain not only code quality rules, but also stylistic rules. Most stylistic rules are unnecessary when using Prettier, but worse – they might with Prettier!

We are going to use Prettier for code formatting concerns, and linters for code-quality concerns.

Install the necessary plugins

pnpm add -D eslint-config-prettier eslint-plugin-prettier

Add the plugin:prettier/recommended as the last element in the extends property in eslintrc.js

module.exports = {
extends: […, plugin:prettier/recommended],
}

For more info on this: https://prettier.io/docs/en/integrating-with-linters.html

Let's create scripts for running the linter and prettier in the package.json file.

npm pkg set scripts.lint=“eslint .”
npm pkg set scripts.format=“prettier –write .”

Run the pnpm lint cmd to run the ESLint and pnpm format cmd to format the files.

Pre-commit hook validation

Even if we added all these linter and formatter mechanisms to maintain the code quality, we can't expect all the developers to use the same editor and execute the lint and format command every time when they are pushing their code.

To automate that we need some kind of pre-commit hook validation. That's where husky and lint-staged plugins come in handy let's install and set them up.

Install the husky, commitlint and lint-staged NPM package and initialise it as shown below,

pnpm add -D @commitlint/cli @commitlint/config-conventional
echo -e “module.exports = { extends: [‘@commitlint/config-conventional'] };” > commitlint.config.cjs
pnpm add -D husky lint-staged
npx husky install
npx husky add .husky/pre-commit “pnpm lint-staged”
npx husky add .husky/commit-msg ‘npx –no — commitlint –edit ${1}'
npm pkg set scripts.prepare=“husky install”

Update the package.json file and include the following property. This will run the ESLint on all the script files and prettier on the other files.

“lint-staged”: {
“**/*.{js,ts,tsx}”: [
“eslint –fix”
],
“**/*”: “prettier –write –ignore-unknown”
},

Workspace config

Create pnpm-workspace.yaml file and add the following content

touch pnpm-workspace.yaml
packages:
apps/*'
packages/*'

Create the apps and packages directories in the root.

mkdir apps packages

Sample package – Common

Let's create a sample package that can be used in the workspace apps.

cd packages
pnpm create vite common –template vanilla-ts
cd ../
pnpm install
npm pkg set scripts.common=“pnpm –filter common”

Clean up the sample files and create a simple isBlank util.

Update the main.ts file with the following content,

/* eslint-disable @typescript-eslint/no-explicit-any */
export const isEmpty = (data: any) => data === null || data === undefined;

export const isObject = (data: any) => data && typeof data === object;

export const isBlank = (data: any) =>
isEmpty(data) ||
(Array.isArray(data) && data.length === 0) ||
(isObject(data) && Object.keys(data).length === 0) ||
(typeof data === string && data.trim().length === 0);

Delete the sample files

cd packages/common
rm -rf src/style.css src/counter.ts

Library mode

Vite by default build the assets in app mode with index. as the entry file. But we want our app to expose our main.ts file as the entry file, so let's update the Vite config to support that.

Before that let's install the Vite package to auto generate the type definitions from the library.

pnpm common add -D vite-plugin-dts

Create the vite.config.ts file and update it like this,

touch vite.config.ts
import { defineConfig } from vite;
import { resolve } from path;
import dts from vite-plugin-dts;

// https://vitejs.dev/config/
export default defineConfig({
build: { lib: { entry: resolve(__dirname, src/main.ts), formats: [es] } },
resolve: { alias: { src: resolve(src/) } },
plugins: [dts()],
});

The resolve property helps us to use absolute import paths instead of relative.

For ex:

import { add } from src/utils/arithmetic

Update the common package package.json file with the entry file for our script as well as the typings.

{
,
“main”: “./dist/common.js”,
“types”: “./dist/main.d.ts”,
}

Sample app – Web app

Let's create a sample app that can make use of the workspace package common.

cd apps
pnpm create vite web-app –template react-ts
cd ../
pnpm install
npm pkg set scripts.app=“pnpm –filter web-app”

Install the common package as a dependency in our web app by updating the web-app package.json.

“dependencies”: {
“common”: “workspace:*”,
,
}

Run pnpm install again so that web-app can symlink the common package present in the workspace and run pnpm common build so that the common package can be found by the web-app server.

Update the App.tsx like below,

import { isBlank } from common;

const App = () => {
return (

p>undefined isBlank {isBlank(undefined) ? true : false}/p>
p>false isBlank {isBlank(false) ? true : false}/p>
p>true isBlank {isBlank(true) ? true : false}/p>
p>Empty object isBlank {isBlank({}) ? true : false}/p>
/>
);
};

export default App;

Run pnpm app dev and check whether the common package util is successfully linked to the app.

That's it.. We have successfully created a PNPM monorepo from with typescript support.

Dev mode

Most of the time, you just need to build the common package once and use it in the repo apps. But if you are actively making changes in your common package and want to see that in the web-app immediately you can't build the common app again and again for every change.

To avoid this, let's run the common package in watch mode so that any change in the code will rebuild automatically and reflect in the web-app in realtime.

Run these commands in different terminal.

pnpm common build –watch
pnpm web-app dev

Advantages:

All your code will be in one single repo with proper isolation.
Only one time effort is needed to setup the repo with proper linting, formatting and pre-commit hook validations all the packages will extend the same configuration.
All the packages will have the similar setup, look and feel.

Tips:

Checkout my blog on creating a TS Util library and React app for creating the repo packages with all the bells and whistles. Ignore the prettier, pre-commit hook validations in those packages as they are already handled in the root workspace of this monorepo.

For linter, if you are good with the basic linting present in the root workspace you don't have to do anything special in the package. However, for apps like react we will have some more plugins dedicated to lint the react library.

Ex:

module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
eslint:recommended,
plugin:@typescript-eslint/recommended,
plugin:react-hooks/recommended,
],
ignorePatterns: [dist, .eslintrc.cjs],
parser: @typescript-eslint/parser,
plugins: [react-refresh],
rules: {
react-refresh/only-export-components: [
warn,
{ allowConstantExport: true },
],
},
};

You can keep it like this itself or else you can make it extend the root by simply removing the root property and remove the duplicates.

module.exports = {
extends: [plugin:react-hooks/recommended],
plugins: [react-refresh],
rules: {
react-refresh/only-export-components: [
warn,
{ allowConstantExport: true },
],
},
};

Sample repo

The code for this series is hosted in Github here

Please take a look at the Github repo and let me know your feedback, queries in the comments section.

About Author

Vinodh Kumar

Leave a Reply

SOFAIO BLOG We would like to show you notifications for the latest news and updates.
Dismiss
Allow Notifications