npm: el registro, node_modules y el hoisting plano
El package manager que viene con Node, por dentro: el registro como protocolo HTTP y no solo como web, el packument y los tarballs con integridad, cómo un rango semver de package.json se resuelve a un árbol de carpetas concreto, y el salto de npm 2 (node_modules anidado y recursivo) a npm 3+ (hoisting plano que deduplica izando dependencias al tope). Entender el aplanado es la única forma de entender, después, por qué existen las dependencias fantasma.
npm es tres cosas a la vez, y confundirlas cuesta caro: un programa de línea de comandos, un formato de paquete y un registro público de software. La pieza que de verdad define el ecosistema no es el comando npm install, sino el registro que hay detrás y el árbol de archivos que ese comando deja en tu disco. Esta lección abre node_modules en canal —de dónde salen los paquetes, cómo se resuelve un rango de versiones a una carpeta concreta y por qué en 2015 npm decidió aplanar ese árbol—, una decisión de ingeniería que resolvió un problema real y sembró otro que arrastramos hasta 2026.
- Distinguir npm el CLI, npm el formato de paquete y npm el registro.
- Entender el packument, los tarballs y la integridad como base de toda instalación.
- Seguir la resolución de un rango semver desde
package.jsonhasta un árbol de carpetas. - Comprender el hoisting plano de npm 3+ y el problema estructural que introdujo.
npm no es un programa: son tres cosas
Cuando alguien dice “npm” puede referirse a tres entidades distintas. Está el CLI, el ejecutable que viene incluido con Node.js y que orquesta instalaciones, scripts y publicaciones. Está el formato: un paquete npm no es más que un tarball —un .tgz— con un package.json en su raíz y el código junto a él. Y está el registro, registry.npmjs.org, una base de datos pública con una API HTTP que sirve metadatos y tarballs. El CLI es reemplazable —yarn y pnpm hablan exactamente el mismo protocolo—, pero el registro y el formato son el terreno común sobre el que se levanta todo el ecosistema de JavaScript.
El registro es un servicio HTTP, no una página web. Cuando pides un paquete, el cliente hace un GET a una URL con el nombre del paquete y recibe un documento JSON —el packument— que describe todas las versiones publicadas: sus dependencias, sus hashes, las etiquetas móviles como latest (los dist-tags) y la URL del tarball de cada versión.
{
"name": "is-odd",
"dist-tags": { "latest": "3.0.1" },
"versions": {
"3.0.1": {
"dependencies": { "is-number": "^6.0.0" },
"dist": {
"tarball": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",
"integrity": "sha512-...=="
}
}
}
}
Instalar una versión concreta es un segundo GET a ese tarball. El campo integrity es un hash criptográfico SRI (típicamente sha512) del contenido: el cliente lo verifica tras descargar y aborta si no cuadra. Ese hash es la base mínima de la seguridad de la cadena de suministro, mucho antes del lockfile (nivel 6).
De un rango a una carpeta: la resolución
Lo que declaras en package.json no son versiones, son rangos:
{
"dependencies": {
"react": "^19.0.0",
"lodash": "~4.17.21"
}
}
El operador ^ admite cualquier minor o patch dentro del mismo major; ~ fija major y minor y solo deja subir el patch. Instalar consiste en, para cada rango, consultar el packument, elegir la versión más alta que lo satisface y recursar en las dependencias de esa versión, cerrando así un grafo dirigido de cientos o miles de nodos. Como el resultado depende de qué había publicado el registro en ese instante, dos instalaciones separadas por una semana pueden diferir: esa es la ambigüedad que el lockfile congela después.
El resultado de esa negociación lo puedes inspeccionar en cualquier momento: el CLI conoce el árbol completo, con la versión concreta que quedó fijada para cada nodo, directo y transitivo.
npm ls --all # arbol completo, directas y transitivas
npm ls lodash # por que esta lodash y quien lo pide
Elegir una versión de cada paquete que satisfaga a la vez todas las restricciones del grafo no es un simple “coge la más alta”: cuando dos ramas piden rangos que solo se solapan en un punto, o cuando un peerDependency impone una condición cruzada, el resolvedor se enfrenta a un problema de satisfacción de restricciones emparentado con SAT, teóricamente NP-duro. npm lo aborda con heurísticas —preferir lo más nuevo, deduplicar cuando puede— que funcionan casi siempre pero no garantizan una solución óptima ni única. Que “instalar dependencias” parezca instantáneo esconde que, por debajo, hay un solucionador tomando decisiones no triviales cada vez.
El árbol anidado: npm 2 y la recursión
El algoritmo de resolución de módulos de Node camina hacia arriba por las carpetas buscando un node_modules. El diseño ingenuo, el de npm 2, aprovechaba esto de forma literal: cada paquete cargaba su propio node_modules con sus dependencias anidadas dentro, recursivamente.
node_modules/
foo/
node_modules/
lodash/ # copia 1
bar/
node_modules/
lodash/ # copia 2, byte a byte identica
Semánticamente es perfecto —cada paquete ve exactamente las versiones que declaró—, pero en la práctica es un desastre: el mismo paquete duplicado decenas de veces, profundidades patológicas que reventaban el límite de 260 caracteres de ruta en Windows, e instalaciones lentísimas por la cantidad de archivos.
El hoisting plano de npm 3+
npm 3 (2015) aplanó el árbol. La idea del hoisting: izar cada dependencia lo más arriba posible en node_modules, de modo que cuando muchos paquetes necesitan la misma versión, una sola copia compartida se sienta en el tope y todos los de abajo la resuelven —porque Node camina hacia arriba—. Las versiones en conflicto siguen anidándose; el resto se deduplica.
node_modules/
foo/
bar/
lodash/ # una sola copia, izada al tope
flowchart TD root[node_modules raiz] --> foo[foo] root --> bar[bar] root --> lodash[lodash 4.17 izada al tope] foo -.la necesita.-> lodash bar -.la necesita.-> lodash style lodash fill:#f9e2af,color:#11111b style root fill:#89b4fa,color:#11111b
El aplanado tiene dos consecuencias estructurales que definen todo lo que viene después. La primera es que no es determinista: qué versión gana el puesto de arriba depende del orden de instalación, así que dos máquinas pueden producir árboles distintos y ambos válidos; el lockfile nació para parchear esto. La segunda es más sutil y más grave: la abstracción tiene fugas. Cualquier paquete izado al tope es visible —y por tanto importable— desde tu código, aunque nunca lo hayas declarado en package.json. Esa visibilidad accidental es la dependencia fantasma, protagonista de la próxima lección.
El registro
Una API HTTP, no una web. Sirve el packument (metadatos de todas las versiones) y los tarballs. npm, yarn y pnpm hablan el mismo protocolo.
Integridad
Cada versión trae un hash SRI del tarball. El cliente lo verifica al descargar: la defensa mínima contra corrupción y manipulación en tránsito.
Rango a versión
package.json declara rangos; el resolvedor los baja a versiones exactas recorriendo el grafo. Sin lockfile, el resultado depende del reloj.
Hoisting plano
npm 3+ iza dependencias al tope para deduplicar. Deja el árbol plano, no determinista y permeable a fantasmas.
El hoisting no fue un error: fue un intercambio deliberado. npm cambió la corrección del contrato de dependencias por disco y velocidad, y en 2015, con instalaciones que tardaban minutos y árboles que no cabían en Windows, era el intercambio correcto. Pero conviene ver lo que pasó con precisión conceptual: la disposición física de node_modules es, de facto, una interfaz. Tu package.json promete “mi código depende de estos paquetes”; el árbol aplanado, sin querer, expone además todas las dependencias transitivas al mismo nivel de visibilidad que las directas. La interfaz real acabó siendo más ancha que la interfaz declarada, y esa brecha es exactamente el hueco por donde se cuelan las fantasmas. La lección trasciende a npm: cada vez que una estructura de datos se usa como frontera entre dos partes de un sistema —aquí, entre tu manifiesto y el resolvedor de Node—, lo que esa estructura permite pesa más que lo que pretende. Un buen diseño hace que los estados inválidos sean imposibles de representar; el aplanado hizo justo lo contrario, dejó representable un estado que el manifiesto nunca autorizó. Toda la tesis de pnpm, tres lecciones más adelante, es cerrar esa brecha por construcción. Entender que el layout es una interfaz, y no un detalle de fontanería, es lo que separa a quien “hace que instale” de quien razona sobre por qué instala mal.
- Haz un
curlal packument de un paquete pequeño (curl https://registry.npmjs.org/is-odd) y localiza susdist-tags, la URL de un tarball y suintegrity. - Abre el
node_modulesde un proyecto tuyo y encuentra en el tope un paquete que no aparece en tupackage.json: es una transitiva izada. - Cuenta cuántas carpetas hay en el primer nivel de
node_modulesy compáralo con el número de dependencias que declaraste; la diferencia es el hoisting en acción. - Explica en un comentario por qué el mismo
package.json, sin lockfile, puede producir dos árboles distintos según el orden de instalación.