Inicio / Artículos / Cómo crear un monorepo con pnpm y Turborepo para aplicaciones Node.js, paso a paso

Cómo crear un monorepo con pnpm y Turborepo para aplicaciones Node.js, paso a paso

Cree un monorepo de TypeScript a partir de una carpeta vacía utilizando pnpm workspaces y Turborepo, y luego ejecute, compile y filtre tareas en una aplicación web, una API y paquetes compartidos.

1702 palabras

Una vez que un producto cuenta con una interfaz de usuario, un backend y el código necesario para ambos, los repositorios separados comienzan a ser problemáticos: los tipos compartidos se desvían, la configuración se copia a mano y un solo cambio requiere varios pull requests. pnpm workspaces junto con Turborepo resuelven esto sin fusionar los proyectos en uno solo. Esta guía muestra cómo pasar de un directorio vacío a dos aplicaciones en TypeScript y un paquete compartido que se puede desarrollar, compilar y gestionar desde una única raíz.

El diseño final:

my-monorepo/
├── apps/
│   ├── web/
│   └── api/
│
├── packages/
│   ├── types/
│   └── eslint-config/
│
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.json
└── pnpm-lock.yaml

Qué te ofrece un monorepo

Un monorepo es un único repositorio Git que alberga varias aplicaciones y paquetes. La alternativa es tener un repositorio por cada área de funcionalidad:

frontend-repository
backend-repository
shared-types-repository
ui-library-repository

En un monorepo, estas se convierten en carpetas, con el código listo para desplegarse bajo apps y el código reutilizable bajo packages:

my-monorepo/
├── apps/
│   ├── web/
│   └── api/
│
└── packages/
    ├── types/
    └── ui/

El código se comparte directamente en lugar de publicarse primero. Un paquete de tipos consumido tanto por el frontend como por el backend significa que cualquier cambio en el contenido del paquete llega a ambos lados en un único commit:

apps/web
      ↓
packages/types
      ↑
apps/api

Dónde encaja Turborepo

Los espacios de trabajo de pnpm vinculan los paquetes; Turborepo decide cómo se ejecutan las tareas en ellos. Ofrece orquestación de tareas, ordenamiento basado en dependencias, ejecución paralela, caché local y remoto, compilaciones incrementales y soporte para espacios de trabajo.

Tomemos un repositorio con tres espacios de trabajo:

apps/web
apps/api
packages/types

Cada uno puede definir las mismas scripts:

build
lint
test
dev

En lugar de ingresar a cada directorio en el orden correcto, se ejecutan desde la raíz y Turborepo paraleliza lo que es posible. Para saber cuándo vale la pena utilizar esta opción, consulte dónde encaja Turborepo en un monorepo de NestJS y cuándo omitirlo.

Requisitos previos

Necesita Node.js, pnpm, Git y un editor. Verifique Node.js:

node -v

Y pnpm:

pnpm -v

Si falta pnpm, Corepack, incluido con Node.js, puede proporcionarlo. Actívelo:

corepack enable

Active la versión más reciente de pnpm:

corepack prepare pnpm@latest --activate

Confirme:

pnpm -v

Configuración del espacio de trabajo raíz

Cree el directorio:

mkdir my-monorepo
cd my-monorepo

Inicialice Git:

git init

Genere el manifiesto raíz:

pnpm init

Lo que deja:

my-monorepo/
└── package.json

Instale Turborepo en la raíz

Turborepo sirve a todo el repositorio, por lo que --workspace-root lo instala en la raíz en lugar de dentro de un paquete:

pnpm add turbo --save-dev --workspace-root

El manifiesto de la raíz termina con scripts que delegan en turbo run; la opción private impide que la raíz sea publicada:

{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test"
  },
  "devDependencies": {
    "turbo": "..."
  }
}

La versión de turbo que utiliza depende del momento de instalación. Las versiones recientes también exigen un campo packageManager en el package.json de la raíz; si turbo no puede detectar su gestor de paquetes, consulte la documentación.

Declare los espacios de trabajo

pnpm encuentra los paquetes a través de un archivo de la raíz:

pnpm-workspace.yaml

Enumere los nombres genéricos que deben considerarse como paquetes:

packages:
  - "apps/*"
  - "packages/*"

Cada directorio directamente debajo de ellos se convierte en un espacio de trabajo:

apps/*
packages/*

Cree las carpetas para ambas aplicaciones y el paquete types:

mkdir -p apps/web
mkdir -p apps/api
mkdir -p packages/types

El árbol hasta ahora:

my-monorepo/
├── apps/
│   ├── web/
│   └── api/
│
├── packages/
│   └── types/
│
├── package.json
└── pnpm-workspace.yaml

Agregar las dos aplicaciones

La aplicación web

Para mantener el enfoque en el monorepo, la aplicación web es simplemente una aplicación Node.js por ahora. Úsela:

cd apps/web

Déle un manifiesto:

pnpm init

La forma final deseada:

apps/web/
├── package.json
└── src/
    └── index.ts

Cree el archivo de entrada:

mkdir src
touch src/index.ts

Agregue un marcador de posición:

console.log("Hello from Web application");

Cada espacio de trabajo necesita TypeScript, así que regrese a la raíz:

cd ../..

E instálelo una vez:

pnpm add typescript --save-dev --workspace-root

La API

Mismo patrón: úsela e inicialícela:

cd apps/api
pnpm init

Cree el archivo de entrada:

mkdir src
touch src/index.ts

Agregue su marcador de posición:

console.log("Hello from API application");

Las dos aplicaciones ahora son iguales:

apps/
├── web/
│   ├── src/
│   │   └── index.ts
│   └── package.json
│
└── api/
    ├── src/
    │   └── index.ts
    └── package.json

Compartir la configuración de TypeScript

Volver a la raíz:

cd ../..

Crear una configuración base:

tsconfig.json

Contiene opciones que comparten todos los espacios de trabajo: un destino moderno, resolución con NodeNext y verificación estricta:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

Cada aplicación la extiende y agrega solo configuraciones locales. Para la aplicación web, crear:

apps/web/tsconfig.json

Señala al archivo raíz, establece una carpeta de salida y compila solo src:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

La API obtiene el mismo archivo:

apps/api/tsconfig.json

Con contenido idéntico:

{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "dist"
  },
  "include": ["src"]
}

Los cambios en la estrictitud o el destino ahora ocurren en un solo lugar.

Proporcionar scripts de compilación a cada espacio de trabajo

Turborepo ejecuta los scripts definidos por los espacios de trabajo. Abra el manifiesto web:

apps/web/package.json

Establecer un nombre específico y tres scripts: build mediante tsc, dev en modo de vigilancia, lint mediante ESLint:

{
  "name": "@repo/web",
  "private": true,
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "lint": "eslint ."
  }
}

Luego, el manifiesto de la API:

apps/api/package.json

Con su propio nombre:

{
  "name": "@repo/api",
  "private": true,
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "lint": "eslint ."
  }
}

Los filtros y las dependencias del espacio de trabajo hacen referencia a estos nombres @repo/.... El script dev necesita tsx:

pnpm add tsx --save-dev --workspace-root

El script lint también asume que ESLint está instalado y configurado; agréguelo o elimine el script, de lo contrario pnpm lint fallará.

Configuración de la tubería de tareas

turbo.json describe cómo se comporta cada tarea. Créelo en la raíz:

turbo.json

Defina las tareas:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "dependsOn": ["^lint"]
    }
  }
}

Qué observar:

  • "dependsOn": ["^build"] hace que un paquete espere a que se construyan los paquetes del espacio de trabajo de los cuales depende; el signo de apuntación indica dependencias.
  • outputs indica qué se debe cachear y restaurar, de modo que los paquetes sin cambios no se vuelvan a compilar.
  • dev está sin cachear y es persistent porque un monitor nunca termina.
  • lint también ejecuta primero las dependencias.
  • tasks es la clave actual; las versiones anteriores utilizaban pipeline, por lo que los ejemplos más antiguos podrían necesitar ajustes.

    Ejecución y compilación desde la raíz

    Inicie todos los procesos de desarrollo:

    pnpm dev
    

    Esto funciona porque el script dev de la raíz es:

    "dev": "turbo run dev"
    

    Turborepo encuentra dev en cada espacio de trabajo y los inicia juntos, utilizando una única terminal para la aplicación web:

    cd apps/web
    pnpm dev
    

    Y otra para la API:

    cd apps/api
    pnpm dev
    

    Con una única orden desde la raíz:

    pnpm dev
    

    Compila todo de la misma manera:

    pnpm build
    

    Turborepo ordena las compilaciones según las dependencias de los paquetes, comenzando por los paquetes compartidos:

    pnpm build
          │
          ▼
    turbo run build
          │
          ├── packages/types
          │
          ├── apps/api
          │
          └── apps/web
    

    Ese orden proviene de las dependencias declaradas: hasta que packages/types cuente con un package.json y las aplicaciones dependan de él, Turborepo no lo compilará primero.

    Dirigirse a un solo espacio de trabajo

    El parámetro --filter de pnpm ejecuta un script en un espacio de trabajo, como el servidor de desarrollo de la API:

    pnpm --filter @repo/api dev
    

    O una compilación web:

    pnpm --filter @repo/web build
    

    El propio filtro de Turborepo se encarga del caché y del ordenamiento:

    pnpm turbo run build --filter=@repo/api
    

    Comandos que utilizará a diario

    Instalar todo:

    pnpm install
    

    Iniciar el desarrollo:

    pnpm dev
    

    Compilar todo:

    pnpm build
    

    Revisar la sintaxis de todo:

    pnpm lint
    

    Compilar un solo paquete:

    pnpm --filter @repo/api build
    

    Ejecutar una sola aplicación:

    pnpm --filter @repo/web dev
    

    Agregar una dependencia a un espacio de trabajo:

    pnpm --filter @repo/api add express
    

    Depender de un paquete local; workspace:* enlaza con la copia del repositorio en lugar de descargarla desde el registro:

    pnpm --filter @repo/api add @repo/types@workspace:*
    

    ¿Por qué no limitarse a los workspaces de pnpm?

    Se podría confiar únicamente en los workspaces:

    apps/
    packages/
    

    No obstante, a medida que el repositorio crece, es necesario coordinar más manualmente:

    build
    test
    lint
    typecheck
    dev
    dependencies
    task ordering
    caching
    

    Turborepo añade capacidad de orquestación: un único comando comprende las relaciones entre paquetes, omite los trabajos sin cambios y ejecuta tareas independientes en paralelo:

    pnpm turbo run build
    

    Para una sola aplicación y un solo paquete, los workspaces simples pueden ser suficientes. ¿Aún eligiendo un gestor de paquetes? Consulte nuestra comparación entre npm y pnpm.

    Conclusión

    Un buen monorepo es un entorno compartido donde las aplicaciones y los paquetes evolucionan bajo el mismo conjunto de herramientas. La capa tecnológica aquí es sencilla:

    pnpm
    +
    Turborepo
    +
    TypeScript
    

    Comience con dos aplicaciones:

    apps/
    ├── web/
    └── api/
    

    Desarrolle hacia más servicios:

    apps/
    ├── web/
    ├── admin/
    ├── api/
    └── worker/
    

    Sostenido por paquetes compartidos:

    packages/
    ├── ui/
    ├── types/
    ├── database/
    ├── auth/
    └── utils/
    

    La ventaja radica en poder compartir código, tipos, configuraciones y flujos de trabajo, al tiempo que cada aplicación se mantiene organizada de forma independiente. A medida que la amplíe:

    • declare las dependencias locales con workspace:* para que las compilaciones se realicen en el orden correcto
    • enumere cada artefacto en outputs, de lo contrario las restauraciones del caché lo pasarán por alto
    • conservar la configuración base en la raíz y ampliarla posteriormente
    • agregue packageManager y herramientas como ESLint antes de depender de los scripts de la raíz en CI

    Referencias: la documentación de Turborepo, el repositorio de Turborepo, la documentación de pnpm y la documentación de Node.js.

    Lecturas relacionadas