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.
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.
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 runtimeEl 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.
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 explicitamenteimport { 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.