La frontera cliente/servidor y el secreto que no debe filtrarse
La directiva `use server` no solo cambia dónde corre el código: instruye al bundler para partir la aplicación en dos grafos. El cuerpo de la función y todo lo que solo él importa —el cliente de base de datos, las claves de API— se extraen al bundle del servidor y desaparecen del que llega al navegador. Se estudia cómo el compilador hace ese corte por alcanzabilidad, por qué el código de nivel de módulo e isomorfo sí puede filtrar secretos, el papel de `import.meta.env` y sus prefijos, por qué los helpers HTTP de Vinxi no hacen treeshaking, y cómo `isServer` marca las ramas que jamás deben llegar al cliente.
La consecuencia más valiosa de "use server" no es dónde corre el código, sino qué código deja de existir en el navegador. La directiva instruye al bundler para partir la aplicación en dos grafos de dependencias: uno para el servidor, con el cuerpo real y todo lo que arrastra —el cliente de base de datos, las claves de API—, y otro para el cliente, del que esas piezas simplemente desaparecen. Esa separación es tu principal muralla de seguridad, y como toda muralla, tiene puertas por las que un secreto puede escaparse si no las conoces. Esta lección explica cómo el compilador traza el corte y dónde acecha la filtración.
- Entender que
"use server"divide la aplicación en dos grafos de dependencias, no solo en dos entornos de ejecución. - Ver cómo el bundler extrae por alcanzabilidad el cuerpo del servidor y lo que solo él importa, dejándolo fuera del cliente.
- Localizar las puertas de filtración: código de nivel de módulo, código isomorfo y variables de entorno mal prefijadas.
- Usar
import.meta.envcon sus prefijos eisServerpara que ningún secreto ni lógica sensible cruce al navegador.
Dos grafos, no dos carpetas
La separación cliente/servidor de SolidStart no se basa en carpetas ni en convenciones de nombres: se basa en alcanzabilidad desde cada punto de entrada. El bundler parte de la raíz del cliente y sigue los imports; cuando llega a una función marcada con "use server", no cruza al cuerpo —lo reemplaza por el stub de la lección anterior— y, por tanto, nunca visita lo que ese cuerpo importa. Un módulo que solo es alcanzable a través de cuerpos de servidor queda íntegramente fuera del bundle del cliente. Ese es el mecanismo por el que tu cliente de base de datos no se filtra: nada en el grafo del cliente lo alcanza.
// data/usuarios.ts
"use server";
import { db } from "../server/db"; // alcanzable SOLO desde cuerpos de servidor
import { API_KEY } from "../server/env"; // por tanto, jamas entra al bundle del cliente
export async function buscar(q: string) {
return db.usuarios.buscar(q, API_KEY);
}
Observa el archivo entero: nada de lo que importa —db, API_KEY— aparece en el grafo del cliente, no porque lo hayamos escondido, sino porque el único camino hasta ellos pasa por un cuerpo de servidor que el bundler no atraviesa. La invisibilidad es una consecuencia estructural, no una capa de ofuscación que se añade encima.
flowchart TD R[raiz del cliente] --> U[interfaz y componentes] U -.->|import a funcion de servidor| ST[stub del RPC] ST -->|el bundler corta aqui| X[cuerpo real: db y claves] X --> DB[cliente de base de datos] X --> K[claves de API] style ST fill:#89b4fa,color:#11111b style X fill:#a6e3a1,color:#11111b style K fill:#f38ba8,color:#11111b
Conviene subrayar que hablamos literalmente de dos compilaciones con dos puntos de entrada distintos, no de un mismo bundle etiquetado por entornos. El servidor parte de su entrada y produce un artefacto; el cliente parte de la suya y produce otro. Un módulo puede acabar en uno, en el otro, en ambos o en ninguno, según quién lo alcance. Pensar en «dos grafos» —y no en la vaga dualidad «código de servidor y código de cliente»— es lo que te da la precisión para razonar dónde termina realmente cada archivo.
Ese encuadre explica también por qué la frontera no coincide con las carpetas: un archivo en src/lib puede acabar solo en el servidor, solo en el cliente o en ambos según quién lo importe. La ubicación en disco es una pista para humanos; la pertenencia real la decide el grafo. Cuando lo internalizas dejas de preguntar «¿este archivo es de cliente o de servidor?» y empiezas a preguntar «¿desde dónde es alcanzable?», que es la única de las dos que tiene respuesta objetiva.
Las puertas por las que se filtra un secreto
El corte por alcanzabilidad es sólido, pero tiene un supuesto: que el secreto solo sea alcanzable desde código de servidor. En cuanto una referencia lo pone también al alcance del cliente, la muralla tiene una puerta abierta. La primera puerta es el código de nivel de módulo: lo que escribes fuera de toda función se ejecuta al importar el módulo, y si ese módulo es isomorfo —lo importan tanto el cliente como el servidor—, ese código corre en ambos.
// PELIGRO: modulo isomorfo con un secreto en el nivel superior
const CLAVE = process.env.STRIPE_SECRET; // si este archivo lo importa el cliente,
export function formatear(precio: number) { // la CLAVE acaba en el bundle del navegador
return `${precio} EUR`;
}
La segunda puerta es cerrar sobre un secreto desde código que sí cruza. Si una función de servidor devuelve un objeto que, por descuido, incluye una clave, esa clave se serializa y viaja al cliente igual que cualquier otro dato: la frontera de datos de la lección anterior no distingue un secreto de un valor cualquiera.
La tercera puerta es más sutil y más frecuente de lo que parece: los archivos barril. Un index.ts que reexporta a la vez utilidades de cliente y de servidor teje un puente de alcanzabilidad, porque importar la utilidad inocente arrastra al grafo todo lo que el barril toca. El bundler seguirá cada reexportación sin juzgar tu intención, y basta una para que un módulo de servidor entre por la puerta de atrás al bundle del cliente.
// utils/index.ts — barril peligroso
export { formatear } from "./formato"; // inofensivo, isomorfo
export { db } from "../server/db"; // arrastra el cliente de base de datos
// importar solo { formatear } desde el barril puede alcanzar db igualmente
La regla que cierra las tres puertas es una sola: un secreto solo debe ser alcanzable y legible dentro de un cuerpo de servidor —nunca en el nivel de módulo de un archivo isomorfo, ni en un valor de retorno, ni a través de un barril que lo reexporte—.
La disciplina positiva que se deduce es de organización: reúne lo que es solo de servidor en una carpeta —server/— cuyos archivos lleven la directiva de archivo, no la mezcles con utilidades isomorfas, y evita barriles que crucen esa línea. Convención de carpetas y directiva de archivo forman dos capas que se refuerzan: la primera te ayuda a ver la frontera de un vistazo, la segunda hace que el compilador la imponga aunque te despistes.
Variables de entorno: el prefijo decide quién las ve
SolidStart —vía Vite— formaliza esta frontera en las variables de entorno con import.meta.env. Por defecto, una variable es solo de servidor: no se inyecta en el bundle del cliente. Solo las que llevan el prefijo público —VITE_— se exponen al navegador, y lo hacen a propósito. El prefijo es, literalmente, tu declaración de intención: escribirlo es decir «esto puede ser público». Todo lo que no lo lleve permanece invisible para el cliente.
const publico = import.meta.env.VITE_ANALYTICS_ID; // visible en el cliente: es publico
const secreto = import.meta.env.DATABASE_URL; // sin prefijo: solo en el servidor
Un matiz que evita sorpresas: las variables públicas se incrustan en el bundle en tiempo de build, no se leen en ejecución. Su valor queda congelado en el artefacto del cliente, de modo que rotar una de ellas exige recompilar, no solo reiniciar el proceso. Las de servidor, en cambio, se leen del entorno del proceso vivo y pueden cambiar sin reconstruir. La asimetría es coherente con la frontera: lo que el cliente ve lo ve fijado desde el build; lo que solo el servidor ve puede mudar en caliente.
Hay una trampa relacionada con las herramientas de bajo nivel. Los helpers HTTP de Vinxi —los que dan acceso a cookies o cabeceras crudas— no hacen treeshaking: si los importas en un archivo que también contiene código de cliente o isomorfo, arrastras código de servidor al bundle del navegador. Por eso se importan únicamente en archivos que jamás toca el cliente, exactamente los que marcas con la directiva de archivo.
Fallar en build, no en producción
La alcanzabilidad es una propiedad que el bundler calcula en silencio, y ese silencio es un riesgo: el día que un import descuidado vuelva alcanzable un módulo de servidor desde el cliente, no habrá ningún error —solo un secreto de más en el bundle—. La defensa madura convierte ese fallo silencioso en un fallo ruidoso poniendo una guarda de módulo en la cima de los archivos que jamás deben cruzar. Como isServer es estáticamente falso en el build del cliente, la guarda o bien desaparece con el módulo entero, o bien —si algo lo arrastró— explota de forma visible.
// server/db.ts — guarda defensiva contra imports accidentales del cliente
import { isServer } from "solid-js/web";
if (!isServer) {
throw new Error("server/db.ts es solo de servidor: no lo importes desde el cliente");
}
export const db = crearCliente(import.meta.env.DATABASE_URL);
El valor de esta guarda no es que oculte el secreto —de eso ya se encarga el corte por alcanzabilidad— sino que transforma la clase de error: pasa de una filtración silenciosa que solo descubres auditando el bundle a un fallo temprano que te grita en cuanto rompes la premisa. Es el principio de fallar rápido aplicado a la frontera de seguridad: prefieres mil veces un build que no compila a una clave que viaja sin que nadie lo note.
Esta filosofía escala a la integración continua. Un paso que rastree en el bundle del cliente las cadenas que jamás deberían aparecer —nombres de variables secretas, prefijos de claves conocidas— y detenga la entrega si las encuentra convierte la verificación manual del dios de abajo en una barrera automática. Así, ninguna regresión futura reabre en silencio una puerta que hoy cierras a conciencia: el grafo se audita solo en cada despliegue, y la propiedad «ningún secreto en el navegador» deja de depender de que alguien se acuerde de comprobarla.
Hay una asimetría que conviene aceptar sin resistencia: proteger un secreto es fácil —basta con no volverlo alcanzable—, pero demostrar que sigue protegido tras cada cambio exige disciplina activa. El compilador te da la garantía puntual; mantenerla en el tiempo, mientras el código crece y los imports se enredan, es cosa de tus convenciones y tu automatización. La frontera rara vez se rompe de golpe: se erosiona import a import, y por eso vigilarla es un hábito continuo, no un acto único que se firma y se olvida.
Corte por alcanzabilidad
El bundler no cruza al cuerpo de servidor; lo que solo ese cuerpo importa nunca entra al grafo del cliente.
Puertas de filtración
Código de nivel de módulo isomorfo y secretos en valores de retorno: dos formas de hacer alcanzable lo que no debía.
El prefijo manda
import.meta.env esconde toda variable salvo las prefijadas con VITE_, que declaras públicas a propósito.
El resultado físico del corte es tangible: el build genera dos artefactos independientes, el del servidor y el del cliente, cada uno partiendo de su propio punto de entrada. La misma línea de código puede terminar en uno, en los dos o en ninguno según la alcanzabilidad, así que la pregunta «¿esto llega al navegador?» tiene siempre una respuesta objetiva. Cuando dudes, no razones sobre intenciones: abre el artefacto del cliente y busca el símbolo. La frontera no es una promesa abstracta, es un archivo concreto que puedes inspeccionar línea a línea.
Cuando un mismo archivo necesita una rama que solo tiene sentido en el servidor, isServer de solid-js/web es una constante que el bundler evalúa en build. Al envolver la rama sensible en if (isServer), el bundler puede eliminarla por completo del bundle del cliente en lugar de enviar código muerto o —peor— una referencia que arrastre secretos. No es un condicional de tiempo de ejecución cualquiera: es una guía para el treeshaking. Úsalo para separar lo que legítimamente pertenece al proceso servidor de lo que pertenece a la pestaña, y deja que el compilador pode la mitad que sobra en cada bundle.
Conviene decirlo sin adornos: la separación cliente/servidor no es una promesa moral del framework, es un hecho del grafo de dependencias, y tu seguridad vale exactamente lo que el bundler es capaz de demostrar sobre ese grafo. El compilador garantiza una sola cosa, pero la garantiza con rigor mecánico: lo que solo es alcanzable a través de un cuerpo de servidor no aparece en el bundle del cliente. Todo tu trabajo consiste en no romper esa premisa de alcanzabilidad. Cada vez que un secreto se vuelve alcanzable desde el grafo del cliente —porque lo pusiste en el nivel de módulo de un archivo que el cliente importa, porque lo devolviste sin querer en un objeto que se serializa, porque le quitaste el hueco de invisibilidad al ponerle el prefijo público— has abierto tú la puerta, no el framework. Por eso la mentalidad correcta no es «confío en que Solid oculte mis claves», sino «diseño mi grafo para que mis claves sean indemostrablemente inalcanzables desde el cliente». Esa inversión de responsabilidad es liberadora una vez asumida: dejas de esperar que una herramienta adivine tus intenciones y empiezas a estructurar el código para que la propiedad que quieres —ningún secreto en el navegador— sea una consecuencia que el bundler puede probar, no una esperanza que tú mantienes. Verifícalo, además, como se verifica un teorema: abre el bundle del cliente, busca la cadena de tu clave, y que no aparezca sea tu prueba, no tu fe.
- Crea un archivo de datos con la directiva de archivo que importe un cliente de base de datos y una clave; compila y busca ambos en el bundle del cliente para confirmar que no están.
- Pon a propósito un secreto en el nivel de módulo de un archivo isomorfo, compila y encuéntralo en el bundle del navegador; luego muévelo dentro de un cuerpo de servidor y verifica que desaparece.
- Devuelve por descuido un objeto que incluya una clave desde una función de servidor y detéctala en la respuesta de red; corrige la firma para que no viaje.
- Declara dos variables de entorno, una con prefijo
VITE_y otra sin él, e inspecciona cuál acaba en el cliente. - Envuelve una rama sensible en
if (isServer)y compara el bundle del cliente antes y después para ver la poda.