wandres.dev
PNPM WORKSPACES · el monorepo básico

El protocolo workspace: referenciar paquetes internos

workspace:* frente a workspace:^ y workspace:~, por qué en pnpm 10 enlazar es opt-in, y la reescritura del prefijo a un rango semver real en el momento de publicar.

⏱ 14 min

Dentro de un monorepo, un paquete depende de otro. La pregunta es: cuando apps/web declara que necesita @acme/ui, ¿de dónde sale ese paquete? ¿Del registro npm, con la versión publicada, o del código que tienes ahí mismo en packages/ui? El protocolo workspace: existe para responder esa pregunta sin ambigüedad: fuerza el enlace a la copia local y, al publicar, se transforma solo en un rango de versión que el resto del mundo pueda entender.

🎯 Al terminar esta lección sabrás
  • Usar el protocolo workspace: para enlazar paquetes internos por symlink.
  • Distinguir workspace:*, workspace:^ y workspace:~ y su intención.
  • Entender por qué en pnpm 10 el enlace dejó de ser automático.
  • Dominar la reescritura del prefijo a semver real al publicar o empaquetar.

Enlazar, no descargar

Referencias un paquete interno igual que cualquier dependencia, en el package.json, pero con el prefijo workspace::

{
  "name": "@acme/web",
  "dependencies": {
    "@acme/ui": "workspace:*",
    "@acme/utils": "workspace:^"
  }
}

Ese prefijo es un contrato con pnpm: “esta dependencia debe resolverse desde el workspace”. pnpm crea un enlace simbólico en node_modules que apunta al directorio de packages/ui, de modo que un cambio en el código de la librería es visible al instante en la app, sin publicar ni reinstalar. Y si el paquete no existe en el workspace, la instalación falla en vez de caer sigilosamente al registro. Esa garantía —o lo local, o un error— es justo lo que quieres: elimina la clase de bugs en que creías usar tu código y estabas usando una versión vieja de npm.

Puedes verlo en el disco. La dependencia interna no es una carpeta con una copia, sino un enlace que apunta al paquete real:

$ ls -l apps/web/node_modules/@acme
ui    -> ../../../../packages/ui       # enlace simbolico, no una copia
utils -> ../../../../packages/utils

Ese enlace es la diferencia material entre un monorepo y una colección de paquetes sueltos: editar packages/ui/src/Button.tsx cambia al instante lo que ve apps/web, sin pnpm install, sin build intermedio y sin pasar por el registro.

Y la otra cara de la garantía es igual de valiosa: si escribes "@acme/ui": "workspace:*" pero ese paquete no existe en el workspace —un typo en el nombre, un glob que no lo alcanza—, pnpm install aborta con un error explícito en vez de instalar sigilosamente algo del registro. El prefijo convierte un fallo silencioso, usar la versión equivocada, en un fallo ruidoso y temprano.

⚠️
En pnpm 10 enlazar es opt-in

Hasta pnpm 9, si un paquete del workspace tenía una versión que satisfacía el rango pedido, pnpm lo enlazaba automáticamente aunque escribieras "@acme/ui": "^1.0.0". En pnpm 10 el ajuste link-workspace-packages pasó a valer false por defecto: ahora solo se enlaza lo que declares con workspace:. Es más explícito y menos mágico, pero significa que un rango a secas irá al registro. Si migras un repo antiguo, revisa que todas las dependencias internas lleven el prefijo.

Las tres varas de medir

El prefijo admite un selector de versión, y su elección no importa en desarrollo —siempre enlaza lo local— pero define qué rango heredarán los consumidores externos al publicar:

workspace:*

“La versión local, sea cual sea.” Al publicar se congela a la versión exacta actual (por ejemplo 1.5.0). Acoplamiento máximo: el consumidor externo pedirá esa versión clavada.

🔼

workspace:^

Al publicar se convierte en un rango caret (^1.5.0): compatible con futuros minor y patch. Es el valor por defecto al añadir con pnpm add --workspace.

workspace:~

Al publicar se convierte en un rango tilde (~1.5.0): admite solo patches. Un término medio conservador entre clavar y abrir a minors.

También puedes fijar una versión explícita, workspace:1.2.3 o workspace:^1.2.3, y usar la forma con alias "ui": "workspace:@acme/ui@*" cuando quieras que el nombre local difiera del nombre del paquete. El ajuste save-workspace-protocol, en rolling por defecto, hace que pnpm add @acme/ui --workspace escriba workspace:^ sin fijar el número, de modo que el rango “rueda” con la versión del paquete.

¿Cuál elegir? La heurística del ecosistema: para paquetes que publicas al mundo, workspace:^ es el defecto sensato —confías en tu propio semver y no impones versiones clavadas a terceros—. Reserva workspace:~ para librerías donde un minor ajeno podría romperte y quieras admitir solo parches. Y usa workspace:* cuando quieras que el consumidor reciba exactamente la versión con la que probaste, sin margen.

ℹ️
El selector solo importa si publicas

Si un paquete tiene "private": true y nunca llega al registro, la diferencia entre workspace:*, ^ y ~ es puramente teórica: la reescritura al publicar jamás se ejecuta para él. El selector solo cobra consecuencias reales en los paquetes que sí publicas. Por eso en las apps —privadas por definición— casi todo el mundo escribe workspace:* y no le da más vueltas.

La reescritura al publicar

Aquí está la pieza que hace que todo el esquema tenga sentido. El prefijo workspace: es un dialecto privado de pnpm: npm install @acme/web en la máquina de un extraño no sabría qué hacer con "@acme/ui": "workspace:*". Por eso, en el momento de pnpm publish o pnpm pack, pnpm reescribe el prefijo a un rango semver concreto, calculado a partir de la versión que ese paquete tiene en ese instante en el workspace:

En el repo (dev)          En el tarball publicado (si @acme/ui es 1.5.0)
------------------------  ---------------------------------------------
workspace:*        ---->  1.5.0        (version exacta, clavada)
workspace:~        ---->  ~1.5.0       (admite patches)
workspace:^        ---->  ^1.5.0       (admite minors y patches)
workspace:^1.5.0   ---->  ^1.5.0       (solo se retira el prefijo)

El package.json de tu disco nunca cambia; la transformación ocurre solo en la copia que va al tarball. Así conviven dos verdades: en el monorepo, workspace: significa “enlaza mi código”; fuera, el consumidor recibe un rango normal que su gestor de paquetes resuelve contra el registro. Sin esta reescritura tendrías que elegir entre desarrollar cómodo o publicar correcto; con ella tienes ambas.

Un ejemplo concreto: el package.json publicado de @acme/web, suponiendo que en el repo @acme/ui está en 1.5.0 y @acme/utils en 2.3.1, queda así en el tarball:

{
  "name": "@acme/web",
  "dependencies": {
    "@acme/ui": "^1.5.0",
    "@acme/utils": "2.3.1"
  }
}

Donde había workspace:^ hay ahora ^1.5.0; donde había workspace:* hay una versión clavada. El consumidor que instale @acme/web desde npm jamás sabrá que estos paquetes convivían en un monorepo.

flowchart LR
A[package.json en el repo] -->|pnpm install| B[symlink a packages ui local]
A -->|pnpm publish| C[reescritura del prefijo]
C --> D[tarball con rango semver real]
D --> E[registro npm]
E --> F[consumidor externo resuelve normal]

Añadir, alias y el momento de publicar

No editas el JSON a mano para enlazar: pnpm add con la bandera --workspace lo hace y respeta save-workspace-protocol. Sin esa bandera, y con el link-workspace-packages: false de pnpm 10, acabarías pidiendo la versión del registro en lugar de la local.

pnpm --filter @acme/web add @acme/ui --workspace   # dependencia interna
pnpm --filter @acme/web add zod                     # dependencia externa

El alias resuelve un caso incómodo: cuando el identificador con el que quieres importar difiere del nombre real del paquete. "reactv18": "workspace:react-legacy@*" enlaza el paquete interno react-legacy bajo el nombre reactv18. Es la misma mecánica que el alias npm:, pero apuntando puertas adentro del workspace.

Y para subir de versión una dependencia interna en todos sus consumidores a la vez, pnpm up -r recorre el workspace:

pnpm up -r @acme/ui        # bump de la dependencia interna en todo el repo
pnpm up -r --latest        # sube todo a la ultima version disponible

Con workspace:* el bump ni siquiera exige tocar los package.json, porque el rango ya sigue a la versión local por construcción.

El punto delicado llega al publicar. Como la reescritura congela la versión del instante, el orden importa: si publicas @acme/web antes de haber subido la versión de @acme/ui, el tarball apuntará a una versión de la UI que quizá aún no existe en el registro. Por eso el ecosistema no publica a mano: Changesets recorre el grafo, calcula qué paquetes suben de versión por efecto dominó y publica en orden topológico, dejando que la reescritura de workspace: produzca rangos coherentes. El protocolo hace la traducción; Changesets orquesta el cuándo.

📝
workspace: no debe sobrevivir al tarball

Antes de publicar, herramientas como publint y @arethetypeswrong/cli verifican que el paquete resultante sea consumible: que exports resuelva, que los tipos existan y —clave aquí— que ningún workspace: haya quedado sin reescribir. Un prefijo workspace: filtrado a un tarball es un paquete roto: ningún npm install externo sabrá resolverlo.

Un mismo campo, dos audiencias

La genialidad del protocolo workspace: es que resuelve una tensión que parecía irreconciliable: la dependencia interna quiere ser una cosa durante el desarrollo y otra distinta al publicarse. Durante el desarrollo es una identidad: apunta a un directorio concreto de tu disco, por enlace simbólico, y cualquier edición se propaga sin ceremonia. Al publicarse es un contrato: un rango de compatibilidad que un desconocido resolverá contra el registro dentro de seis meses. El prefijo codifica esa dualidad en un solo campo del package.json, y la elección entre *, ^ y ~ no es cosmética: es una declaración sobre cuánto acoplamiento impones a quien consuma tu librería fuera del monorepo. workspace:* clava y garantiza reproducibilidad exacta a costa de rigidez; workspace:^ confía en el semver de tus futuros yo. Entender que estás escribiendo simultáneamente para dos audiencias —el pnpm de tu repo hoy y el npm de un extraño mañana— es lo que separa configurar dependencias de diseñar la superficie pública de un paquete. Y explica por qué Changesets, en el mismo acto de versionar, coordina estos rangos: publicar un monorepo es reescribir su grafo interno como semver externo.

⚔️ Observa la reescritura con tus ojos
  1. En un workspace, haz que @acme/web dependa de @acme/ui con workspace:^ y de @acme/utils con workspace:*.
  2. Ejecuta pnpm install y comprueba en node_modules que ambos son enlaces simbólicos a packages/.
  3. Fija la versión de @acme/ui a 1.5.0 en su package.json y corre pnpm --filter @acme/web pack.
  4. Descomprime el .tgz resultante y abre su package.json: verifica que workspace:^ se convirtió en ^1.5.0 y workspace:* en la versión exacta.
  5. Investiga: cambia save-workspace-protocol y observa cómo pnpm add --workspace escribe el prefijo de forma distinta.