Fastify + Vite SSR Plugin wrapper with RPC
npm install @bobtail.software/b-ssrFastify + Vite + React SSR + TanStack Router + RPC
@bobtail.software/b-ssr es una solución integral para construir aplicaciones "monolíticas" modernas con TypeScript. Combina la potencia de Fastify en el backend y Vite en el frontend, proporcionando Server-Side Rendering (SSR) y una capa de RPC (Remote Procedure Call) totalmente tipada sin necesidad de generar código manual ni mantener definiciones de API separadas.
- Integración Profunda Fastify & Vite: Manejo automático del servidor de desarrollo de Vite (HMR) y servicio de estáticos en producción.
- End-to-End Type Safety: Define tus rutas en el backend con esquemas Zod. La librería genera automáticamente los tipos para el cliente. Si cambias el backend, el frontend te avisa del error.
- RPC Transparente: Llama a tus funciones del backend directamente desde el frontend como si fueran funciones locales.
- addRpcRoute: Para mutaciones (POST).
- addLoaderRoute: Para fetching de datos (GET), ideal para loaders.
- Soporte TanStack Router: Helpers específicos (createServerHandler, hydrateClient) para integrar SSR con TanStack Router fácilmente.
- Gestión de Archivos: Soporte nativo para multipart/form-data validado con Zod.
- Seguridad (Firewall): El plugin de Vite impide que código sensible del backend (base de datos, secretos) se filtre al bundle del cliente.
---
``bash`
pnpm add @bobtail.software/b-ssr fastify vite zod @tanstack/react-router react react-dom
pnpm add -D @types/node @types/react @types/react-dom
---
Registra el plugin principal. Esto habilitará el middleware de Vite y los decoradores de rutas.
`typescript
import Fastify from 'fastify';
import bSsrPlugin from '@bobtail.software/b-ssr';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fastify = Fastify();
await fastify.register(bSsrPlugin, {
root: process.cwd(),
// Archivo de entrada para SSR en desarrollo
devEntryFile: '/src/entry-server.tsx',
// Archivo compilado para producción
prodEntryFile: './dist/server/entry-server.mjs',
// Carpeta de assets del cliente en producción
clientDistDir: './dist/client',
});
// Importa tus rutas de backend aquí
await fastify.register(import('./src/routers/my-router.js'));
await fastify.listen({ port: 3000 });
console.log('Server running on http://localhost:3000');
`
Necesitas el plugin rpcGeneratorPlugin para habilitar la magia de los tipos y la separación cliente/servidor.
`typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { rpcGeneratorPlugin } from '@bobtail.software/b-ssr/vite-plugin';
import { TanStackRouterVite } from '@tanstack/router-vite-plugin';
export default defineConfig({
plugins: [
react(),
TanStackRouterVite(),
rpcGeneratorPlugin({
// Patrón para encontrar tus archivos de rutas backend
routerPattern: 'src/routers/*/.mts',
// Dónde se generarán los tipos
routerBaseDir: 'src/routers',
}),
],
});
`
---
Por defecto, el plugin de Vite genera tipos automáticamente cuando el servidor de desarrollo está activo. Sin embargo, hay casos donde necesitas generar tipos sin el servidor de desarrollo:
- Scripts de build de producción
- Pre-commit hooks (lint-staged)
- CI/CD pipelines
- Type-checking sin servidor dev
`typescript
import { generateRpcTypes } from '@bobtail.software/b-ssr/type-generator';
await generateRpcTypes({
routerPattern: 'src/routers/*/.mts',
tsConfigFilePath: 'tsconfig.json',
routerBaseDir: 'src/routers',
clean: true, // Opcional: elimina archivos .d.ts orphans
});
`
`bashGenerar tipos (lee configuración desde vite.config.ts)
pnpm generate:types
$3
`json
{
"scripts": {
"build": "pnpm generate:types && vite build",
"type-check": "pnpm generate:types && tsc --noEmit",
"pre-commit": "pnpm generate:types:clean && git add src/*/.universal.d.ts"
}
}
`$3
| Escenario | Recomendación |
| ------------------------- | --------------------------------------------------------- |
| Desarrollo activo | Usa el plugin de Vite (generación automática en dev mode) |
| Build de producción | Usa
pnpm generate:types && vite build |
| Type-checking en CI | Usa pnpm generate:types && tsc --noEmit |
| Pre-commit hooks | Usa pnpm generate:types:clean |
| Script personalizados | Usa generateRpcTypes() programáticamente |Por qué ambas opciones son necesarias:
- El plugin de Vite genera automáticamente durante desarrollo (HMR, watch mode)
- El generador standalone es para escenarios donde NO hay servidor Vite (CI, scripts de build, etc.)
- Ambos usan la misma lógica interna, garantizando consistencia
$3
GitHub Actions:
`yaml
name: Build and Teston: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: '18'
- run: pnpm install
- run: pnpm generate:types # Genera tipos para type-check
- run: pnpm type-check
- run: pnpm build
`GitLab CI:
`yaml
build:
image: node:18
script:
- pnpm install
- pnpm generate:types # Genera tipos antes de build
- pnpm type-check
- pnpm build
artifacts:
paths:
- dist/
`> Nota de Compatibilidad: El generador standalone usa la misma lógica interna que el plugin de Vite, garantizando que los tipos generados son idénticos en ambos casos. No necesitas elegir entre un enfoque u otro - ambos pueden coexistir perfectamente.
---
$3
El CLI lee automáticamente la configuración del plugin desde tu
vite.config.ts:`typescript
// vite.config.ts
export default defineConfig({
plugins: [
rpcGeneratorPlugin({
routerPattern: 'src/routers/*/.mts', // ← Usado por CLI
routerBaseDir: 'src/routers', // ← Usado por CLI
tsConfigFilePath: 'tsconfig.json', // ← Usado por CLI
}),
],
});
`Si no hay
vite.config.ts, el CLI usa valores por defecto:-
routerPattern: src-ts/routers/*/.mts
- tsConfigFilePath: tsconfig.json
- routerBaseDir: src-ts/routers
- clean: false$3
El modo
clean elimina archivos .universal.d.ts orphans:1. Archivos
.universal.d.ts que no tienen correspondiente .mts/.ts
2. Archivos .universal.d.ts de archivos .mts/.ts que ya no definen rutas RPCEsto es útil cuando renombras o eliminas archivos de rutas backend.
Advertencia: Asegúrate de que el modo
clean no elimine archivos necesarios antes de usarlo en scripts automáticos.---
🛠️ Uso y Definición de Rutas
La librería utiliza la extensión
.mts (o .ts) para definir rutas de backend que exportan funciones RPC.$3
Crea un archivo, por ejemplo
src/routers/users.mts.`typescript
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';export default async function userRouter(fastify: FastifyInstance) {
// 1. RPC (Mutation/Action) - Método POST
fastify.addRpcRoute('/create-user', {
schema: {
body: z.object({
name: z.string(),
email: z.string().email(),
}),
},
handler: async (req, reply) => {
// req.body está tipado automáticamente
const { name, email } = req.body;
return { success: true, id: 123, message:
User ${name} created };
},
}); // 2. Loader (Query) - Método GET
fastify.addLoaderRoute('/get-user', {
schema: {
querystring: z.object({
id: z.string(),
}),
},
handler: async (req, reply) => {
const { id } = req.query;
return { id, name: 'Victor', role: 'admin' };
},
});
}
`$3
Aquí ocurre la magia. Importas desde el archivo
.universal. El plugin de Vite intercepta esta importación y te entrega un cliente ligero que hace fetch, manteniendo los tipos de retorno y argumentos.`tsx
// src/components/CreateUser.tsx
import React from 'react';
// NOTA: Importamos desde .universal, no desde .mts directamente
import { actionCreateUser, loaderGetUser } from '../routers/users.universal';export function CreateUser() {
const handleSubmit = async () => {
try {
// TypeScript autocompleta 'body' y valida los tipos
const result = await actionCreateUser({
body: { name: 'Victor', email: 'test@example.com' },
});
console.log(result.message);
} catch (err) {
console.error(err);
}
};
return ;
}
`> Nota: El nombre de la función exportada se genera automáticamente basado en la URL.
>
> -
/create-user (RPC) -> actionCreateUser
> - /get-user (Loader) -> loaderGetUser---
🌐 Integración con TanStack Router (SSR)
La librería exporta helpers específicos para hidratar y renderizar TanStack Router.
$3
`tsx
import { createServerHandler } from '@bobtail.software/b-ssr/tanstack-server';
import { createRouter } from './router'; // Tu función que crea el router// Exporta la función 'render' que Fastify llamará
export const render = createServerHandler(() => createRouter());
`$3
`tsx
import { hydrateClient } from '@bobtail.software/b-ssr/tanstack-client';
import { createRouter } from './router';hydrateClient(() => createRouter());
`$3
Para servir la aplicación HTML, usa
addRenderRoute en tu servidor:`typescript
// server.ts
fastify.addRenderRoute('/*', {
handler: async (req, reply) => {
// Puedes pasar datos iniciales al SSR aquí si lo deseas
return { user: req.user };
},
});
`---
📂 Estructura de Archivos Recomendada
`text
.
├── src/
│ ├── routers/ # Rutas Backend (RPCs)
│ │ ├── users.mts # Definición de rutas con Zod
│ │ └── posts.mts
│ ├── routes/ # Rutas de TanStack Router (Frontend)
│ ├── entry-server.tsx # Punto de entrada SSR
│ ├── entry-client.tsx # Punto de entrada Hidratación
│ └── router.tsx # Configuración de TanStack Router
├── server.ts # Servidor Fastify
├── vite.config.ts
└── package.json
`🧪 Testing
El proyecto utiliza Vitest para ejecutar una suite de tests comprehensiva que cubre:
- Generación de tipos - Prueba la generación automática de tipos
.d.ts desde rutas backend (dev mode y standalone)
- Validación de Zod - Verifica edge cases complejos de esquemas Zod (refine, transform, pipe, union, etc.)
- Error Handling - Prueba validaciones de errores (400, 401, 403, 500) en escenarios reales
- SSR Hydration - Valida serialización correcta de datos complejos (Date, BigInt, Map, Set, etc.)
- Firewall de Seguridad - Verifica que código de backend no se filtre al bundle del cliente
- Cliente RPC - Prueba la lógica de llamadas RPC desde el frontend$3
Ejecuta los tests con los siguientes comandos:
`bash
Ejecutar todos los tests una vez
pnpm testEjecutar tests en modo watch (recomendado durante desarrollo)
pnpm test:watchEjecutar tests con interfaz visual
pnpm test:uiEjecutar tests con reporte de cobertura
pnpm test:coverage
`$3
- 70/70 tests passing (100% de tests ejecutados)
- 4 tests en skip - Limitaciones conocidas del generador de tipos
Distribución:
- Unit Tests: 27 tests (vite-rpc-plugin, standalone type generator, virtual modules, firewall)
- Integration Tests: 43 tests (zod-validation, error-handling, ssr-hydration, rpc-client)
🔒 Seguridad
El plugin
vite-rpc-plugin incluye un Firewall. Si intentas importar un archivo .mts de backend directamente en un archivo de cliente (sin usar la extensión .universal), el build fallará o lanzará un error en tiempo de ejecución, previniendo que código de servidor llegue al navegador.---
⚠️ Limitaciones Conocidas
$3
Limitación: Al convertir Zod schemas a JSON Schema para Fastify, se pierden las transforms (ej:
.transform(Number), .pipe()). Esto afecta principalmente a querystring y params.Por qué ocurre:
- Fastify valida usando JSON Schema (no Zod directamente)
-
z.toJSONSchema() con { io: 'input' } genera el tipo de entrada (string), perdiendo las transforms
- Los handlers reciben strings en lugar de numbers/datos transformadosEjemplo del problema:
`typescript
// Schema con transform
fastify.addRpcRoute('/search', {
schema: {
querystring: z.object({
page: z.string().transform(Number).optional(), // ← Transform perdido
limit: z.string().transform(Number).optional(), // ← Transform perdido
}),
},
handler: async (req) => {
// req.query.page es '2' (string) en lugar de 2 (number)
// ❌ Causa TypeError o comportamiento incorrecto
return { page: req.query.page + 1, limit: req.query.limit };
},
});
`Solución recomendada (Workaround):
Opción 1 - Parsear manualmente en el handler:
`typescript
handler: async (req) => {
const { page: pageStr, limit: limitStr } = req.query;
const page = pageStr ? Number(pageStr) : 1;
const limit = limitStr ? Number(limitStr) : 10;
return { page, limit, results: [...] };
}
`Opción 2 - Usar strings en el schema y parsear en el handler:
`typescript
fastify.addRpcRoute('/search', {
schema: {
querystring: z.object({
page: z.string().optional(),
limit: z.string().optional(),
}),
},
handler: async (req) => {
const { page, limit } = req.query;
return { page: Number(page || 1), limit: Number(limit || 10) };
},
});
`Nota: Este workaround NO es necesario para el
body, ya que los transforms en body se mantienen correctamente después de la conversión a JSON Schema.$3
Limitación: El generador de tipos tiene limitaciones conocidas en algunos edge cases complejos de Zod.
#### multipart/form-data con File
Problema: Al usar
multipart/form-data con archivos, el plugin no genera correctamente el tipo File en el body.`typescript
fastify.addRpcRoute('/upload', {
schema: {
consumes: ['multipart/form-data'],
body: z.object({
file: z.instanceof(File), // No se genera correctamente en .d.ts
}),
},
handler: async (req) => {
// ...
},
});
`Workaround: Usa el tipo
File manualmente en el cliente:`typescript
await actionUpload({
body: {
file: fileObject as unknown as File,
},
});
`#### Imports de Tipos Externos
Problema: El plugin no extrae imports de tipos desde archivos externos en la generación de
.d.ts.`typescript
// types.ts
export interface User {
id: string;
name: string;
}// router.mts
import type { User } from './types'; // No se extrae en .d.ts
`Workaround: Define los tipos inline en el router o usa Zod schemas directamente:
`typescript
// router.mts
const UserSchema = z.object({
id: z.string(),
name: z.string(),
});fastify.addRpcRoute('/create-user', {
schema: {
body: UserSchema, // Funciona correctamente
},
handler: async (req) => {
// req.body está tipado correctamente
},
});
`#### Parsing Incompleto en Schemas Complejos
Problema: En algunos edge cases con Zod muy complejos, el plugin puede generar
body: any en lugar de inferir el tipo completo.`typescript
// Edge case con pipes, transforms y refinements anidados
fastify.addRpcRoute('/complex-route', {
schema: {
body: z.object({
data: z
.string()
.transform((val) => val.toUpperCase())
.pipe(z.string().min(5))
.refine((val) => val.includes('X')),
}),
},
handler: async (req) => {
// req.body puede ser 'any' en lugar del tipo inferido
},
});
`Workaround: Simplifica el schema o define el tipo manualmente:
`typescript
interface ComplexBody {
data: string;
}fastify.addRpcRoute('/complex-route', {
schema: {
body: z.object({
data: z
.string()
.min(5)
.refine((val) => val.includes('X')),
}),
},
handler: async (req, reply) => {
const body = req.body as ComplexBody;
// ...
},
});
`$3
Los siguientes tests están marcados como
.skip() debido a limitaciones conocidas del generador de tipos:1. multipart/form-data con File - El plugin no genera correctamente el tipo
File en el body para uploads de archivos. Ver la sección "Generación de Tipos en Edge Cases Complejos" arriba.
2. Import types externos - El plugin no extrae imports de tipos desde archivos externos en la generación de .d.ts. Ver "Imports de Tipos Externos" arriba.
3. File watching y caching - El plugin no genera/actualiza archivos .d.ts correctamente en escenarios complejos de cache y watch. Estas son limitaciones internas de ts-morph y fast-glob` cuando se crean archivos dinámicamente en tests.Impacto: Estas limitaciones no afectan la funcionalidad crítica del sistema. Los tests en skip representan edge cases complejos del generador de tipos que tienen workarounds documentados en esta sección.