Setting up our monorepo with Turborepo, pnpm, React Native, Expo, and NestJS
Overview
In this post we will go over our project setup and build. We will be building both a mobile app using React Native with Expo and an API service using NestJS. For the purpose of this project I have chosen to use a monorepo where both the mobile app and service code will live. Within our monorepo, we will modularize our artifacts from the start. This makes it easier to reuse shared code amongst our different applications, to split up CI/CD, and if we ever want to split into a multi-repo, we will have clear boundaries already in place.
Turborepo and pnpm - A winning combination
To enable a fast build with multiple artifacts all living in the same repo, Turborepo and pnpm are some of the top choices.
pnpm is a replacement for npm with a faster build that also takes up less disk space due the the way it shares package files. Its workspace feature provides the ability to make one build artifact depend on another artifact in the same repo without the need to publish it as an npm package first.
Turborepo helps speed up and scale monorepo development by both parallelizing and caching the results of different CI steps so that you only build what has been changed. It does so by building a dependency graph between artifacts and CI tasks which you can tune as needed. As your project gets larger and larger this is a place where the time savings can really add up. These caches can even be configured to be stored remotely which is useful in an ephemeral CI environment.
Our shared artifacts
packages/eslint-config
Lint config for react-based projects.
packages/eslint-config-backend
Lint config for backend projects.
packages/tour-common
Shared types across all layers. For example, shared types returned from the tour-service apis.
packages/tsconfig
Shared typescript config.
Our application artifacts
Another common conventions is to place all of our applications go in the apps directory. The build artifacts in the apps directory will not be made as dependencies for any artifacts in the repo, however they will most likely depend on one or more artifacts from the packages directory. The autotoor project will have the following two apps:
apps/tour-service
The tour-service NestJS backend service.
apps/mobile
The autotour mobile application
Installing pnpm
Installing pnpm: https://pnpm.io/installation
Since I am using a mac I installed using brew:
brew install pnpmInstalling Turborepo
pnpm install turbo --global
A deep-dive into the build
- turbo is included as a dev dependency in the devDependencies section.
- Nearly all of the scripts point to turbo. This allows turbo to call the relevant scripts in the other packages in the repo and manage the caching of all of their results.
- Some of the scripts use a --filter option. This is used to limit which dependency tree is considered when building with a given command. More details on filtering are available here.
- There is a pnpm section with some peer dependency rules. This is used to suppress some warnings about missing peer dependencies. We have these warnings since we need to use hoisted node modules in order for our react-native project to work properly.
package.json
{
"private": true,
"name": "@autotoor/monorepo",
"scripts": {
"clean": "turbo run clean && rm -rf node_modules",
"dev": "turbo dev",
"dev:mobile": "turbo dev --filter=\"{./apps/mobile}...\"",
"dev:tour": "turbo dev --filter=\"{./apps/tour-service}...\"",
"lint": "turbo lint",
"test": "turbo test",
"build": "turbo build",
"build:mobile": "turbo build --filter=\"...{./apps/mobile}\"",
"build:tour": "turbo build --filter=\"...{./apps/tour}\"",
"build:docker:tour": "docker build -t tour-service . -f ./apps/tour-service/docker/Dockerfile"
},
"devDependencies": {
"turbo": "^1.10.7",
"typescript": "^4.9.5"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"@babel/*",
"expo-modules-*",
"typescript"
]
}
}
}Defining the pnpm Workspace
In pnpm, an entire project which has a pnpm-workspace.yaml file at its root is referred to as a workspace. Individual build artifacts that can be shared in that workspace are referred to as packages. Each package has its own package.json at its root. The pnpm-workspace.yaml file is used to define the workspace root as well as which directories to look in for packages to include/exclude from the workspace. All packages included in the workspace are available to be imported into other packages in the workspace.
Below is the pnpm-workspace.yaml from our project. Some things to note:
- By including 'apps/*' the * denotes that only packages that are direct children of the apps directory will be included. In our repo, this is where we will be putting our applications.
- By including 'packages/**' the ** denotes that packages in any subdir of packages will be included. In our repo, this is where we will be putting our artifacts that are to be imported by one-another and by our applications.
pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/**'
Defining the build pipeline with Turborepo
We use Turborepo to define our build pipeline. While pnpm refers to the whole project as a workspace and each of the individual build artifacts as packages, Turborepo calls the whole project a workspace and also calls the individual artifacts workspaces as well. The turbo.json file is where our Turborepo config lives. This is where we use the pipeline directive to define all of the tasks in our build pipeline: lint, test, build, dev, clean.
Turborepo matches the task names to script names in the individual packages/workspaces in the project workspace. For example, in our repo, every package has a build script. Calling turbo build from the root of our workspace will result in the build script in each of our packages being run. The benefit of using Turborepo to do so is that it will build a dependency graph across all of our packages and then use it to parallelize the build of the different packages whenever possible. It will also cache the results of each task execution and only run the ones need to change each time the task is called. For example calling turbo build if no code has changed since the last time it was called will not do anything.
In order to know which tasks to run and when, turborepo allows the definition of dependencies using the dependsOn directive (in depth explanation can be found here). If you look at our turbo.json file below you will notice that sometimes our dependsOn contains "build" and other times it contains "^build". Why is that?
A dependency task name without the caret symbol "^" preceding it indicates that it is the name of a task in the same workspace. For example, you will notice in the definition of the test task it depends on "build" without a caret, so that is essentially saying that running the test task in a workspace requires that that workspace's build task has been run first.
When a dependency task is preceded by the caret symbol "^" this indicates that the name of the task it precedes is the name of a task in a workspaces that the current workspace depends on. For example the build task in the pipeline defined below depends on "^build" which means that the build task for each workspace requires that the build task of any workspace it depends on to be run first. This ensures that all dependencies are built in order.
Turborepo then uses dependsOn directive to build out a dependency graph which enables it to know which tasks can be parallelized and which need to run serially. It also uses this to determine what needs to be rebuilt when a workspace has changes vs using a cached value.
turbo.json
{
"$schema": "https://turborepo.org/schema.json",
"pipeline": {
"lint": {
"outputs": []
},
"test": {
"dependsOn": ["build"],
"inputs": ["**/*.{ts,tsx,js,jsx}"]
},
"build": {
"dependsOn": ["^build"],
"outputs": [
".next/**",
"build/**",
"dist/**",
"node_modules/.cache/metro/**"
]
},
"dev": {
"cache": false,
"persistent": true
},
"clean": {
"cache": false
}
}
}
Module hoisting to support react-native
node-linker=hoistedIncluding one package in another
{
"name": "@autotoor/tour-common",
"version": "1.0.0",
"description": "Common Tour Types",
"files": [
"dist"
],
"main": "dist/index.js",
"typings": "dist/index.d.ts",
"scripts": {
"type-check": "tsc",
"lint": "NODE_OPTIONS=\"--max-old-space-size=5120\" eslint --ext .ts,.tsx src",
"build": "tsc -b",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
"test": "NODE_ENV=test jest --passWithNoTests"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@autotoor/eslint-config-backend": "*",
"@autotoor/tsconfig": "*",
"jest": "29.5.0",
"ts-jest": "29.1.0",
"typescript": "^5.0.4"
}
}
{
"name": "tour-service",
...
"dependencies": {
"@autotoor/tour-common": "*",
"@nestjs/axios": "^2.0.0",
...
},
"devDependencies": {
"@autotoor/eslint-config-backend": "*",
"@autotoor/tsconfig": "*",
"@nestjs/cli": "^9.0.0",
...
}
}Turbo build tasks vs package-level scripts
{
"name": "tour-service",
...
"scripts": {
"build": "nest build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand"
},
...
}Is a monorepo right for your project?
For a demo project like this a monorepo is a great choice since all of the code can be located in one place, but does that hold true for a real project? Like anything in software, the answer is "It depends...". At the start of a project, a monorepo is probably the easiest choice. However, when your company and the number of services you have grows, so does your repo, along with the number of people competing for merges into your main branch. This can lead to a whole new set of problems. Even if you do decide you need separate repos for different services or teams, it may still be useful to produce multiple artifacts out of each of those separate repos. For example, the tour-service could publish an npm with some common types or client utils to be imported by other service repos that it also then depends on itself. In such a case, using the monorepo setup along with pnpm and turbo in each of the separate service repos would still provide a lot of value.
Comments
Post a Comment