wandres.dev
LA ENVIRONMENT API · multi-entorno y SSR

El concepto de environment: módulos aislados por destino

Qué es exactamente un environment en Vite: una configuración de módulos aislada por runtime de destino, con su propio grafo, sus condiciones de resolución y su pipeline de transforms. De un flag ssr:true a un objeto de primera clase; los entornos por defecto client y ssr y cómo declarar otros como workerd.

⏱ 15 min

Si la lección anterior fue el diagnóstico, esta es la definición. Un environment no es un modo ni un flag: es una configuración de módulos aislada, atada a un runtime de destino concreto. Tiene un nombre, unas condiciones de resolución, un grafo de módulos propio y un pipeline de transforms propio. El cliente es un environment; el SSR de Node es otro; workerd es un tercero. Entender qué contiene un environment y qué lo separa de sus vecinos es la llave conceptual de todo el nivel.

🎯 Al terminar esta lección sabrás
  • Definir environment como una configuración de módulos aislada por runtime de destino.
  • Identificar sus partes: nombre, condiciones de resolución, grafo propio y pipeline de transforms.
  • Distinguir los entornos por defecto client y ssr de los que declaras tú.
  • Declarar un entorno workerd en la config y entender qué lo aísla de los demás.

De un flag a un objeto de primera clase

En Vite 5 el “entorno” era un adjetivo del transform: pasabas ssr: true y la misma tubería se comportaba distinto. En la Environment API el entorno pasa a ser un sustantivo: un objeto con identidad, estado y ciclo de vida. En lugar de preguntar “¿estoy transformando para SSR?”, ahora preguntas “¿en qué entorno estoy?”, y la respuesta es una instancia con nombre —client, ssr, workerd— que lleva consigo toda su configuración.

Ese cambio de adjetivo a sustantivo es lo que permite pluralizar. Un adjetivo binario solo distingue dos casos; un objeto con nombre admite tantos como declares. Por eso la primera consecuencia de la API es que los entornos dejan de estar cableados en el server y pasan a vivir en un mapa, server.environments, indexado por nombre.

// De preguntar por un flag a preguntar por una instancia con nombre
const server = await createServer()
server.environments.client   // DevEnvironment del navegador
server.environments.ssr      // DevEnvironment de Node
server.environments.workerd  // DevEnvironment del edge, si lo declaraste

Anatomía de un environment

Un environment agrupa cuatro cosas que antes andaban dispersas o cableadas. Verlas por separado aclara qué lo convierte en un entorno y no en otro.

🏷️

Un nombre

La identidad del entorno —client, ssr, workerd—. Es la clave en server.environments y lo que consultas con this.environment.name dentro de un plugin.

🧭

Condiciones de resolución

Qué archivo de un paquete se importa. browser para el cliente, node para el SSR, workerd y worker para el edge. La misma dependencia puede resolver a ficheros distintos por entorno.

🕸️

Un grafo propio

Un EnvironmentModuleGraph aislado. Los módulos, sus transforms cacheados y sus relaciones no se comparten entre entornos: cada uno ve su propia versión del código.

🔧

Un pipeline propio

Los plugins y su orden aplicados a ese entorno. Un plugin puede activarse solo en workerd, o transformar distinto según this.environment, sin contaminar a los demás.

El aislamiento es la propiedad central. Que cada entorno tenga su propio grafo significa que un módulo transformado para el cliente y el mismo módulo transformado para workerd son entradas distintas, con contenido distinto, cacheadas por separado. No hay filtración entre destinos: precisamente el fallo que hacía que dev no coincidiera con producción cuando todo compartía un único grafo de SSR.

// Cada entorno trae su propio grafo y su propio transform
const ssr = server.environments.ssr
ssr.moduleGraph                            // EnvironmentModuleGraph, solo suyo
await ssr.transformRequest('/src/app.ts')  // con las condiciones y plugins de ssr
ssr.hot                                     // su canal de HMR, independiente

Declarar entornos: client, ssr y más allá

Por defecto Vite crea dos entornos, client y ssr, para no romper nada. Pero ahora son valores por defecto de una lista abierta, no un binario cerrado. Declarar un tercero es añadir una clave al objeto environments de la config.

// vite.config.ts
export default defineConfig({
  environments: {
    client: {},                                   // el navegador, por defecto
    ssr: {
      consumer: 'server',
      resolve: { conditions: ['node', 'import'] },
    },
    workerd: {
      consumer: 'server',
      resolve: { conditions: ['workerd', 'worker', 'browser', 'import'] },
    },
  },
})

Cada entrada describe un runtime. El campo consumer dice si el entorno lo consume un cliente (navegador) o un servidor, lo que cambia decisiones por defecto como si los assets se sirven o se empaquetan. Las conditions fijan cómo resuelve los paquetes: fíjate en que workerd pide primero su propia condición y luego cae a worker y browser, porque el edge se parece más al navegador que a Node. Con esas tres claves ya tienes tres grafos aislados, tres pipelines y tres formas de resolver, todos derivados de una config declarativa.

flowchart TB
cfg[environments en vite config] --> cl[client]
cfg --> sr[ssr]
cfg --> wk[workerd]
cl --> clg[grafo propio y condiciones browser]
sr --> srg[grafo propio y condiciones node]
wk --> wkg[grafo propio y condiciones workerd worker browser]
style cfg fill:#cba6f7,color:#11111b
style clg fill:#89b4fa,color:#11111b
style srg fill:#a6e3a1,color:#11111b
style wkg fill:#fab387,color:#11111b
ℹ️
client y ssr siguen ahí por compatibilidad, no por privilegio

Es tentador pensar que client y ssr son especiales. No lo son: son simplemente los dos entornos que Vite declara por ti para que los proyectos existentes sigan funcionando. Conceptualmente están al mismo nivel que cualquier entorno que definas. Esa igualdad es importante: significa que workerd o rsc no son ciudadanos de segunda clase parcheados sobre el sistema, sino instancias del mismo tipo con los mismos poderes. El día que tu app no tenga navegador —una función pura de edge, por ejemplo— podrías incluso prescindir de client. La API no privilegia ningún destino.

Cómo nace un entorno: herencia y factoría

Un entorno no se configura desde cero: hereda la config de nivel superior como base y luego la especializa. Lo que escribes en la raíz de defineConfigresolve, optimizeDeps, define— actúa como valor por defecto para todos los entornos, y cada entrada de environments solo necesita declarar en qué se desvía. Esa herencia evita repetir lo común y deja el bloque de cada entorno centrado en lo que de verdad lo distingue.

// La raiz es el valor por defecto; cada entorno solo declara su desviacion
export default defineConfig({
  resolve: { alias: { '@': '/src' } },   // comun a todos los entornos
  environments: {
    ssr: {
      resolve: { conditions: ['node', 'import'] },   // solo lo propio de ssr
    },
    workerd: {
      resolve: { conditions: ['workerd', 'worker', 'browser'] },
    },
  },
})

Conviene conocer la semántica de la mezcla, porque no siempre es intuitiva: los escalares del entorno sobrescriben a los de la raíz, pero listas como conditions no se fusionan silenciosamente, sino que el entorno fija las suyas. Saber si un campo hereda, sustituye o acumula es lo que evita sorpresas cuando un paquete resuelve a un fichero que no esperabas: casi siempre la causa está en qué condiciones ganó el entorno, no en la raíz.

La otra pieza es la factoría. Cada entorno declara cómo se instancia mediante un createEnvironment, distinto para dev y para build. Esa factoría decide de qué tipo concreto es la instancia: un RunnableDevEnvironment para lo que corre en el mismo proceso, o una implementación a medida —como la que provee el plugin de Cloudflare— para lo que corre dentro de workerd. Vite fija la forma común del entorno; la factoría rellena el “cómo” específico de cada runtime.

flowchart LR
base[config raiz como base] --> env[cada environment hereda y especializa]
env --> devf[createEnvironment en dev]
env --> bldf[createEnvironment en build]
style base fill:#cba6f7,color:#11111b
style env fill:#89b4fa,color:#11111b
style devf fill:#a6e3a1,color:#11111b
style bldf fill:#fab387,color:#11111b

Junta las dos ideas y tienes el retrato completo de un entorno: una config que hereda de la raíz y se especializa, y una factoría que sabe darle cuerpo en cada fase. Con eso, “declarar un entorno” deja de ser magia y pasa a ser un contrato claro —qué resuelve, qué transforma, cómo se instancia— que cualquier adaptador puede cumplir sin tocar el núcleo de Vite.

💡
Empieza por la raíz, desciende solo lo imprescindible

En la práctica, el error más común al estrenar entornos es duplicar en cada bloque lo que ya vale para todos. La regla sana es la contraria: pon en la raíz todo lo compartido y baja a un entorno únicamente los campos que de verdad cambian por runtime —casi siempre las conditions y poco más—. Cuanto más pequeño sea el bloque de cada entorno, más fácil es leer de un vistazo qué lo hace único y menos ocasiones tendrás de que dos entornos discrepen por accidente en algo que debería ser común.

Un environment es una frontera de aislamiento, y el aislamiento es el diseño

La idea que hay que interiorizar es que un environment no es una etiqueta que cuelgas de un transform, sino una frontera. Dentro de esa frontera vive un grafo de módulos completo con su propia identidad: el mismo archivo utils.ts puede existir a la vez en el grafo del cliente, en el de SSR y en el de workerd, y en cada uno ser un módulo distinto —resuelto con condiciones distintas, transformado por plugins distintos, cacheado por separado—. Esa multiplicidad no es un efecto secundario, es el objetivo. El error histórico fue asumir que “el módulo X” era una entidad única cuando en realidad hay tantos módulos X como runtimes lo ejecutan, cada uno con su forma. Al reificar el entorno en un objeto con nombre, condiciones, grafo y pipeline, Vite convierte esa verdad del dominio en una estructura de datos, y una vez que la verdad está en el tipo, todo lo demás se sigue: puedes tener N entornos porque environments es un mapa; puedes aislar un plugin porque el hook conoce this.environment; puedes garantizar paridad porque el mismo objeto describe el entorno en dev y en build. La profundidad está en el orden de las cosas: primero decides que el entorno es una frontera de aislamiento de primera clase, y de esa decisión emanan, sin fricción, la pluralidad, el aislamiento de plugins y la unificación de dev con build. Diseñar es elegir la abstracción de la que todo lo demás cae por gravedad, y aquí la abstracción elegida es la frontera.

⚔️ Modela tus entornos
  1. Declara en un vite.config.ts de juguete tres entornos: client, ssr y workerd, con sus condiciones.
  2. Arranca el dev server e inspecciona server.environments: comprueba que hay una instancia por cada uno.
  3. Explica qué significa que cada entorno tenga su propio EnvironmentModuleGraph y por qué eso elimina filtraciones entre destinos.
  4. Razona por qué workerd resuelve con worker y browser antes que con node.
  5. Argumenta por qué client y ssr no son privilegiados, sino solo dos entornos declarados por defecto.