El ecosistema web: npm, tipos, empaquetado y entornos
Compilar a JavaScript solo resuelve la mitad del problema: la otra mitad es convivir con un ecosistema que tiene su propio gestor de paquetes, su propio formato de módulos, su propia noción de tipos y dos entornos de ejecución que comparten lenguaje pero no API. Esta lección cubre cómo se declaran dependencias de npm desde Gradle, cómo se describen en Kotlin, qué hace el empaquetador con todo ello y en qué se diferencian de verdad el navegador y Node.
Un backend de compilación se juzga por el código que emite; un objetivo de plataforma se juzga por lo bien que se integra en la vida cotidiana de esa plataforma, y son dos exámenes distintos. Kotlin/JS aprobó el primero hace años y el segundo le costó bastante más, porque el ecosistema de JavaScript no es una biblioteca estándar sino un organismo con gestor de paquetes propio, varios formatos de módulo históricamente incompatibles, un sistema de tipos paralelo que vive en ficheros aparte y dos entornos de ejecución que comparten el lenguaje pero casi ninguna interfaz. La respuesta de Kotlin fue no competir con nada de eso, sino integrarse: el complemento de Gradle sabe hablar con npm, el compilador entiende los formatos de módulo, el empaquetador del ecosistema se invoca desde la construcción y las definiciones de tipos se generan para que el otro lado no note que el módulo vino de otro lenguaje. Entender esa integración es lo que separa un experimento que funciona en la máquina propia de un artefacto que un equipo de web puede consumir sin saber que existe Kotlin.
- Declarar dependencias de npm desde el guion de construcción y entender el papel del fichero de bloqueo.
- Describir un paquete ajeno en Kotlin con
@JsModuley valorar cuándo escribir las declaraciones a mano. - Explicar qué hace el empaquetador con la salida del compilador y qué se controla desde Gradle.
- Distinguir las diferencias reales entre el navegador y Node y estructurar el código en consecuencia.
Dependencias de npm desde Gradle
El complemento de Kotlin unifica dos mundos de gestión de dependencias bajo un único guion de construcción. Las bibliotecas de Kotlin y las multiplataforma se declaran como siempre; los paquetes de npm se declaran con una función dedicada, y el complemento se encarga de descargarlos e instalarlos usando la maquinaria del ecosistema.
kotlin {
js {
browser()
binaries.executable()
}
sourceSets["jsMain"].dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation(npm("date-fns", "4.1.0"))
}
}
De esa instalación surge un fichero de bloqueo que fija las versiones exactas del árbol de dependencias transitivas. Es un artefacto de construcción tan importante como cualquier otro y debe versionarse en el repositorio: sin él, dos máquinas que construyan el mismo proyecto en semanas distintas pueden acabar con árboles de npm diferentes, y la reproducibilidad se pierde justo en la parte del sistema que menos garantías ofrece.
Añadir un paquete de npm no es como añadir una dependencia de Maven. El árbol transitivo suele ser mucho más ancho, la política de versionado la aplica cada autor a su criterio y el código llega en fuente y se ejecuta en el cliente. Todo lo que se sabe sobre auditoría de dependencias en el ecosistema de JavaScript aplica íntegramente a un proyecto de Kotlin que las consuma; el hecho de escribir Kotlin no interpone ninguna protección.
Describir el paquete en Kotlin
Un paquete instalado no es todavía utilizable: hace falta contarle al compilador qué hay dentro. Eso se hace con declaraciones external anotadas para indicar de qué módulo proceden.
@JsModule("date-fns")
external object DateFns {
fun formatDistance(fecha: Date, base: Date): String
}
// Una funcion exportada por defecto, en su propio fichero
@file:JsModule("is-sorted")
external fun <T> sorted(a: Array<T>): Boolean
Aquí aparece la pregunta que todo el mundo hace: si el paquete ya trae definiciones de TypeScript, por qué hay que reescribirlas. La respuesta honesta es que la generación automática de declaraciones a partir de esos ficheros fue una vía experimental que no llegó a consolidarse, y que hoy el camino recomendado es escribir a mano la parte de la API que realmente se usa. Esto suena a trabajo y en la práctica suele ser una ventaja: una declaración escrita a mano cubre las cinco funciones que el proyecto invoca en lugar de las trescientas del paquete, se lee, se revisa y sirve además como frontera documentada.
En sentido contrario el flujo sí está automatizado. El compilador genera un fichero de definiciones de tipos a partir de todo lo marcado con @JsExport, tanto en el objetivo de JavaScript como en el de WebAssembly, donde se habilita con una función explícita en el bloque de configuración.
Hacia dentro
Consumir npm exige declaraciones external escritas a mano. Se describe solo lo que se usa.
Hacia fuera
Publicar hacia el ecosistema es automático: @JsExport produce definiciones que TypeScript entiende.
Fichero de bloqueo
Fija el árbol transitivo de npm. Se versiona en el repositorio o la construcción deja de ser reproducible.
Empaquetar
La salida del compilador son módulos de JavaScript, no una aplicación lista para servir. El paso que falta lo hace un empaquetador del propio ecosistema, que el complemento de Gradle invoca por debajo: resuelve las importaciones, une el código de Kotlin con el de npm, aplica la minificación y produce los ficheros que se publican. En el objetivo de navegador viene además un servidor de desarrollo con recarga, que combinado con la construcción continua de Gradle da el ciclo corto al que uno está acostumbrado en la web.
kotlin {
js {
browser {
commonWebpackConfig {
outputFileName = "app.js"
}
testTask {
useKarma { useChromeHeadless() }
}
}
}
}
Para el objetivo de Node el empaquetado suele sobrar, porque allí el sistema de módulos ya resuelve las importaciones en tiempo de carga y no hay que enviar nada por la red.
kotlin {
js {
nodejs()
binaries.executable()
}
}
El formato de módulo elegido gobierna cómo se consume el resultado, y aquí hay una asimetría que conviene tener presente al planificar una migración: Kotlin/JS admite tanto módulos ES como los formatos heredados, mientras que Kotlin/Wasm solo produce módulos ES y expone lo exportado como propiedades del objeto por defecto del módulo generado. Un proyecto que hoy publique en un formato antiguo tendrá que modernizar su forma de publicación antes de plantearse el otro backend.
flowchart LR A[Fuentes Kotlin] --> B[Compilador] C[Paquetes npm] --> D[Fichero de bloqueo] B --> E[Modulos JavaScript] D --> E E --> F[Empaquetador] F --> G[Artefacto servible] B --> H[Definiciones de tipos] H --> I[Consumidores en TypeScript]
Navegador y Node no son el mismo entorno
Comparten el lenguaje y casi nada más. El navegador aporta el modelo de documento, el almacenamiento local, la interfaz de red del cliente y un modelo de seguridad de origen; Node aporta sistema de ficheros, procesos, red de servidor y un conjunto de módulos propios. Ningún objeto global del uno existe en el otro, así que el código que toca el documento no puede compartirse con el que lee un fichero, por mucho que ambos compilen al mismo lenguaje.
La consecuencia práctica es de arquitectura y no de configuración. La lógica de negocio pura se escribe en el conjunto de fuentes común, sin tocar ninguna API de entorno; el acceso al documento y el acceso al sistema de ficheros se aíslan detrás de una interfaz declarada en común e implementada en cada lado. Es exactamente la disciplina de la multiplataforma aplicada a dos entornos que engañan porque hablan el mismo idioma.
// commonMain: la logica no sabe donde vive
interface AlmacenDePreferencias {
fun leer(clave: String): String?
fun escribir(clave: String, valor: String)
}
// El navegador lo resuelve con almacenamiento local;
// Node lo resuelve con un fichero. La logica no cambia.
class Ajustes(private val almacen: AlmacenDePreferencias) {
var idioma: String
get() = almacen.leer("idioma") ?: "es"
set(v) = almacen.escribir("idioma", v)
}
Hay además un matiz de seguridad que separa los dos entornos y que no tiene equivalente en la JVM: el navegador impone una política de origen que gobierna qué peticiones puede hacer el código y qué respuestas puede leer. Un módulo que funciona sin problemas bajo Node porque allí no existe esa política puede fallar de forma desconcertante en el navegador, y el error no estará en Kotlin sino en la configuración del servidor que responde.
Hay además una diferencia de pruebas que sorprende a quien llega desde la JVM: las pruebas del objetivo de navegador se ejecutan en un navegador real sin interfaz gráfica, lanzado por la construcción, con todo lo que eso implica en tiempo de arranque y en dependencias de la máquina de integración continua. Las de Node arrancan en un instante. Repartir bien qué se prueba en cada sitio es una decisión de coste, no de cobertura.
Merece la pena observar la asimetría que recorre toda esta lección, porque no es casual ni provisional. Hacia dentro, cuando Kotlin consume el ecosistema, el contrato lo posee el otro: el paquete de npm decide su forma, su versionado y su semántica, y Kotlin no puede hacer más que describir esa forma en una declaración que es una afirmación sin prueba. Hacia fuera, cuando Kotlin publica, el contrato lo posee Kotlin, y por eso ahí sí puede generar automáticamente unas definiciones de tipos fiables, porque proceden de código que el compilador conoce entero. La tentación de simetrizar la situación, generando declaraciones de Kotlin a partir de los ficheros de tipos de TypeScript, se intentó y no prosperó, y las razones son instructivas más allá de esta herramienta concreta: el sistema de tipos de TypeScript es estructural y admite construcciones como los tipos de unión, los tipos condicionales y los literales de cadena como tipos, que no tienen contraparte en un sistema nominal como el de Kotlin, de modo que la traducción no puede ser total y una traducción parcial de trescientas declaraciones produce un fichero enorme, ilegible y lleno de aproximaciones que fallan justo donde importa. Escribir a mano las cinco declaraciones que el proyecto usa no es una derrota de la automatización sino el reconocimiento de que la frontera con un ecosistema ajeno es un artefacto de diseño y no un subproducto de la compilación. El principio general vale para cualquier integración entre sistemas de tipos distintos, y también para el fichero de bloqueo y para la separación entre navegador y Node: allí donde la garantía no puede derivarse mecánicamente, lo correcto no es fabricar una garantía falsa sino hacer explícito y pequeño el lugar donde alguien tuvo que confiar.
- Añade un paquete de npm real, escribe sus declaraciones
externalcubriendo solo lo que uses y compara tu fichero con las definiciones de tipos originales del paquete. - Borra el fichero de bloqueo, reconstruye y examina qué cambió en el árbol instalado. Después restáuralo y explica a tu equipo por qué se versiona.
- Exporta una API con
@JsExport, publica el módulo y consúmelo desde un proyecto de TypeScript vacío. Anota cada fricción que encuentres. - Escribe una función que dependa del documento y otra que lea un fichero, y refactoriza ambas detrás de una interfaz común con dos implementaciones.
- Mide cuánto tarda la batería de pruebas en el navegador sin interfaz frente a la misma en Node y decide qué pruebas viven en cada objetivo.