@bobtail.software/b-ssr
v1.1.2
Published
Fastify + Vite SSR Plugin wrapper with RPC
Maintainers
Readme
@bobtail.software/b-ssr
Fastify + 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.
🚀 Características Principales
- 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-datavalidado 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.
📦 Instalación
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⚙️ Configuración
1. Configuración del Servidor Fastify (server.ts)
Registra el plugin principal. Esto habilitará el middleware de Vite y los decoradores de rutas.
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');Multi-entry SSR (opcional)
Puedes definir múltiples entry points de SSR y escogerlos por ruta o con un resolver.
await fastify.register(bSsrPlugin, {
root: process.cwd(),
entries: [
{
name: 'app',
match: '/app',
devEntryFile: '/src/entry-app-server.tsx',
prodEntryFile: './dist/app/server/entry-server.mjs',
},
{
name: 'admin',
match: /^\\/admin/,
devEntryFile: '/src/entry-admin-server.tsx',
prodEntryFile: './dist/admin/server/entry-server.mjs',
},
{
name: 'fallback',
devEntryFile: '/src/entry-server.tsx',
prodEntryFile: './dist/server/entry-server.mjs',
},
],
// Solo se sirve un clientDistDir por defecto.
// Si necesitas varios, registra static manualmente.
clientDistDir: './dist/client',
});Selección de entry (orden de prioridad):
entryenaddRenderRoute(override explícito)resolveEntry(req)en opciones del pluginentries[].match(string, RegExp, o función)- Fallback sin
match devEntryFile/prodEntryFile(single-entry legacy)
Resolver global:
const entries = [
{
name: 'app',
devEntryFile: '/src/entry-app-server.tsx',
prodEntryFile: './dist/app/server/entry-server.mjs',
},
{
name: 'admin',
devEntryFile: '/src/entry-admin-server.tsx',
prodEntryFile: './dist/admin/server/entry-server.mjs',
},
];
await fastify.register(bSsrPlugin, {
root: process.cwd(),
entries,
resolveEntry: (req) => (req.headers['x-entry'] === 'admin' ? entries[1] : null),
});Si entries no existe, el comportamiento es el mismo que antes (single-entry).
2. Configuración de Vite (vite.config.ts)
Necesitas el plugin rpcGeneratorPlugin para habilitar la magia de los tipos y la separación cliente/servidor.
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',
}),
],
});🔄 Generación de Tipos Standalone
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
Uso Programático
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
});Uso con CLI
# Generar tipos (lee configuración desde vite.config.ts)
pnpm generate:types
# Generar tipos y limpiar orphans
pnpm generate:types:clean
# Con npx (instalado globalmente)
generate-rpc-typesScripts de Build
{
"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"
}
}Cuándo Usar Standalone vs Dev Server
| 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
Ejemplo de Integración con CI/CD
GitHub Actions:
name: Build and Test
on: [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 buildGitLab CI:
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.
Configuración del CLI
El CLI lee automáticamente la configuración del plugin desde tu vite.config.ts:
// 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/**/*.mtstsConfigFilePath:tsconfig.jsonrouterBaseDir:src-ts/routersclean:false
Modo --clean
El modo clean elimina archivos .universal.d.ts orphans:
- Archivos
.universal.d.tsque no tienen correspondiente.mts/.ts - Archivos
.universal.d.tsde archivos.mts/.tsque ya no definen rutas RPC
Esto 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.
1. Crear un Router (Backend)
Crea un archivo, por ejemplo src/routers/users.mts.
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' };
},
});
}2. Consumir en el Cliente (Frontend)
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.
// 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: '[email protected]' },
});
console.log(result.message);
} catch (err) {
console.error(err);
}
};
return <button onClick={handleSubmit}>Crear Usuario</button>;
}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.
Entry Server (src/entry-server.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());Entry Client (src/entry-client.tsx)
import { hydrateClient } from '@bobtail.software/b-ssr/tanstack-client';
import { createRouter } from './router';
hydrateClient(() => createRouter());Render Route en Fastify
Para servir la aplicación HTML, usa addRenderRoute en tu servidor:
// server.ts
fastify.addRenderRoute('/*', {
handler: async (req, reply) => {
// Puedes pasar datos iniciales al SSR aquí si lo deseas
return { user: req.user };
},
});Puedes forzar un entry específico por ruta usando entry (aplica también a sub-rutas por el wildcard):
fastify.addRenderRoute('/admin', { entry: 'admin' });entry debe coincidir con entries[].name.
📂 Estructura de Archivos Recomendada
.
├── 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.tsdesde 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
Scripts de Test
Ejecuta los tests con los siguientes comandos:
# Ejecutar todos los tests una vez
pnpm test
# Ejecutar tests en modo watch (recomendado durante desarrollo)
pnpm test:watch
# Ejecutar tests con interfaz visual
pnpm test:ui
# Ejecutar tests con reporte de cobertura
pnpm test:coverageCobertura de Tests
La suite cubre unit tests e integration tests. Algunos tests están en skip por limitaciones conocidas del generador de tipos.
Distribución (general):
- Unit Tests: vite-rpc-plugin, type generator standalone, virtual modules, firewall
- Integration 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
Query Params y Path Params con Transforms
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 transformados
Ejemplo del problema:
// 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:
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:
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.
Generación de Tipos en Edge Cases Complejos
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.
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:
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.
// types.ts
export interface User {
id: string;
name: string;
}
// router.mts
import type { User } from './types'; // No se extrae en .d.tsWorkaround: Define los tipos inline en el router o usa Zod schemas directamente:
// 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.
// 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:
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;
// ...
},
});Tests en Skip (Limitaciones del Generador)
Los siguientes tests están marcados como .skip() debido a limitaciones conocidas del generador de tipos:
- multipart/form-data con File - El plugin no genera correctamente el tipo
Fileen el body para uploads de archivos. Ver la sección "Generación de Tipos en Edge Cases Complejos" arriba. - 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. - File watching y caching - El plugin no genera/actualiza archivos
.d.tscorrectamente en escenarios complejos de cache y watch. Estas son limitaciones internas dets-morphyfast-globcuando 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.
