Construyendo un Monorepo en Typescript utilizando pnpm
En este artículo, explicaremos qué es un Monorepo, por qué es beneficioso utilizarlo en ciertos tipos de proyectos y cómo podemos construir uno utilizando pnpm
¿Qué es un monorepo?
Un monorepo, como su nombre indica, es un repositorio único que contiene múltiples paquetes o partes de un sistema. Pensemos, por ejemplo, en una arquitectura básica de un proyecto web. Podríamos tener, por un lado, un proyecto de frontend y, por otro, un proyecto de backend. Ambos serían totalmente independientes entre sí, contando con su propio repositorio, código fuente, dependencias, etc.
Por el contrario, cuando trabajamos con un monorepo, estos dos proyectos estarían bajo el mismo repositorio. Seguirían siendo totalmente independientes en términos de despliegue y ejecución, pero tendríamos la capacidad de compartir ciertos elementos entre ellos cuando sea necesario.
¿Cuando es interesante utilizarlos?
Para ilustrar esto mejor, imaginemos que en el ejemplo anterior tenemos que extenderlo y crear un tercer proyecto. Este proyecto será otro frontend que utilizará otro tipo de usuario, por ejemplo, un panel de administración. No sería de extrañar que este nuevo frontend comparta componentes de UI con el otro frontend, ¿verdad?
¿Cómo solucionamos esto cuando trabajamos en una arquitectura multirepo? Probablemente copiando estos componentes al nuevo proyecto, lo cual nos obliga a mantener componentes idénticos en dos lugares diferentes.
Tendría mucho más sentido que esos componentes estén en algún sitio donde ambos proyectos puedan utilizarlos, sin necesidad de duplicarlos. Algo como un paquete externo que ambos proyectos importen. Pues bien, un enfoque monorepo nos habilita el hacer esto de una forma sencilla y organizada.
Cuando estos dos proyectos viven bajo el mismo monorepo, podemos añadir un tercer proyecto (o paquete) que se encargue de implementar estos componentes comunes y que sea importado por ambos frontends.
Otro ejemplo
Otro ejemplo interesante es cuando queremos compartir clases o modelos entre el frontend y el backend. Frecuentemente, en el backend se definen clases con los modelos de respuesta que los diferentes endpoints devolverán y, a su vez, en el frontend defines también modelos para gestionar la respuesta.
Estas clases probablemente sean duplicadas en ambos proyectos, por lo que tendría mucho más sentido si se encuentran en un paquete independiente que ambos proyectos puedan importar. De nuevo, un caso que una arquitectura monorepo nos permite implementar y mantener de forma sencilla.
¿Cómo puedo construir un monorepo?
Vista la teoría y la justificación de por qué es interesante este enfoque, vamos a ver cómo podemos construir uno en TypeScript utilizando la herramienta pnpm
, que es una alternativa a npm que, por su diseño y funcionamiento, encaja mucho mejor para la construcción de este tipo de arquitecturas.
Para empezar, nos ubicamos en la carpeta de trabajo y creamos un nuevo proyecto vacío utilizando pnpm
.
pnpm init
Debes tener pnpm instalado en tu máquina. En su web oficial puedes ver como instalarlo
Esto nos creará el típico package.json
en la carpeta. Hasta este punto, lo que tenemos es un proyecto de Node estándar gestionado por pnpm
. Para convertirlo a un proyecto monorepo, tenemos que crear un archivo pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
Este archivo especifica en qué rutas estarán los distintos paquetes de nuestro monorepo. En este caso, definimos dos posibles carpetas, apps
y packages
, por lo que podemos aprovechar ya para crear estas dos carpetas en nuestro proyecto, que de momento las dejamos vacías.
Hacemos esta distinción de carpetas para diferenciar las aplicaciones de los paquetes que van a ser utilizados por estas aplicaciones. Esto no es obligatorio; para el monorepo no hay distinción alguna entre lo que es una app y lo que es un paquete. La hacemos nosotros por la diferencia conceptual de lo que representan. A partir de ahora, cuando hablemos de un paquete, será aplicable a cualquiera de los dos tipos, ya que realmente, es así en la terminología utilizada en un monorepo con pnpm
Como ya hemos comentado, cada uno de los paquetes será una entidad independiente, nuevos proyectos pnpm que contarán con su package.json
, con su listado de dependencias, scripts personalizados, etc. Pero lo importante es que al estar en el monorepo, será este quien se encargue de gestionar las dependencias de cada uno de ellos. Esto quiere decir que, cuando instalamos una dependencia en alguno de los paquetes, será el monorepo quien la instalará y quien decidirá que el alcance de dicha dependencia es para dicho paquete y no para el resto. Ahora bien, ¿qué pasa si instalamos una dependencia en el paquete raíz? Recordemos que el paquete raíz no deja de ser un proyecto pnpm también, por lo que se puede instalar algo a este nivel. Pues bien, lo que pasará es que dicha dependencia estará disponible en todos los paquetes.
Por lo general, no querremos instalar dependencias en raíz, pero sí es habitual hacer alguna excepción. Por ejemplo, algo que suelo hacer es instalar TypeScript y configurar eslint a este nivel de raíz. La razón de hacer esto es que sé seguro que todos los paquetes se van a escribir en TypeScript y quiero unificar las reglas de escritura de código en todos ellos. Pero esto no tiene por qué ser así; es una decisión totalmente de la cultura de ingeniería del proyecto.
Instalando dependencias globales
Dicho lo anterior, vamos a configurar estas dependencias visibles para todos los paquetes. Para ello, simplemente nos ubicamos en la raíz del proyecto y ejecutamos:
pnpm install typescript eslint -w
Es importante destacar el uso de la flag -w. Cuando tratas de instalar algo en la raíz del monorepo, por defecto pnpm
te lanza una advertencia para que tengas en cuenta que estás en raíz. Como no es lo habitual instalar dependencias a este nivel, pnpm
considera que puede ser un error y te advierte. Con la flag -w indicas que eres consciente de ello y que quieres instalarlo en raíz, evitando que te salte el aviso. Este comportamiento también puede ser evitado estableciendo un ajuste en el proyecto, lo veremos más adelante.
Una vez hecho esto, ya podemos instalar estas dos dependencias y ver como aparecen en el package.json
de raíz. Vamos a proceder ahora a configurar eslint
, para ello vamos a utilizar el configurador que la propia dependencia nos ofrece. Pero antes de hacer esto es importante que tengamos en cuenta algo. Como decíamos antes, pnpm
bloquea las instalaciones a nivel raíz si no llevan la flag -w. El configurador de eslint
, a pesar de que sí permite la instalación usando pnpm
, no utilizará esta flag -w, por lo que no podrá finalizar correctamente. ¿Cómo podemos solucionar esto? Estableciendo un ajuste a nivel proyecto que le indique a pnpm
que ignore esta advertencia. Esto se hace creando un archivo .npmrc
e insertando la siguiente línea:
ignore-workspace-root-check=true
Con esto, la advertencia será ignorada y se podrá instalar en raíz sin la flag -w. Procedemos entonces a la configuración del eslint
:
pnpm eslint --init
El resultado de este configurador será la instalación de las dependencias necesarias y la creación del fichero .eslintrc.json
en la raíz del proyecto:
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": "standard-with-typescript",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
}
}
En este punto, tenemos todo listo para empezar a crear paquetes en el monorepo 🚀
Definiendo el proyecto
Antes de empezar con la implementación de los diferentes paquetes, vamos a explicar brevemente el proyecto que, a modo de ejemplo, vamos a abordar. Será algo muy sencillo ya que queremos centrarnos en cómo funciona el monorepo y no en la aplicación en sí misma. Por esa razón, no voy a entrar en detalle en nada referente a la implementación de la aplicación. Solo mencionar que será la típica App de lista de tareas, con un frontend hecho con React + Vite
y una API hecha con Express
, ambos proyectos en TypeScript.
Además, para ilustrar mejor como usar un monorepo, crearemos un paquete llamado core
que será consumido por ambas apps y que definirá clases o tipos que ambos puedan necesitar.
En el enlace al repo que dejo al final del artículo, podéis ver la implementación completa de cada parte
Creando el primer paquete
Vamos a empezar con la creación de la app para el frontend. Nos vamos a la carpeta apps
y creamos un proyecto en React usando Vite:
pnpm create vite@latest front -- --template react-ts
Seguimos las instrucciones del configurador para crear el proyecto React con TypeScript e instalar todos los paquetes necesarios. Si tras instalarlo, nos vamos a apps/front
y hacemos pnpm run dev
, deberíamos poder lanzar correctamente el proyecto React en ejecución. Vamos a hacer ahora algunas modificaciones para adaptarlo mejor al monorepo.
Antes que nada, vamos a decirle a Vite que utilice el puerto 3000 para nuestro frontend, en lugar de uno aleatorio como hace por defecto. Nos vamos al vite.config.ts
y agregamos el siguiente objeto server
:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
}
})
A continuación, vamos a adaptar el proyecto un poco mejor para vivir en el monorepo. La instalación por defecto de Vite configurará su propio TypeScript y eslint, pero como hemos comentado, nosotros queremos usar el del monorepo. Por tanto, lo que haremos es desinstalar localmente estas dependencias y eliminar todos los archivos. Nos vamos al package.json
y eliminamos las siguientes dependencias:
typescript
eslint
@typescript-eslint/eslint-plugin
@typescript-eslint/parser
En el momento de escribir este artículo, estas eran las dependencias relacionadas con TypeScript o eslint que Vite instalaba por defecto. Puede que en tu caso esto cambie un poco según la versión de Vite utilizada. Asegúrate de eliminar todas aquellas que tengan que ver con estas herramientas.
Una vez eliminadas en el package.json
, ejecutamos pnpm install
en apps/front
para que la desinstalación se lleve a cabo. Por último, eliminamos el archivo .eslintrc
para que no utilice la configuración de eslint
que ahí se indica. Tras hacer esto, nuestro proyecto frontend ya debería usar las reglas de eslint
definidas en raíz.
Ahora bien, es cierto que, según el tipo de proyecto TypeScript en el que estemos, quizá nos interese tener alguna configuración diferente. Por ejemplo, este paquete frontend es una aplicación con React y, en este caso, es muy común instalar el plugin de eslint para React eslint-plugin-react
. No tiene sentido que instalemos este plugin en raíz y que nuestro proyecto api
también esté expuesto a lo que dicte este plugin. Por tanto, vamos a ver cómo se puede adaptar la configuración de eslint
para cada proyecto.
Primero, instalamos a este nivel front
el paquete con el plugin mencionado.
cd apps/front
pnpm install -D eslint-plugin-react
Luego creamos otro fichero .eslintrc.json
a nivel front
y agregamos lo siguiente:
{
"extends": [
"../../.eslintrc",
"plugin:react/recommended"
],
"plugins": [
"react"
],
"rules": {}
}
Así le estamos indicando lo siguiente: por un lado, que utilice dicho plugin para React; por otro lado, que extienda la configuración que tenemos definida en raíz; y por último, que extienda también lo que nos define el propio plugin. Finalmente, al igual que hemos hecho en raíz, definimos unas reglas personalizadas, por el momento vacías.
Si utilizas VSCode y la extensión para eslint
, en este momento deberían aparecer errores en tus archivos. Por ejemplo, podemos abrir el App.tsx
y ver cómo hay varios errores de sintaxis. Esto se debe a que ahora la configuración de eslint
es la que viene de Standard, la guía de estilos que decidimos elegir cuando configuramos eslint
en raíz, extendida además por la del plugin de React que le acabamos de añadir. Entonces, tenemos que adaptar nuestro código para cumplir con esto. Tenemos dos opciones para abordarlo: arreglar los errores que nos indica o modificar las reglas definidas. Esto, de nuevo, es una decisión que has de tomar según cómo quieras afrontar tu desarrollo.
En mi caso, lo que haré será una mezcla de ambas opciones. Añadiré las siguientes tres reglas a la configuración de eslint en raíz:
{
//...
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off"
}
}
Y añadiré la siguiente a la configuración del proyecto front:
{
//...
"rules": {
"react/react-in-jsx-scope": "off"
}
}
Una vez hecho esto, corregiré el resto de errores o advertencias que eslint me indique.
En este punto, ya tenemos todo listo para crear nuestra App. Como decía anteriormente, no vamos a entrar en detalles de la implementación. Puedes ir al repo y ver cómo está hecho.
Creando otro paquete para la Api
Ahora que ya hemos visto cómo crear y adaptar un proyecto al monorepo, vamos a hacer lo mismo para la API. Como habíamos dicho, implementaremos una API usando Express.
Nos vamos a la carpeta apps
y creamos un nuevo directorio llamada api
. En este caso no usaremos ningún generador. Entramos en la carpeta api
y creamos un nuevo proyecto pnpm vacío.
cd apps
mkdir api && cd api && pnpm init
Instalamos las dependencias necesarias
pnpm add express
pnpm add -D @types/express @types/node nodemon ts-node
Básicamente, lo que estamos haciendo es instalar Express y como dependencias de desarrollo:
- Los tipos de Express y Node
- Nodemon, que es una herramienta que nos permite recargar el servidor al guardar cambios en los archivos
- ts-node, que nos permite ejecutar directamente código Typescript
Agregamos un fichero nodemon.json
en raíz para especificar como debe funcionar esta herramienta.
{
"watch": ["src"],
"ext": "ts",
"exec": "ts-node ./src/index.ts"
}
Así le estamos indicando es que debe escuchar a los archivos en la carpeta src
con extensión .ts para que, cada vez que se guarde uno de ellos, ejecute ts-node
en el archivo principal. Con eso, conseguimos ejecutar directamente la API con Typescript (ts-node se encarga por debajo de todo) y hacer que se recargue con cada cambio (nodemon se encarga)
Agregamos también el fichero tsconfig.json
para indicar como se ha de realizar el transpilado a Javascript, así como algunas reglas de linting.
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2020",
"outDir": "./dist",
"incremental": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
}
}
Por último creamos los scripts en el package.json
para realizar el dev, el build y el start.
{
// ---
"scripts": {
//---
"build": "tsc",
"start": "node dist/index.js",
"dev": "nodemon"
},
}
Creando un paquete compartido
Lo siguiente que vamos a hacer es crear el paquete core
, en el que implementaremos aquellas cosas que puedan ser comunes en ambas aplicaciones frontend y API. Creamos una nueva carpeta en packages
llamada core
y, estando en ella, creamos de nuevo un proyecto pnpm
básico.
cd packages
mkdir core
cd core
pnpm init
A continuación creamos el tsconfig.json
para especificar a Typescript como ha de realizar el compilado a Javascript.
{
"compilerOptions": {
"module": "ESNext",
"target": "ES2015",
"outDir": "./dist",
"incremental": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
}
}
Esta configuración es similar a la del proyecto API. En este caso, como sistema de módulos, usamos ESNext que lo hará más compatible para ser importado por las diferentes apps. Además, agregamos algunas propiedades que permiten generar las declaraciones de tipos y los archivos de mapas entre el código fuente y el código compilado. Esto es interesante ya que ayuda al IDE a entender la estructura del paquete y facilita cosas como ir a la definición de aquello que el paquete exporte cuando lo estamos usando en otro paquete
Creamos también un nodemon.json
para poder realizar el compilado en modo desarrollo:
{
"watch": ["src"],
"ext": "ts",
"exec": "npm run build"
}
Y finalmente, agregamos los scripts para hacer el build y el dev en el package.json
{
// ---
"scripts": {
//---
"build": "tsc -p tsconfig.json",
"dev": "nodemon"
},
}
Y con esto ya tenemos también listo este paquete para escribir el código que necesitemos.
Importando el paquete
Lo único que tenemos que hacer ahora es importar el paquete core
en las apps. Esto lo hacemos como cualquier otro paquete, nos vamos al directorio de cada una de las apps y hacemos:
pnpm add core@workspace:^
De esta forma, indicamos que instale un paquete llamado core
que se encuentra en el workspace, para que lo busque directamente del monorepo y no en repositorios externos.
Una vez hecho esto, ya podremos utilizar en nuestras apps aquellos componentes que core
exporte.
Scripts en paralelo
Para ir finalizando este artículo, vamos a ver una característica interesante de los monorepos que aún no hemos comentado: la posibilidad de ejecutar todos los scripts a la vez y en paralelo. Si nos fijamos, los tres paquetes tienen un script dev. Es muy posible que cuando estemos desarrollando, queramos tener levantados estos entornos de desarrollo en los tres paquetes. Pnpm nos permite hacer esto de una forma muy sencilla. Simplemente tenemos que ir al package.json de raíz y definir ahí el siguiente script dev:
{
// ---
"scripts": {
//---
"dev": "pnpm run --parallel dev",
},
}
Esto busca todos los scripts dev que haya en cada uno de los paquetes que conforman el monorepo y los ejecuta en paralelo.
Otra cosa interesante que nos ofrece pnpm
es la posibilidad de filtrar un script a solo ciertos paquetes. Esto se hace utilizando la flag --filter
. Esto nos permite crear scripts con las operaciones más habituales de desarrollo que podamos necesitar. Por ejemplo, podríamos configurar para levantar cada una de las apps por separado o las dos a la vez pero sin el paquete core
.
{
// ---
"scripts": {
//---
"dev": "pnpm run --parallel dev",
"dev:front": "pnpm run --filter front dev",
"dev:api": "pnpm run --filter api dev",
"dev:apps": "pnpm run --filter api --filter front dev"
},
}
Fin
Espero que este artículo sirva como una introducción para empezar a trabajar con monorepos en TypeScript usando pnpm. He tratado de realizar paso a paso la configuración que suelo utilizar para trabajar con este tipo de arquitecturas en proyectos TypeScript. Creo que puede servir como una buena base con la que empezar a construir proyectos usando este enfoque.
En posteriores artículos del blog, utilizaremos esto como base para crear ciertos proyectos y/o explicar algunas técnicas de desarrollo que suelo utilizar en mi día a día.
Por último, dejo por aquí el link al repositorio en github en el que está implementado todo lo que hemos ido haciendo en este artículo.