Inicio / Inteligencia Artificial / AI-First Full Stack: Construye Apps con IA / Backend AI con Node.js y TypeScript

Backend AI con Node.js y TypeScript

Arquitectura backend para apps AI: rutas, middleware, manejo de sesiones de chat, colas y rate limiting.

Intermedio
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

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
}
🔒

Ejercicio práctico disponible

Rate limiter y conversation store

Desbloquear ejercicios
// Rate limiter y conversation store
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes