Saltar al contenido

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.

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.

TypeScript
// data/usuarios.ts"use server";import { db } from "../server/db";        // alcanzable SOLO desde cuerpos de servidorimport { API_KEY } from "../server/env";   // por tanto, jamas entra al bundle del clienteexport 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.

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.

TypeScript
// PELIGRO: modulo isomorfo con un secreto en el nivel superiorconst 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.

TypeScript
// utils/index.ts  —  barril peligrosoexport { formatear } from "./formato";     // inofensivo, isomorfoexport { 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.

TypeScript
const publico = import.meta.env.VITE_ANALYTICS_ID;   // visible en el cliente: es publicoconst 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.

TypeScript
// server/db.ts  —  guarda defensiva contra imports accidentales del clienteimport { 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.