El sistema de plugins de Rollup: el estándar heredado
El sistema de plugins de Rollup se volvió el estándar de facto del empaquetado: Vite lo extiende como superconjunto y Rolldown lo reimplementa en Rust. Su arquitectura en dos fases —los hooks de build que construyen el grafo y los hooks de output que generan el bundle—, los tres tipos de hook —primero que gana, secuencial, paralelo—, y el contexto `this` que convierte cada hook en una ventana al bundler. Los hooks principales de cada fase y por qué este contrato sobrevive al cambio de motor.
Rollup casi no hace nada por sí solo: su núcleo construye un grafo y emite bytes, y todo lo demás —compilar TypeScript, resolver alias, inyectar datos, cargar formatos raros— lo hacen plugins colgados de una serie de hooks bien definidos. Ese sistema de hooks resultó tan acertado que se volvió el estándar de facto del empaquetado: Vite lo adoptó como superconjunto y Rolldown lo reimplementa en Rust sin cambiar el contrato. Entender su arquitectura —dos fases, tres tipos de hook y un contexto compartido— es entender el lenguaje común de toda una generación de herramientas.
- Ver un plugin como un objeto con
namey hooks repartidos en dos fases. - Distinguir los hooks de build —que construyen el grafo— de los de output —que generan el bundle—.
- Clasificar los hooks en primero que gana, secuenciales y paralelos.
- Reconocer el contexto
thiscomo la ventana del plugin al bundler.
Un plugin y sus dos fases
Un plugin de Rollup es un objeto plano: lo único obligatorio es name, y el resto son funciones-hook que el bundler invoca en momentos concretos. Por convención se envuelve en una factoría que acepta opciones y devuelve el plugin, para poder parametrizarlo al usarlo. No hay clases, ni ciclo de vida propio, ni magia: solo un contrato de funciones con nombres reservados.
export function miPlugin(opciones = {}) {
return {
name: 'mi-plugin',
buildStart() {
this.info('empieza la fase de build');
},
generateBundle(output, bundle) {
this.info('empieza la fase de output');
},
};
}
La clave arquitectónica es que esos hooks se reparten en dos fases nítidas. Primero corre la fase de build: Rollup parte de los puntos de entrada, resuelve y carga cada módulo, lo transforma, parsea su AST y descubre sus imports, y así teje el grafo completo. Solo cuando el grafo está cerrado empieza la fase de output: Rollup trocea ese grafo en chunks, les da forma según el output.format, y escribe los archivos. Un mismo build puede tener una sola fase de build y varias de output —una por cada entrada del array output—.
Hooks de build y hooks de output
Los hooks de build son los que has visto ya en el nivel anterior aplicados a Vite: options normaliza la configuración, buildStart marca el arranque, resolveId traduce un especificador a un id, load entrega el fuente, transform lo reescribe, moduleParsed se dispara cuando un módulo ya tiene su AST, y buildEnd cierra la fase. La recursión sobre los imports descubiertos ocurre entre transform y moduleParsed, y de repetirla emerge el grafo.
Los hooks de output pertenecen a un mundo distinto, el de dar forma al artefacto. outputOptions ajusta las opciones de salida, renderStart abre la generación, renderChunk reescribe cada chunk ya troceado —el momento de inyectar un banner o de transformar el código final—, generateBundle recibe el bundle completo en memoria justo antes de escribirlo —ideal para emitir un manifest o un archivo extra—, y writeBundle corre después de que los archivos toquen el disco. closeBundle cierra todo al final.
Esa separación no es burocrática: delimita qué puedes hacer y cuándo. En build tienes acceso al grafo de módulos pero aún no a los chunks; en output tienes los chunks pero el grafo ya está congelado. Un plugin que intenta emitir un chunk nuevo lo hace en build con this.emitFile; uno que quiere leer el resultado final lo hace en generateBundle. Confundir la fase es el error de diseño más común al escribir un plugin.
El orden dentro de cada fase es fijo, y memorizar la secuencia ayuda a saber dónde engancharse:
- Build:
options,buildStart,resolveId,load,transform,moduleParsedybuildEnd. - Output:
renderStart,renderChunk,generateBundle,writeBundleycloseBundle.
flowchart LR subgraph Fase_de_build O1[options] --> BS[buildStart] BS --> RI[resolveId] RI --> LD[load] LD --> TR[transform] TR --> MP[moduleParsed] MP --> BE[buildEnd] end subgraph Fase_de_output RS[renderStart] --> RC[renderChunk] RC --> GB[generateBundle] GB --> WB[writeBundle] end BE --> RS
La API de plugins de Vite es un superconjunto estricto de la de Rollup: todos estos hooks de build y de output funcionan igual dentro de un plugin de Vite. Encima, Vite inyecta los suyos para lo que un bundler puro no cubre —config y configResolved para la configuración, configureServer para el dev server, transformIndexHtml para el HTML de entrada, y handleHotUpdate para el HMR—. Por eso un plugin escrito contra los hooks de Rollup suele funcionar en Vite sin cambios: no compite con la base, se apoya en ella.
Tipos de hook y el contexto this
Con decenas de plugins activos, importa tanto qué hace cada hook como cómo se combinan varios sobre el mismo punto. Rollup clasifica los hooks en tres tipos según esa combinación. Los de primero que gana —resolveId, load— modelan decisiones únicas: Rollup recorre los plugins y se queda con el primer resultado no nulo, porque la identidad y el contenido de un módulo son una sola cosa. Los secuenciales —transform, renderChunk— modelan una tubería: corren todos en orden y la salida de uno alimenta al siguiente. Los paralelos —buildStart, buildEnd, writeBundle— solo reaccionan a un momento, no compiten ni encadenan, así que Rollup los lanza a la vez.
Saber a qué tipo pertenece un hook te dice de antemano tu papel: si compites por resolver, si colaboras en una cadena, o si solo reaccionas. Y para afinar el orden dentro de un tipo, cualquier hook admite su forma de objeto con order —pre, post o nulo— y handler, más quirúrgica que mover el plugin entero.
El contraste se ve en dos hooks: uno decide, el otro encadena.
// first-wins: el primer resultado no nulo cierra la pregunta
resolveId(source) {
return source === 'virtual:datos' ? '\0virtual:datos' : null;
}
// secuencial: corren todos y la salida de uno alimenta al siguiente
transform(code, id) {
return id.endsWith('.especial') ? { code: reescribir(code), map: null } : null;
}
Dentro de cualquier hook, this es la ventana del plugin al bundler. No es una función suelta que recibe datos: puede pedirle cosas al motor mientras corre.
this.resolveythis.loadinvocan la resolución y la carga de otro módulo desde dentro del tuyo.this.emitFileañade un chunk o un asset a la salida, ythis.getFileNamerecupera su ruta final.this.getModuleInfoythis.parseinspeccionan el grafo y producen un AST sin reimplementar un parser.this.addWatchFilevigila un archivo externo, ythis.errorythis.warnemiten diagnósticos ubicados.
Hooks de build
options, buildStart, resolveId, load, transform, moduleParsed, buildEnd. Construyen el grafo desde las entradas.
Hooks de output
renderStart, renderChunk, generateBundle, writeBundle, closeBundle. Trocean el grafo y escriben el artefacto.
Tres tipos
Primero que gana decide, secuencial encadena, paralelo reacciona. El tipo predice tu papel frente a los demás plugins.
El contexto this
resolve, load, emitFile, getModuleInfo, parse, addWatchFile, error, warn: pedirle cosas al bundler en marcha.
Marcar un plugin entero como prioritario es un instrumento grueso: mueve todos sus hooks de golpe. Cuando solo necesitas que un hook concreto se adelante o se atrase, escríbelo en su forma de objeto { order: 'pre', handler() {} }. Reordenas esa fase sin arrastrar el resto del plugin, y evitas el efecto colateral de que un transform que querías temprano arrastre también a un renderChunk que estaba bien donde estaba.
La lección estratégica de este nivel se cristaliza aquí. Rollup no ganó por ser el bundler más rápido —nunca lo fue—, sino por definir un contrato de extensión tan limpio que sobrevivió a todos sus motores. Fíjate en la cadena: Rollup diseña el sistema de hooks; Vite lo adopta como superconjunto en vez de inventar el suyo, y hereda de golpe un ecosistema entero de plugins ya escritos; Rolldown reimplementa ese mismo contrato en Rust sobre Oxc, y añade hook filters para que declares por adelantado qué módulos te incumben y el motor no cruce la frontera entre Rust y JavaScript por los que no. En ninguno de esos saltos cambió lo esencial: sigue siendo un objeto con name, hooks repartidos en dos fases, tres tipos de combinación, y un this que habla con el motor. Un plugin bien escrito contra este contrato en 2018 sigue encajando en 2026, aunque debajo hayan cambiado dos lenguajes de programación. Esa es la propiedad que debes internalizar: cuando la extensibilidad se diseña como un conjunto pequeño de hooks ordenados en lugar de como una API monolítica, el núcleo se puede reescribir entero sin traicionar a quien construyó encima. Deja de aprender la herramienta de moda y aprende el contrato que las herramientas comparten; lo primero caduca cada dos años, lo segundo te mantiene vigente a través de todas las reescrituras.
- Escribe un plugin con un
buildStarty ungenerateBundle, y registra en cuál se ejecuta cada uno para sentir la frontera entre fases. - Usa
this.emitFileen un hook de build para añadir un asset y recupera su ruta conthis.getFileNameengenerateBundle. - Clasifica
resolveId,transformywriteBundleen su tipo —primero que gana, secuencial, paralelo— y justifica cada uno. - Convierte un
transforma su forma de objeto conorder: 'pre'y razona qué cambia frente a marcar el plugin entero. - Explica en dos frases por qué un plugin de Rollup suele funcionar en Vite y en Rolldown sin tocar una línea.