Backend AI con Node.js y TypeScript
Construir un backend para aplicaciones AI-First requiere patrones diferentes a un CRUD tradicional: manejo de streaming, sesiones de chat, rate limiting por tokens, colas de procesamiento y fallbacks de modelos.
Estructura del proyecto
src/
├── index.ts # Entry point
├── config/
│ └── env.ts # Variables de entorno validadas
├── routes/
│ ├── chat.ts # Endpoints de chat
│ ├── documents.ts # Upload y gestión de docs
│ └── auth.ts # Autenticación
├── services/
│ ├── llm.service.ts # Abstracción multi-proveedor
│ ├── rag.service.ts # Pipeline RAG
│ ├── chat.service.ts # Lógica de conversaciones
│ └── embedding.service.ts
├── middleware/
│ ├── auth.ts # JWT middleware
│ ├── rateLimiter.ts # Rate limiting
│ └── errorHandler.ts # Error handling global
├── db/
│ ├── schema.ts # Schema Prisma/Drizzle
│ └── migrations/
└── types/
└── index.ts # Tipos compartidos
Configuración con Hono (Express moderno)
// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { chatRoutes } from './routes/chat';
import { authRoutes } from './routes/auth';
import { errorHandler } from './middleware/errorHandler';
const app = new Hono();
// Middleware global
app.use('*', logger());
app.use('*', cors({ origin: process.env.FRONTEND_URL! }));
// Rutas
app.route('/api/auth', authRoutes);
app.route('/api/chat', chatRoutes);
// Error handler
app.onError(errorHandler);
export default {
port: Number(process.env.PORT) || 3001,
fetch: app.fetch,
};
Variables de entorno tipadas
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3001),
DATABASE_URL: z.string().url(),
OPENAI_API_KEY: z.string().startsWith('sk-'),
ANTHROPIC_API_KEY: z.string().startsWith('sk-ant-'),
JWT_SECRET: z.string().min(32),
FRONTEND_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
});
export const env = envSchema.parse(process.env);
Rutas de Chat
// src/routes/chat.ts
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { authMiddleware } from '../middleware/auth';
import { ChatService } from '../services/chat.service';
const chatRoutes = new Hono();
const chatService = new ChatService();
const chatSchema = z.object({
conversationId: z.string().uuid().optional(),
message: z.string().min(1).max(10000),
model: z.enum(['gpt-4o', 'gpt-4o-mini', 'claude-sonnet']).default('gpt-4o-mini'),
});
// Chat con streaming
chatRoutes.post(
'/stream',
authMiddleware,
zValidator('json', chatSchema),
async (c) => {
const { conversationId, message, model } = c.req.valid('json');
const userId = c.get('userId');
// Obtener o crear conversación
const conversation = await chatService.getOrCreateConversation(
userId,
conversationId
);
// Guardar mensaje del usuario
await chatService.saveMessage(conversation.id, 'user', message);
// Stream response
return new Response(
new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let fullResponse = '';
try {
await chatService.streamChat(
conversation.id,
message,
model,
(token: string) => {
fullResponse += token;
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ content: token })}\n\n`)
);
}
);
// Guardar respuesta completa
await chatService.saveMessage(conversation.id, 'assistant', fullResponse);
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ done: true })}\n\n`)
);
} catch (error) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ error: 'Error generando respuesta' })}\n\n`)
);
} finally {
controller.close();
}
},
}),
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
}
);
}
);
// Listar conversaciones
chatRoutes.get('/conversations', authMiddleware, async (c) => {
const userId = c.get('userId');
const conversations = await chatService.listConversations(userId);
return c.json(conversations);
});
// Obtener mensajes de una conversación
chatRoutes.get('/conversations/:id', authMiddleware, async (c) => {
const conversationId = c.req.param('id');
const userId = c.get('userId');
const messages = await chatService.getMessages(conversationId, userId);
return c.json(messages);
});
export { chatRoutes };
Servicio de Chat
// src/services/chat.service.ts
import { LLMService } from './llm.service';
import { prisma } from '../db/client';
export class ChatService {
private llm: LLMService;
constructor() {
this.llm = new LLMService();
}
async getOrCreateConversation(userId: string, conversationId?: string) {
if (conversationId) {
const conv = await prisma.conversation.findFirst({
where: { id: conversationId, userId },
});
if (conv) return conv;
}
return prisma.conversation.create({
data: {
userId,
title: 'Nueva conversación',
},
});
}
async saveMessage(conversationId: string, role: string, content: string) {
return prisma.message.create({
data: { conversationId, role, content },
});
}
async streamChat(
conversationId: string,
userMessage: string,
model: string,
onToken: (token: string) => void
) {
// Cargar historial de la conversación
const history = await prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
take: 20, // Últimos 20 mensajes como contexto
});
const messages = [
{ role: 'system' as const, content: SYSTEM_PROMPT },
...history.map(m => ({
role: m.role as 'user' | 'assistant',
content: m.content,
})),
{ role: 'user' as const, content: userMessage },
];
await this.llm.streamChat(messages, model, onToken);
}
async listConversations(userId: string) {
return prisma.conversation.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
take: 50,
select: {
id: true,
title: true,
updatedAt: true,
_count: { select: { messages: true } },
},
});
}
async getMessages(conversationId: string, userId: string) {
// Verificar que la conversación pertenece al usuario
const conv = await prisma.conversation.findFirst({
where: { id: conversationId, userId },
});
if (!conv) throw new Error('Conversación no encontrada');
return prisma.message.findMany({
where: { conversationId },
orderBy: { createdAt: 'asc' },
});
}
}
const SYSTEM_PROMPT = `Eres un asistente útil, preciso y conciso.
- Responde en español a menos que te pregunten en otro idioma
- Usa markdown para formatear respuestas
- Para código, especifica siempre el lenguaje
- Si no sabes algo, dilo claramente`;
Rate Limiting
// src/middleware/rateLimiter.ts
import { Context, Next } from 'hono';
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
}
const rateLimits = new Map<string, { count: number; resetAt: number }>();
export function rateLimiter(config: RateLimitConfig) {
return async (c: Context, next: Next) => {
const userId = c.get('userId') || c.req.header('x-forwarded-for') || 'anonymous';
const key = `rate:${userId}`;
const now = Date.now();
let entry = rateLimits.get(key);
if (!entry || now > entry.resetAt) {
entry = { count: 0, resetAt: now + config.windowMs };
rateLimits.set(key, entry);
}
entry.count++;
if (entry.count > config.maxRequests) {
return c.json(
{ error: 'Rate limit exceeded', retryAfter: Math.ceil((entry.resetAt - now) / 1000) },
429
);
}
c.header('X-RateLimit-Remaining', String(config.maxRequests - entry.count));
c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000)));
await next();
};
}
// Uso
chatRoutes.use('*', rateLimiter({ windowMs: 60_000, maxRequests: 20 }));
Error Handling
// src/middleware/errorHandler.ts
import { Context } from 'hono';
export function errorHandler(err: Error, c: Context) {
console.error(`[ERROR] ${err.message}`, err.stack);
if (err.message.includes('Rate limit')) {
return c.json({ error: 'Demasiadas solicitudes. Intenta más tarde.' }, 429);
}
if (err.message.includes('API key')) {
return c.json({ error: 'Error de configuración del servidor.' }, 500);
}
if (err.message.includes('not found')) {
return c.json({ error: 'Recurso no encontrado.' }, 404);
}
return c.json({ error: 'Error interno del servidor.' }, 500);
}
Schema de Base de Datos
// prisma/schema.prisma
model User {
id String @id @default(uuid())
email String @unique
name String?
plan Plan @default(FREE)
tokensUsed Int @default(0)
conversations Conversation[]
createdAt DateTime @default(now())
}
model Conversation {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
title String @default("Nueva conversación")
messages Message[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Message {
id String @id @default(uuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id])
role String // 'user' | 'assistant' | 'system'
content String
tokensUsed Int?
model String?
createdAt DateTime @default(now())
}
model Document {
id String @id @default(uuid())
content String
embedding Unsupported("vector(1536)")?
metadata Json @default("{}")
createdAt DateTime @default(now())
}
enum Plan {
FREE
PRO
ENTERPRISE
}