Serialización: qué cruza la frontera y qué no
Toda función de servidor es una frontera de red, y por una red solo viajan bytes: argumentos y retorno tienen que serializarse. SolidStart no usa `JSON.stringify` sino Seroval, que amplía el vocabulario a `Date`, `Map`, `Set`, `BigInt`, arrays tipados, referencias circulares y `Promise` en streaming, además de tipos de plataforma como `FormData`, `Headers`, `ReadableStream`, `Request`, `Response` y `URL`. Se estudia qué cruza, qué queda excluido —funciones, instancias de clase con métodos, `RegExp` por defecto—, los modos `json` frente a `js` y su relación con la CSP, y por qué pensar en valores y no en referencias es la disciplina que evita errores en la frontera.
Si una función de servidor es un RPC, entonces sus argumentos y su retorno no pasan: se serializan, viajan como bytes y se reconstruyen del otro lado. Esa reconstrucción tiene límites, y conocerlos es la diferencia entre una frontera que funciona y un error opaco en tiempo de ejecución. SolidStart no se conforma con JSON.stringify: usa Seroval, un serializador que entiende Date, Map, Set, BigInt, referencias circulares y hasta Promise que resuelven en streaming. Pero ni Seroval serializa funciones ni instancias vivas. Esta lección traza el mapa exacto de qué cruza la frontera y qué se queda en la orilla.
- Entender que cruzar la frontera es serializar, no pasar una referencia: solo viajan datos reconstruibles.
- Enumerar lo que Seroval sí soporta: primitivos ricos, colecciones, ciclos,
Promiseen streaming y tipos de plataforma web. - Reconocer lo que no cruza: funciones, instancias de clase con métodos, símbolos,
RegExppor defecto. - Elegir entre los modos
jsonyjssegún el compromiso entre tamaño de payload y política de CSP.
Serializar no es pasar: es copiar por valor
En una llamada de función normal, un objeto se pasa por referencia: ambos lados comparten la misma identidad en memoria. En un RPC eso es imposible, porque no hay memoria compartida entre el cliente y el servidor. Lo que ocurre es una copia por valor: el objeto se convierte en una representación textual, viaja, y del otro lado se materializa un objeto nuevo, equivalente pero distinto. Por eso la primera regla mental es dejar de pensar en referencias y empezar a pensar en valores: si algo no se puede describir enteramente como datos, no puede cruzar.
// data/carrito.ts
"use server";
export async function guardar(carrito: { items: string[]; total: number }) {
// 'carrito' aqui es una RECONSTRUCCION, no el mismo objeto del cliente
return db.carritos.upsert(carrito);
}
El anotado del argumento como { items: string[]; total: number } no es cosmético: describir la entrada con un tipo plano y explícito es tu forma de comprometerte a que solo cruzarán datos. En cuanto la firma admite algo con comportamiento —una función, una instancia viva— has prometido algo que la red no puede cumplir, y el incumplimiento aflora en ejecución, no en compilación, que es el peor momento para enterarse.
flowchart LR A[valor en el cliente] -->|seroval serializa| B[texto que viaja por la red] B -->|seroval deserializa| C[valor nuevo en el servidor] C -->|ejecuta y devuelve| D[retorno serializado] D -->|deserializa| E[valor nuevo en el cliente] style A fill:#89b4fa,color:#11111b style C fill:#a6e3a1,color:#11111b style E fill:#89b4fa,color:#11111b
Esta copia por valor tiene una consecuencia que sorprende a quien viene de pasar objetos entre módulos: la identidad no cruza. Seroval preserva las referencias internas de un mismo payload —si envías dos veces el mismo objeto, del otro lado siguen siendo uno—, pero un objeto que existe a la vez en el cliente y en el servidor son, tras el viaje, dos entidades distintas que casualmente contienen los mismos datos. Mutar una no toca a la otra. Programar la frontera es aceptar que del otro lado no está tu objeto, sino su retrato fiel.
El vocabulario ampliado de Seroval
Aquí SolidStart se separa del resto. Un serializador basado en JSON solo conoce objetos, arrays, cadenas, números, booleanos y null; pierde undefined, no sabe de fechas y explota con referencias circulares. Seroval amplía ese vocabulario de forma notable: cruzan undefined, Date, Map, Set, BigInt, los arrays tipados como Uint8Array, y —lo más llamativo— referencias circulares, que reconstruye fielmente. Todavía más potente: una Promise puede cruzar la frontera y resolverse en el cliente porque Seroval hace streaming del payload; el retorno empieza a llegar antes de que cada promesa interna termine.
"use server";
export async function panel() {
return {
generado: new Date(), // Date cruza intacta
vistas: new Map([["home", 42]]), // Map cruza como Map
// la lista tarda: viaja como Promise y se resuelve por streaming en el cliente
populares: cargarPopulares(), // Promise<string[]>
};
}
El streaming de promesas merece detenerse. Seroval no espera a que todas resuelvan antes de responder: emite la estructura de inmediato con huecos y los rellena por el mismo canal a medida que cada promesa se cumple. El cliente recibe el esqueleto pronto y los datos lentos después, lo que encaja como un guante con Suspense —la interfaz pinta lo disponible y suspende solo donde falta—. Una sola frontera transporta así datos síncronos y asíncronos sin que orquestes nada a mano.
Sobre esa base, SolidStart habilita por defecto un conjunto de plugins para tipos de la plataforma web, de modo que también cruzan FormData, Headers, ReadableStream, Request, Response, URL, URLSearchParams, Blob, File, AbortSignal y algunos eventos. Eso convierte a FormData en un ciudadano de primera clase de la frontera: puedes recibir el contenido de un formulario tal cual, sin traducirlo a un objeto plano.
export async function subir(form: FormData) {
"use server";
const adjunto = form.get("adjunto"); // File, gracias al plugin de plataforma
const titulo = String(form.get("titulo"));
return guardarArchivo(titulo, adjunto as File);
}
Que FormData, File y Blob crucen intactos es lo que permite recibir el envío de un formulario tal como llega del navegador, sin capa de traducción; el mismo mecanismo sostiene que un formulario funcione aun sin JavaScript, porque su POST nativo transporta un FormData que la función de servidor entiende de fábrica. Ese es el cimiento del realce progresivo que verás con action.
Lo que se queda en la orilla
El mapa se completa con lo que no cruza, y todos los casos comparten una raíz: son cosas cuya esencia es comportamiento o identidad, no datos. Una función no serializa —su cuerpo es código, no un valor—, así que no puedes pasar un callback al servidor ni devolver uno al cliente. Una instancia de clase cruza a lo sumo sus datos enumerables, pero pierde su prototipo: al reconstruirse es un objeto plano sin sus métodos, lo que casi siempre no es lo que quieres. Los símbolos y los nodos del DOM tampoco tienen representación transportable. Y RegExp está deshabilitado por defecto por razones de seguridad, aunque el motor sabría representarlo.
class Usuario {
constructor(public nombre: string, private saldo: number) {}
puedePagar(x: number) { return this.saldo >= x; }
}
export async function cargar(): Promise<Usuario> {
"use server";
return new Usuario("Ana", 100); // cruza como { nombre }: sin saldo privado ni metodo
}
// en el cliente: usuario.puedePagar es undefined; invocarlo lanza en runtime
El arreglo no es pelear contra la frontera sino diseñar para ella: devuelve un objeto plano —un DTO— con exactamente los datos que el cliente necesita, y deja el comportamiento donde vive el estado. Si el cliente precisa puedePagar, o expones ese cálculo como otra función de servidor, o le envías el dato que le permita calcularlo por su cuenta. Nunca esperes que un método sobreviva al viaje: el viaje transporta lo que la clase tiene, jamás lo que la clase sabe hacer.
Cruzan sin esfuerzo
Primitivos y undefined, Date, Map, Set, BigInt, arrays tipados, ciclos y Promise en streaming.
Tipos de plataforma
FormData, Headers, ReadableStream, Request, Response, URL y URLSearchParams, habilitados por defecto.
Se quedan fuera
Funciones, instancias vivas con métodos, símbolos, nodos del DOM y RegExp por defecto.
El error más silencioso de la frontera es devolver una instancia de clase y esperar sus métodos del otro lado. Lo que cruza son sus propiedades enumerables; el prototipo se pierde, así que en el cliente recibes un objeto con los mismos datos pero sin comportamiento —llamar a un método reconstruido lanza is not a function—. La disciplina es devolver datos planos desde las funciones de servidor y reconstruir el comportamiento en el cliente si lo necesitas, o exponer métodos como funciones de servidor propias. Trata el retorno como un DTO, nunca como un objeto de dominio vivo.
Dos modos: json frente a js
Seroval puede emitir su payload de dos maneras, y la elección tiene consecuencias de seguridad. En modo json el cliente reconstruye con JSON.parse, lo que evita eval y encaja con una CSP estricta, a cambio de payloads algo mayores. En modo js el cliente usa el serializador JS de Seroval, más compacto y rápido, pero que requiere unsafe-eval en la CSP. SolidStart 2 elige json por defecto justamente por compatibilidad con CSP; la versión 1 mantenía js por retrocompatibilidad. En modo json hay además un límite de profundidad de 64 niveles: si lo superas, aplana la estructura.
// app.config.ts — fijar el modo de serializacion explicitamente
import { defineConfig } from "@solidjs/start/config";
export default defineConfig({
serialization: { mode: "json" }, // sin eval; ideal con CSP estricta
});
La lectura profunda es que el modo de serialización no es un detalle de rendimiento aislado: es un punto donde la frontera de datos se cruza con la política de seguridad de la página. Elegir json cuando tu CSP prohíbe unsafe-eval no es una optimización, es una condición de que la aplicación arranque siquiera.
Dos límites concretos conviene tener en el radar. RegExp está desactivado por defecto —aunque Seroval sabría representarlo— y cruzarlo exige habilitarlo a propósito; casi siempre es señal de que deberías enviar el patrón como cadena y compilarlo en cada orilla. Y el modo json impone una profundidad máxima de 64 niveles: estructuras más hondas hay que aplanarlas o simplificarlas antes de devolverlas. Ninguno de los dos es un capricho: ambos empujan en la misma dirección —payloads simples, planos y predecibles—, que es justo la forma que la frontera premia.
Todo lo que confunde de la serialización se disuelve al aceptar una única idea: la frontera de una función de servidor no transporta objetos, transporta descripciones de objetos. Un objeto, del lado del que sea, es dos cosas a la vez —datos e identidad, propiedades y comportamiento—, y por la red solo puede viajar la primera mitad. Seroval es generoso describiendo datos: sabe decir «esto es una fecha», «esto es un mapa con estas entradas», «este objeto se referencia a sí mismo», e incluso «esto es una promesa, te mando el resto cuando resuelva». Pero ninguna descripción puede capturar comportamiento: no hay forma de escribir en texto «y además sabe hacer esto», porque hacer algo es ejecutar código, y el código no es un valor que se copie. De ahí se deducen sin esfuerzo todas las exclusiones: las funciones no cruzan porque son puro comportamiento; las instancias pierden sus métodos porque el método vive en el prototipo, que es comportamiento; los nodos del DOM no cruzan porque su identidad está atada a un documento que no existe del otro lado. Y de ahí se deduce también la disciplina positiva: diseña las firmas de tus funciones de servidor como si conversaran por carta, no por teléfono. Manda datos autocontenidos —planos, descriptibles, sin dependencias vivas—, recibe datos autocontenidos, y reconstruye el comportamiento en cada orilla con el código que ya vive en ella. Cuando piensas la frontera en valores y no en referencias, dejas de tropezar con errores de serialización porque dejas de pedirle a la red que transporte lo único que nunca podrá: la capacidad de actuar.
- Devuelve desde una función de servidor un objeto con
Date,Mapy una referencia circular; confirma en el cliente que los tres se reconstruyen fielmente. - Devuelve una instancia de una clase con métodos, intenta llamar a un método en el cliente y explica el error a la luz de la pérdida del prototipo.
- Recibe un
FormDatacomo argumento de una función de servidor y lee un campo sin convertirlo antes a objeto plano. - Devuelve un objeto que contenga una
Promisesin resolver y observa cómo el valor llega en dos tiempos gracias al streaming. - Fija
serialization.modeajsony razona qué directiva de CSP dejarías de necesitar frente al modojs.