npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bobtail.software/b-ssr

v1.1.2

Published

Fastify + Vite SSR Plugin wrapper with RPC

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-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.

📦 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):

  1. entry en addRenderRoute (override explícito)
  2. resolveEntry(req) en opciones del plugin
  3. entries[].match (string, RegExp, o función)
  4. Fallback sin match
  5. 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-types

Scripts 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 build

GitLab 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/**/*.mts
  • tsConfigFilePath: tsconfig.json
  • routerBaseDir: src-ts/routers
  • clean: false

Modo --clean

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 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.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

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:coverage

Cobertura 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.ts

Workaround: 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:

  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.

📄 Licencia