El desarrollo de software no es una tarea sencilla, sobre todo cuando el proyecto es grande o lo desarrollan varias personas. En Node.js, debido a las dependencias entre paquetes locales en desarrollo, se complica un poco más. Por lo que necesitamos alguna forma de desarrollo que sea fácil de aprender, de usar y que proporcione versatilidad y mejore la productividad, eficiencia y mantenimiento. Además, debe facilitar o encajar perfectamente con el uso de CI/CD. Y para esto se utilizan los monorepos y, en caso de Node.js, con npm en particular, con el uso de los espacios de trabajo (workspaces).

Al finalizar, sabrá:

  • Qué es un monorepo y por qué se usan.

  • Cómo usar npm y Lerna para el desarrollo con monorepos.

  • Un ejemplo de uso de los propietarios de código de GitHub.

  • Propiedades típicas que deben aparecer en el archivo package.json de un paquete atendiendo a su tipo.

Introducción

Un repositorio (repository), o repo, no es más que un lugar donde almacenar nuestro trabajo y registrar los cambios. Cuando un proyecto dispone de varios paquetes, es importante organizar su desarrollo. Para estos casos, podemos usar dos enfoques, el uso de monorepos o los repos múltiples.

El desarrollo mediante monorepo (monorepo-style development) es una manera de desarrollar proyectos que se implementan en varios paquetes relacionados, cada uno con su propias responsabilidades y funcionalidades. La idea es desarrollar varios paquetes relacionados, ya sean todos los del proyecto o algunos de ellos, mediante un único repositorio. Estos paquetes del repositorio suelen depender unos de otros y otros de unos. Esto es algo muy importante, al menos en Node.js, porque la gestión de dependencias de paquetes bajo desarrollo puede llegar a generar grandes dolores de cabeza y, en ocasiones, provocar mucha frustración.

En cambio, el desarrollo mediante múltiples repos (multirepos-style development) consiste en dedicar un repositorio a cada módulo independiente.

La diferencia es muy sencilla. Con los monorepos utilizamos un único repositorio para varios paquetes. Mientras que con multirepos utilizamos un repositorio para cada paquete. Esto, que puede parecer intrascendente, es de mucha importancia cuando se detecta un fallo o bug. Si estamos trabajando mediante monorepos, el fallo se corregirá y se arreglará para todo el contenido del monorepo, pudiendo, en ocasiones, saltar de inmediato a otros paquetes desarrollados dentro de ese mismo monorepo. Esto hará que se tenga que resolver de inmediato. En cambio, si lo hacemos en repositorios distintos, habrá que ir a cada repositorio, actualizar la dependencia y volver a ejecutar sus pruebas para, así, poder detectar si la corrección del fallo ha tenido algún efecto colateral o secundario en esos paquetes dependientes.

Los monorepos mejoran el desarrollo, el mantenimiento, la eficiencia y la productividad. Y pueden contener paquetes del mismo o distinto proyecto, pudiendo trabajar en ellos uno o más equipos.

En este punto y antes de continuar, por favor, eche un vistazo al siguiente proyecto, el cual se ha desarrollado mediante un monorepo, concretamente usando npm, y dispone de varios paquetes: https://github.com/akromio/nodejs-expected, una colección de bibliotecas de aserción para su uso en pruebas automatizadas como, por ejemplo, de unidad o integración. En el directorio packages, puede encontrar los distintos paquetes.

Resumiendo, un monorepo es un repositorio para el desarrollo de paquetes relacionados y dependientes entre sí. No es más que un repositorio que contiene el desarrollo de varios paquetes. No significa que todo el proyecto tenga que desarrollarse mediante un único monorepo, podemos hacerlo con varios, aunque podría ser así si el proyecto es de pequeño o mediano tamaño.

En muchas ocasiones, cuando desarrollamos un proyecto de software, tras un análisis y diseño previos, nos damos cuenta que su desarrollo será más fácil, productivo y eficaz si lo dividimos en partes más pequeñas, manejables e independientes que si lo hacemos como un único todo. En Node.js, esto significa implementar el proyecto en varios paquetes, cada uno con unas responsabilidades, características y funciones bien claras y definidas. El problema es que si cada paquete lo implementamos en su propio proyecto, las dependencias entre los paquetes del proyecto pueden hacer su implementación un poco costosa. Para resolver el problema, surgen los monorepos, mediante un único repositorio, implementamos todos los paquetes, aunque lo hacemos como paquetes independientes y no como un gran paquete.

Cuando trabajamos con monorepos, necesitaremos usar un programa de desarrollo conocido formalmente como herramienta de monorepo (monorepo tool). Hay muchas disponibles, siendo Lerna y npm las más utilizadas por la comunidad de Node.js. En nuestro caso, nos vamos a centrar en npm, pues forma parte de nuestro día a día, reduciendo así la curva de aprendizaje. De manera excepcional, mostraremos Lerna, pero sólo aquellos comandos que resuelven deficiencias que hoy en día tiene npm que, muy probablemente, se resolverán en un futuro no muy lejano. Entre las características que, como mínimo, deben proporcionar, encontramos:

  • Gestión de las dependencias de los paquetes del monorepo y entre ellos mismos.

  • Instalación sencilla de dependencias.

  • Establecimiento de la versión de los paquetes del monorepo antes de su publicación.

  • Publicación de los paquetes del monorepo en un registro como, por ejemplo, NPM.

Espacios de trabajo

Desde la versión 7, npm proporciona la funcionalidad de espacios de trabajo (workspaces), la cual podemos utilizar para trabajar con monorepos. Para npm, un espacio de trabajo (workspace) representa un subdirectorio del proyecto que contiene un paquete. Al directorio del proyecto lo consideraremos como el monorepo; mientras que a los distintos espacios de trabajo como sus paquetes.

Archivo package.json del monorepo

Todo monorepo, que use npm, debe tener su propio archivo de metadatos package.json, independientemente del de los paquetes que contiene. Este archivo es similar a los de un paquete de Node.js, pero con algunas características:

  • Debe ser privado, es decir, debe tener su propiedad private a true.

  • Debe contener las dependencias de desarrollo específicas del monorepo. Esto incluye también dependencias de desarrollo comunes usadas por los paquetes del monorepo como, por ejemplo, mocha, typescript, etc.

  • Debe indicar la ubicación de los paquetes del monorepo mediante la propiedad workspaces.

Veamos un ejemplo ilustrativo:

{
  "name": "monorepo",
  "version": "0.2.0",
  "private": true,
  "workspaces": [
    "packages/*"
  ],
  "engines": {
    "node": ">= 18",
    "npm": ">= 8"
  },
  "devDependencies": {
    "@babel/cli": "^7.18.10",
    "@babel/core": "^7.19.0",
    "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
    "@babel/preset-env": "^7.19.0",
    "@dogmalang/core": "^1.0.0-rc18.0",
    "@types/node": "^18.7.15",
    "@typescript-eslint/eslint-plugin": "^5.36.2",
    "@typescript-eslint/parser": "^5.36.2",
    "c8": "^7.12.0",
    "chai": "^4.3.6",
    "eslint": "^8.23.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-prettier": "^4.2.1",
    "mocha": "^10.0.0",
    "prettier": "^2.7.1",
    "typescript": "^4.8.2"
  },
  "scripts": {
    "clean": "npm run env -ws -- rm -rf node_modules/",
    "bootstrap": "npm run clean && npm i -ws",
    "build": "npm run -ws build",
    "test": "npm run -ws test",
    "cov": "npm run -ws cov"
  }
}

En la propiedad name, puede indicar cualquier nombre como el nombre del proyecto o, como en el ejemplo, simplemente monorepo. Para gustos, colores.

La propiedad version la podemos utilizar para indicar la versión del monorepo. En este caso, en cada publicación, incrementaremos esta propiedad para que refleje la versión en la que estamos. Esta propiedad no es necesaria, pero sí recomendable, sobre todo si utiliza la opción --include-workspace-root, tal y como veremos más adelante.

Mediante la propiedad scripts, podemos registrar scripts específicos del monorepo.

En la propiedad workspaces del archivo de metadatos del monorepo, debemos indicar las rutas, relativas a la raíz del monorepo, donde se encuentran sus paquetes. Se puede usar comodines, tal y como muestra el ejemplo anterior. Por convenio, los paquetes del monorepo se suelen ubicar en su directorio packages. Dentro de este directorio, podemos crear subdirectorios que sirvan para agrupar los paquetes por algún tipo de característica o funcionalidad; o bien, ubicar todos los paquetes como hijos de packages. En el ejemplo anterior, todos los paquetes se encuentran en el directorio packages. Ahora bien, otras estructuras son posibles como, por ejemplo:

"workspaces": [
  "packages/libs/*",
  "packages/plugins/*",
  "packages/presets/*",
  "packages/tools/*",
],

En este último ejemplo, los paquetes se ubican en los directorios packages/libs, packages/plugins, packages/presets y packages/tools. Use aquella estructura que mejor se adapte a su situación.

Instalación de dependencias con npm

Una de las características de npm es la instalación de dependencias. El comando npm install, abreviado como npm i, se utiliza, como ya sabemos, para instalar dependencias. Cuando lo usamos con paquetes de un monorepo, hay que recordar utilizarlo con algunas opciones extras.

Opción --workspaces

Cuando se indica la opción --workspaces, abreviada como -ws, el comando se aplicará a todos los paquetes del monorepo. Tal y como veremos más adelante, también se tiene que usar con otros comandos como, por ejemplo, npm version, npm publish y npm run.

El comando npm i -ws lo que hace es recorrer los distintos paquetes del monorepo, analizarlos, desarrollar el grafo de dependencias detectando cuáles son internas al monorepo, es decir, qué paquetes del monorepo dependen de otros del propio monorepo y, entonces, realizar la instalación de las dependencias, creando enlaces cuando sea necesario. No instala las dependencias del package.json del monorepo, sino las dependencias de los paquetes del monorepo ubicadas en sus correspondientes archivos package.json.

Si deseamos que instale primero las dependencias del monorepo, es decir, las indicadas por el archivo de metadatos del monorepo, indicaremos también la opción --include-workspace-root.

Así pues, cuando clonamos un monorepo, lo primero que haremos es instalar las dependencias del monorepo con el siguiente comando npm i:

npm i -ws --include-workspace-root

Instalación de dependencias individuales

Si durante el desarrollo necesitamos añadir una nueva dependencia, primero, tenemos que saber a qué se lo vamos a añadir. Puede ser una dependencia del monorepo o una de uno o más de sus paquetes. Atendiendo a qué, tendremos que hacerlo de una manera u otra.

Si necesitamos añadir una dependencia al monorepo, usaremos el comando npm i sin las opciones de espacio de trabajo. Ubicados en el directorio raíz del monorepo, ejecutaremos el npm i correspondiente. Ejemplo:

npm i -D mocha

Ahora bien, si necesitamos añadir una dependencia a todos los paquetes del monorepo, tendremos que indicar la opción --workspaces. En cambio, si sólo deseamos añadir la dependencia a un único paquete, usaremos la opción --workspace o -w, indicando el paquete en cuestión. El siguiente ejemplo muestra cómo instalar el paquete @skynetlabs/skynet-nodejs en el paquete mi-paquete del monorepo:

npm i -w mi-paquete @skynetlabs/skynet-nodejs

Como nombre del paquete donde instalar la dependencia, se puede indicar el nombre del paquete o la ruta relativa al directorio del paquete. Lo que más le guste.

Si necesitamos instalar un mismo paquete en varios del monorepo, podemos indicar una opción -w para cada uno de ellos. No hace falta ejecutar varias veces el comando, basta con indicar varias opciones -w, una para cada paquete.

Si un paquete del monorepo depende de otro de ese mismo monorepo, debe instalar la dependencia como acabamos de ver. No lo olvide. Esto se debe a que npm i detecta que está ante una dependencia local y entonces realizará todo aquello que sea necesario para mantener el monorepo en buen estado, creando enlaces cuando sea necesario, en vez de instalando paquetes de algún registro remoto.

Ejecución de scripts con npm

A veces, necesitamos ejecutar un determinado script en cada paquete del monorepo como, por ejemplo, cuando deseamos ejecutar la batería de pruebas de todos o algunos paquetes. npm permite hacer esto de manera muy sencilla con el comando npm run.

Comando npm run

Mediante el comando npm run pedimos a npm que ejecute un determinado script de los indicados en el package.json de los paquetes del monorepo. Lo que hace es recorrer los archivos de metadatos de los paquetes, detectar quiénes lo tienen y, entonces, ejecutarlos.

Su sintaxis básica es:

npm run nombreScript

Por ejemplo, si deseamos ejecutar el script build en todos los paquetes, utilizaremos lo siguiente, no olvide el uso de la opción --workspaces:

npm run -ws build

Si sólo quiere ejecutarlo en un determinado paquete del monorepo, puede usar la opción --workspace, de manera similar a como hemos visto con otros comandos anteriormente.

A veces, necesitamos pasar opciones extras al script indicado en el package.json. Podemos hacerlo, en el comando npm run, indicándolas al final de la línea de comandos tras un doble guion (--). Este guion doble actúa como marcador de inicio de las opciones extras que se deben añadir a lo indicado en el archivo de metadatos. Ejemplo:

npm run test -- --config .mocharc.itg.yaml

¿Qué ocurre si un paquete del monorepo no contiene el script indicado? Sencillo, npm mostrará un mensaje de error para ese paquete, similar al siguiente:

npm ERR!   in workspace: nombre-paquete
npm ERR!   at location: /ruta/al/paquete
npm ERR! Lifecycle script `nombre-script` failed with error: 
npm ERR! Error: Missing script: "nombre-script"

Para evitar este tipo de mensajes en aquellos paquetes que no definan el script, utilizaremos la opción --if-present. Ejemplo:

npm run test/itg --if-present

npm run env

npm proporciona un script especial conocido como env, el cual podemos usar para listar las variables de entorno disponibles en un paquete. Con este script, si lo necesitamos, podemos ejecutar líneas de comandos en los paquetes del monorepo. No hay más que indicar un guion doble (--) y, a continuación, el comando a ejecutar. Vamos a ver un ejemplo. Supongamos que queremos suprimir un determinado directorio presente en los paquetes del monorepo, esto lo podemos hacer muy fácilmente como sigue:

npm run env -- rm -rf directorio

Propietarios de código

GitHub proporciona una característica que se conoce como propietarios de código (code owners). Un propietario de código (code owner) es una persona o equipo que se responsabiliza de uno o más paquetes del proyecto. La idea es permitir que varios equipos puedan trabajar en el mismo monorepo, pero que un equipo no pueda cambiar o actualizar un paquete de otro, aunque tenga permiso para trabajar en el monorepo común.

Lo que hay que hacer es definir el archivo CODEOWNERS (CODEOWNERS file), el cual fija los distintos propietarios de código y los paquetes de los que son responsables. Este archivo de texto hay que ubicarlo en el directorio .github del proyecto, o sea, definiremos los propietarios de código en .github/CODEOWNERS. Cada línea define un paquete y quiénes son sus responsables. Realmente, define una ruta del proyecto de tal manera que todo lo que cuelga de ella es responsabilidad de los usuarios indicados. Esto generalmente suele ser la ruta de un paquete.

La sintaxis de cada línea es la siguiente:

ruta propietario(s)

Cada propietario se debe indicar mediante su nombre de usuario de GitHub precedido de una arroba (@). Si hay que fijar varios, usaremos el espacio como separador. Si como ruta indicamos un asterisco (*), estaremos indicando todo el proyecto; ejemplo:

* @tú @yo

Para indicar un determinado paquete, he aquí un ejemplo:

/packages/nombre-paquete/ @tú @yo

En ocasiones, puede ser útil restringir la propiedad a extensiones de archivo, en vez de a directorios como en el caso anterior. Ejemplo:

*.js @tú
*.go @yo

Los propietarios de código se tienen en cuenta en:

  • Las solicitudes de integración.

  • La protección de ramas.

Tipos de paquete

El tipo de paquete (package type) refleja el objeto del paquete. Básicamente, los paquetes de NPM se suelen clasificar como:

  • Paquete de biblioteca (library package), aquel que presenta una API para su reutilización por otros. Este tipo de paquetes suelen definir las propiedades main, exports, browser, module y types.

  • Paquete de línea de comandos (CLI package), aquel que proporciona una o más aplicaciones de línea de comandos. En este caso, habrá que definir la propiedad bin.

  • Paquete de aplicación (application package), aquel que proporciona un servicio como, por ejemplo, un servicio web privado. En este caso, no olvidar poner la propiedad private a true.

Comando npm prune

Una paquete extraño (extraneous package) es aquel que aparece en nuestro directorio node_modules, pero no así en el archivo de metadatos. Puede producirse, por ejemplo, si lo instalamos como dependencia, pero lo hacemos sin actualizar el archivo package.json. Este tipo de paquetes no son recomendables porque pueden conducir a que se utilicen sólo en nuestro entorno de desarrollo personal, pero que por suerte, nos saltarán posteriormente en la integración continua al no instalarse debido a que no se listan entre nuestras dependencias.

El comando npm prune lo que hace es eliminar estos paquetes. Para ello, puede utilizar las siguientes sintaxis:

# suprime los paquetes extraños del monorepo
npm prune --workspaces
npm prune --workspaces --include-workspace-root

# suprime los paquetes extraños del paquete indicado del monorepo
npm prune --workspace nombrePaquete

La opción --dry-run se utiliza para saber lo que haría el comando si no estuviera esta opción.