Cloudflare Workers y Durable Objects para Arquitectura de Micro Frontends

Este documento analiza cómo Cloudflare Workers y Durable Objects pueden servir como la infraestructura base para una arquitectura de micro frontends. Cubre la gestión de WebSockets mediante Durable Objects, la composición en el edge con Workers, estrategias de renderizado del lado del servidor y técnicas de optimización de rendimiento para aplicaciones de micro frontends distribuidas globalmente.

1. Durable Objects para WebSockets

Como los Durable Objects Gestionan Conexiones WebSocket

Los Durable Objects (DOs) proporcionan puntos de coordinación con estado y un solo hilo de ejecución, ideales para servidores WebSocket. Hay dos APIs disponibles:

Hibernatable WebSocket API (Recomendada)

SVG-CF-01: Ciclo de vida de hibernacion WebSocket en Durable Objects -- De Activo a Hibernando, Despertar con mensaje, Procesar y Activo de nuevo Ciclo de Vida de Hibernacion WebSocket en Durable Object Activo Procesando mensajes, facturacion activa Sin mensajes (tiempo de espera) Hibernando WS abierto, sin facturacion, estado desalojado Mensaje WS entrante Despertar + Reconstruir constructor() se ejecuta, re-hidratar estado webSocketMessage() handler se ejecuta serializeAttachment() persiste entre ciclos $$$ Facturado solo mientras esta activo $0 Facturado mientras hiberna

Este es el patron principal para uso en produccion. Permite que los Durable Objects duerman mientras mantienen conexiones WebSocket, reduciendo drasticamente los costos para aplicaciones con muchas conexiones inactivas. Durante la hibernacion, los cargos por duracion facturable no se acumulan, pero la conexion WebSocket permanece abierta. Cuando llega un mensaje, el runtime recrea automaticamente el DO, ejecuta el constructor y entrega el mensaje al handler correspondiente.

import { DurableObject } from "cloudflare:workers";

interface Env {
  ROOMS: DurableObjectNamespace<ChatRoom>;
}

export class ChatRoom extends DurableObject {
  sessions: Map<WebSocket, { id: string; username: string }>;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    // Re-hydrate sessions from hibernation
    this.sessions = new Map();
    this.ctx.getWebSockets().forEach((ws) => {
      const meta = ws.deserializeAttachment();
      this.sessions.set(ws, { ...meta });
    });
  }

  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (request.headers.get("Upgrade") !== "websocket") {
      return new Response("Expected WebSocket", { status: 426 });
    }

    const username = url.searchParams.get("username");
    if (!username) {
      return new Response("Missing username", { status: 400 });
    }

    const webSocketPair = new WebSocketPair();
    const [client, server] = Object.values(webSocketPair);

    // acceptWebSocket() makes this connection "hibernatable"
    // Unlike ws.accept(), this tells the runtime the DO can sleep
    this.ctx.acceptWebSocket(server);

    const sessionData = { id: crypto.randomUUID(), username };
    server.serializeAttachment(sessionData); // Persists across hibernation (max 2048 bytes)
    this.sessions.set(server, sessionData);

    // Notify other clients
    this.broadcast({ type: "join", username }, sessionData.id);

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    if (typeof message !== "string") return;
    const session = this.sessions.get(ws);
    if (!session) return;

    const parsed = JSON.parse(message);
    switch (parsed.type) {
      case "chat":
        this.broadcast({
          type: "chat",
          username: session.username,
          text: parsed.text,
          timestamp: Date.now(),
        });
        break;

      case "get-participants":
        const participants = Array.from(this.sessions.values())
          .map(s => s.username);
        ws.send(JSON.stringify({ type: "participants", participants }));
        break;
    }
  }

  async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {
    const session = this.sessions.get(ws);
    if (session) {
      this.broadcast({ type: "leave", username: session.username }, session.id);
    }
    this.sessions.delete(ws);
    ws.close(code, "Closing"); // Always reciprocate close frames
  }

  async webSocketError(ws: WebSocket, error: unknown) {
    const session = this.sessions.get(ws);
    this.sessions.delete(ws);
    ws.close(1011, "Unexpected error");
  }

  private broadcast(message: object, excludeId?: string) {
    const payload = JSON.stringify(message);
    this.ctx.getWebSockets().forEach((ws) => {
      const { id } = ws.deserializeAttachment();
      if (id !== excludeId) {
        ws.send(payload);
      }
    });
  }
}

// Entry Worker routes to the correct DO
export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);
    const roomName = url.pathname.split("/")[2]; // e.g., /ws/room-name

    // Deterministic routing: same room name always hits same DO
    const id = env.ROOMS.idFromName(roomName);
    const stub = env.ROOMS.get(id);
    return stub.fetch(request);
  },
};

Patron clave: Estado por conexion con serializeAttachment/deserializeAttachment

Esto es critico para la hibernacion. Puedes almacenar hasta 2,048 bytes por conexion que persisten entre ciclos de hibernacion. Almacena aqui IDs de usuario, tokens de sesion y metadatos.

Patrones para Comunicación en Tiempo Real

Salas de Chat / Canales

  • Un DO por sala/canal (el patron "atomo de coordinación")
  • Usa idFromName(roomName) para enrutamiento deterministico
  • Difunde mensajes a todos los WebSockets conectados mediante this.ctx.getWebSockets()

Actualizaciones en Vivo (Dashboards, Notificaciones)

  • Un DO por usuario o por entidad observada
  • Los clientes se suscriben conectandose; el DO envia actualizaciones
  • Combina con alarms para consultar fuentes de datos externas

Edicion Colaborativa

  • Un DO por documento
  • Almacena el estado del documento en almacenamiento respaldado por SQLite
  • Usa transformaciones operacionales o CRDTs dentro del DO
  • La naturaleza de hilo unico de los DOs proporciona serializacion natural de las ediciones

Jerarquias Padre-Hijo para Escalar

SVG-CF-05: Jerarquia padre-hijo de Durable Objects -- Lobby DO enruta a Room DOs y Sub-channel DOs Jerarquia Padre-Hijo de Durable Objects Lobby DO Padre -- enruta conexiones Room DO #1 idFromName("room-1") Room DO #2 idFromName("room-2") Room DO #3 idFromName("room-3") Sub-channel DO (shard opcional) enruta a DOs hijos

Cuando un solo DO se convierte en un cuello de botella, divide en DOs hijos. Por ejemplo, un DO de servidor de juegos que gestiona partidas puede crear DOs por partida.

Limites de Conexion, Precios y Escalabilidad

Precios (Workers Paid Plan, $5/mes minimo):

RecursoIncluidoExcedente
Requests (incluye mensajes WS en ratio 20:1)1M/mes$0.15/millon
Duracion (GB-s)400,000/mes$12.50/millon GB-s
Lecturas de filas SQLite25B/mes$0.001/millon
Escrituras de filas SQLite50M/mes$1.00/millon
Almacenamiento5 GB-mes$0.20/GB-mes

Facturacion especifica de WebSocket:

  • Cada conexion WebSocket cuenta como 1 request
  • Los mensajes WebSocket entrantes usan un ratio 20:1: 100 mensajes entrantes = 5 requests facturables
  • Sin cargo por mensajes WebSocket salientes
  • Con Hibernation API: sin cargos de duracion mientras hiberna (solo se cobra mientras los handlers de eventos estan ejecutandose activamente)

Limites:

  • Limite flexible: ~1,000 requests/segundo por instancia de DO
  • Sin limite fijo de conexiones WebSocket por DO, pero los limites practicos dependen de la carga de trabajo
  • Tamano de mensaje WebSocket: 32 MiB maximo (recibido)
  • CPU por request: 30 segundos (configurable hasta 5 minutos)
  • Attachment por conexion: 2,048 bytes maximo
  • Almacenamiento por objeto: 10 GB (respaldado por SQLite)

Caracteristicas de escalabilidad:

  • Operaciones simples: ~1,000 req/seg por DO
  • Procesamiento moderado: ~500-750 req/seg
  • Operaciones complejas: ~200-500 req/seg
  • Formula: DOs necesarios = Total req/seg / Capacidad por DO

Estructurando un Durable Object para Salas/Canales

Patron recomendado: DO respaldado por SQLite con hibernacion

export class ChatRoom extends DurableObject {
  sql: SqlStorage;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.sql = ctx.storage.sql;

    // Run migrations once, blocking concurrent requests
    ctx.blockConcurrencyWhile(async () => {
      this.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          username TEXT NOT NULL,
          content TEXT NOT NULL,
          created_at INTEGER NOT NULL
        );
        CREATE INDEX IF NOT EXISTS idx_messages_created
          ON messages(created_at);
      `);
    });

    // Re-hydrate sessions from hibernated WebSockets
    this.ctx.getWebSockets().forEach((ws) => {
      // Connections survive hibernation automatically
    });
  }

  async fetch(request: Request) {
    const url = new URL(request.url);

    // HTTP endpoints for the room
    if (url.pathname.endsWith("/history")) {
      const rows = this.sql.exec(
        "SELECT * FROM messages ORDER BY created_at DESC LIMIT 50"
      ).toArray();
      return Response.json(rows);
    }

    // WebSocket upgrade
    if (request.headers.get("Upgrade") === "websocket") {
      return this.handleWebSocketUpgrade(request);
    }

    return new Response("Not found", { status: 404 });
  }

  private handleWebSocketUpgrade(request: Request): Response {
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    this.ctx.acceptWebSocket(server);

    // Tag connections for filtering (e.g., by role or sub-channel)
    const tags = ["all"]; // Can be used with getWebSockets(tag)
    // this.ctx.acceptWebSocket(server, tags);

    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string) {
    const data = JSON.parse(message);
    const session = ws.deserializeAttachment();

    // Persist to SQLite
    this.sql.exec(
      "INSERT INTO messages (username, content, created_at) VALUES (?, ?, ?)",
      session.username, data.text, Date.now()
    );

    // Broadcast to all connected clients
    const outgoing = JSON.stringify({
      type: "message",
      username: session.username,
      text: data.text,
      timestamp: Date.now(),
    });

    for (const client of this.ctx.getWebSockets()) {
      client.send(outgoing);
    }
  }
}

Configuración de wrangler.toml:

name = "chat-service"
main = "src/index.ts"
compatibility_date = "2026-03-03"

[[durable_objects.bindings]]
name = "ROOMS"
class_name = "ChatRoom"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["ChatRoom"]

Buena Practica de Agrupacion de Mensajes

Para datos de alta frecuencia (lecturas de sensores, estado de juegos), agrupa mensajes para reducir cambios de contexto:

async webSocketMessage(ws: WebSocket, message: string) {
  // Messages may arrive batched from client
  const batch = JSON.parse(message);
  if (Array.isArray(batch)) {
    // Process batch atomically
    for (const msg of batch) {
      this.processMessage(ws, msg);
    }
    // Single broadcast with aggregated state
    this.broadcastState();
  }
}

Recomendacion: agrupa cada 50-100ms o cada 50-100 mensajes en el lado del cliente, lo que se alcance primero.


2. Workers como API Gateway

Implementando el Patron API Gateway

SVG-CF-02: Patron API Gateway -- Navegador a Worker API Gateway a BFF Workers a APIs Externas Patron API Gateway Navegador HTTPS API Gateway Auth | Rate Limit Routing | CORS Worker BFF-Dashboard Worker BFF-Settings Worker Auth Service Worker svc bindings APIs Externas D1 / KV / R2 REST/GraphQL de terceros Durable Objects

Un Worker gateway sirve como punto unico de entrada para todas las solicitudes de API, gestionando el enrutamiento, la autenticación, la limitacion de velocidad y el despacho de solicitudes a servicios backend.

import { WorkerEntrypoint } from "cloudflare:workers";

interface Env {
  // Service bindings to backend Workers
  AUTH_SERVICE: Service<AuthService>;
  USER_SERVICE: Service<UserService>;
  MFE_CATALOG: Service<MfeCatalogService>;
  NOTIFICATION_SERVICE: Service<NotificationService>;

  // KV for rate limiting / config
  GATEWAY_CONFIG: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    // --- CORS handling ---
    if (request.method === "OPTIONS") {
      return handleCORS(request);
    }

    // --- Rate limiting (lightweight, per-IP) ---
    const clientIP = request.headers.get("CF-Connecting-IP") ?? "unknown";
    const rateLimitOk = await checkRateLimit(env.GATEWAY_CONFIG, clientIP);
    if (!rateLimitOk) {
      return new Response("Too Many Requests", { status: 429 });
    }

    // --- Authentication (except public routes) ---
    const publicPaths = ["/api/auth/login", "/api/auth/register", "/api/health"];
    let authContext = null;

    if (!publicPaths.some(p => url.pathname.startsWith(p))) {
      const token = request.headers.get("Authorization")?.replace("Bearer ", "");
      if (!token) {
        return new Response("Unauthorized", { status: 401 });
      }

      // Validate via Auth service (RPC call, no HTTP overhead)
      authContext = await env.AUTH_SERVICE.validateToken(token);
      if (!authContext.valid) {
        return new Response("Invalid token", { status: 401 });
      }
    }

    // --- Route to backend services ---
    try {
      if (url.pathname.startsWith("/api/auth")) {
        return await env.AUTH_SERVICE.fetch(stripPrefix(request, "/api/auth"));
      }

      if (url.pathname.startsWith("/api/users")) {
        // Inject auth context into the request for downstream
        const enrichedRequest = addAuthHeader(request, authContext);
        return await env.USER_SERVICE.fetch(stripPrefix(enrichedRequest, "/api/users"));
      }

      if (url.pathname.startsWith("/api/mfe")) {
        return await env.MFE_CATALOG.fetch(stripPrefix(request, "/api/mfe"));
      }

      if (url.pathname.startsWith("/api/notifications")) {
        return await env.NOTIFICATION_SERVICE.fetch(
          stripPrefix(request, "/api/notifications")
        );
      }

      return new Response("Not Found", { status: 404 });
    } catch (err) {
      console.error("Gateway error:", err);
      return new Response("Internal Server Error", { status: 500 });
    }
  },
};

function stripPrefix(request: Request, prefix: string): Request {
  const url = new URL(request.url);
  url.pathname = url.pathname.slice(prefix.length) || "/";
  return new Request(url.toString(), request);
}

function addAuthHeader(request: Request, authContext: any): Request {
  const headers = new Headers(request.headers);
  headers.set("X-User-Id", authContext.userId);
  headers.set("X-User-Role", authContext.role);
  return new Request(request.url, { ...request, headers });
}

Service Bindings: Comunicación Worker-a-Worker

Hay dos patrones para la comunicación entre Workers:

1. RPC via WorkerEntrypoint (Recomendado)

Se siente como llamar a una funcion local. Sin sobrecarga de serializacion HTTP.

// auth-service/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

export default class AuthService extends WorkerEntrypoint<Env> {
  async validateToken(token: string): Promise<{ valid: boolean; userId?: string; role?: string }> {
    // Validate JWT, check revocation list, etc.
    try {
      const payload = await verifyJWT(token, this.env.JWT_SECRET);
      return { valid: true, userId: payload.sub, role: payload.role };
    } catch {
      return { valid: false };
    }
  }

  async createSession(email: string, password: string): Promise<{ token: string }> {
    // Authenticate and return JWT
    const user = await this.env.DB.prepare(
      "SELECT * FROM users WHERE email = ?"
    ).bind(email).first();

    if (!user || !await verifyPassword(password, user.password_hash)) {
      throw new Error("Invalid credentials");
    }

    const token = await signJWT({ sub: user.id, role: user.role }, this.env.JWT_SECRET);
    return { token };
  }

  // Named entrypoint for admin operations
  // Bind separately: entrypoint = "AdminAuth"
}

export class AdminAuth extends WorkerEntrypoint<Env> {
  async revokeAllSessions(userId: string): Promise<void> {
    await this.env.DB.prepare(
      "DELETE FROM sessions WHERE user_id = ?"
    ).bind(userId).run();
  }
}

2. HTTP via fetch() (Para reenviar solicitudes completas)

// Forward entire request to downstream Worker
const response = await env.USER_SERVICE.fetch(request);

wrangler.toml para el Gateway Worker:

name = "api-gateway"
main = "src/index.ts"
compatibility_date = "2026-03-03"

# Service bindings to other Workers
[[services]]
binding = "AUTH_SERVICE"
service = "auth-service"

[[services]]
binding = "USER_SERVICE"
service = "user-service"

[[services]]
binding = "MFE_CATALOG"
service = "mfe-catalog-service"

[[services]]
binding = "NOTIFICATION_SERVICE"
service = "notification-service"

# Named entrypoint binding
[[services]]
binding = "ADMIN_AUTH"
service = "auth-service"
entrypoint = "AdminAuth"

# KV for gateway config
[[kv_namespaces]]
binding = "GATEWAY_CONFIG"
id = "abc123"

Autenticación/Autorizacion a Nivel del Gateway

Patron: Verificación de auth centralizada, autorizacion distribuida

  1. El gateway valida el JWT (via RPC a AuthService) en cada solicitud
  2. El gateway inyecta headers X-User-Id y X-User-Role en las solicitudes downstream
  3. Cada Worker downstream realiza su propia autorizacion (ej., "puede este usuario acceder a este recurso?")
// In the gateway: after auth validation
const enriched = new Request(request.url, {
  method: request.method,
  headers: new Headers({
    ...Object.fromEntries(request.headers),
    "X-User-Id": authContext.userId,
    "X-User-Role": authContext.role,
    "X-Request-Id": crypto.randomUUID(), // For distributed tracing
  }),
  body: request.body,
});

Consideracion clave: Los service bindings son internos y no pueden ser accedidos desde la internet publica. Los Workers downstream confian en los headers inyectados por el gateway porque solo el gateway puede alcanzarlos mediante service bindings.


3. Workers para Servir Micro Frontends

Sirviendo Assets Estaticos

Cloudflare ofrece tres enfoques principales para servir bundles de MFE:

Opcion A: Workers Static Assets (Recomendado para MFEs)

SVG-CF-04: Flujo de servicio de Workers Static Assets -- enrutamiento de solicitudes para contenido estatico vs dinamico Flujo de Servicio de Workers Static Assets Solicitud /dashboard/* MFE Worker handler fetch() decide enrutamiento Asset estatico? Si env.ASSETS.fetch(req) Assets estaticos desde /dist o R2 No Logica del Worker (SSR/API) Respuesta dinamica, consultas D1, etc. El Worker inspecciona la ruta de la solicitud; assets con fingerprint (*.abc123.js) servidos de forma inmutable, HTML re-validado frecuentemente.

Cada MFE es su propio Worker con assets estaticos incluidos. El Worker router reenvia solicitudes mediante service bindings.

# mfe-dashboard/wrangler.toml
name = "mfe-dashboard"
main = "src/index.ts"
compatibility_date = "2026-03-03"

[assets]
directory = "./dist"         # Built frontend assets
binding = "ASSETS"           # Access assets programmatically
// mfe-dashboard/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

export default class DashboardMFE extends WorkerEntrypoint<Env> {
  async fetch(request: Request): Promise<Response> {
    // Serve static assets, with SSR fallback for routes
    return this.env.ASSETS.fetch(request);
  }

  // RPC method for the router to call
  async getAsset(path: string): Promise<Response> {
    return this.env.ASSETS.fetch(new Request(`https://assets.local${path}`));
  }
}

Opcion B: R2 para Bundles Grandes/Versionados

Almacena multiples versiones de cada MFE en R2, usando un esquema de claves estructurado:

r2-bucket/
  mfe-dashboard/
    v1.2.0/
      index.html
      assets/
        main.abc123.js
        styles.def456.css
    v1.3.0/
      index.html
      assets/
        main.xyz789.js
        styles.uvw012.css
  mfe-settings/
    v2.0.0/
      ...
// asset-server/src/index.ts
export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);
    const mfeName = url.pathname.split("/")[1];

    // Look up active version from KV
    const activeVersion = await env.MFE_CONFIG.get(`active:${mfeName}`);
    if (!activeVersion) {
      return new Response("MFE not found", { status: 404 });
    }

    // Construct R2 key
    const assetPath = url.pathname.split("/").slice(2).join("/") || "index.html";
    const r2Key = `${mfeName}/${activeVersion}/${assetPath}`;

    const object = await env.ASSETS_BUCKET.get(r2Key);
    if (!object) {
      return new Response("Asset not found", { status: 404 });
    }

    const headers = new Headers();
    headers.set("Content-Type", getContentType(assetPath));
    headers.set("ETag", object.httpEtag);

    // Fingerprinted assets get long cache; HTML gets short cache
    if (assetPath.match(/\.[a-f0-9]{8,}\.(js|css|woff2?)$/)) {
      headers.set("Cache-Control", "public, max-age=31536000, immutable");
    } else {
      headers.set("Cache-Control", "public, max-age=60, s-maxage=300");
    }

    return new Response(object.body, { headers });
  },
};

Opcion C: Cloudflare Pages (La mas simple)

Cada MFE es un proyecto de Pages. El Worker router obtiene de cada dominio de Pages. Esto es mas simple pero da menos control sobre la seleccion de versiones.

Sistema de Gestión de Versiones

Usa KV (por velocidad y distribucion global) o D1 (para consultas relacionales y registros de auditoria) para almacenar que version de cada MFE esta activa.

Configuración de versiones basada en KV:

// Data structure in KV
// Key: "mfe-config"
// Value:
{
  "dashboard": {
    "activeVersion": "v1.3.0",
    "availableVersions": ["v1.2.0", "v1.3.0", "v1.4.0-beta"],
    "updatedAt": "2025-06-15T10:30:00Z",
    "updatedBy": "admin@example.com"
  },
  "settings": {
    "activeVersion": "v2.0.0",
    "availableVersions": ["v1.9.0", "v2.0.0"],
    "updatedAt": "2025-06-14T08:00:00Z",
    "updatedBy": "admin@example.com"
  },
  "navbar": {
    "activeVersion": "v3.1.0",
    "availableVersions": ["v3.0.0", "v3.1.0"],
    "updatedAt": "2025-06-10T14:00:00Z",
    "updatedBy": "admin@example.com"
  }
}

Worker de API de Administracion para gestión de versiones:

// mfe-admin/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

interface MfeConfig {
  activeVersion: string;
  availableVersions: string[];
  updatedAt: string;
  updatedBy: string;
}

interface AllConfigs {
  [mfeName: string]: MfeConfig;
}

export default class MfeAdminService extends WorkerEntrypoint<Env> {
  // Get all MFE configurations
  async getConfigs(): Promise<AllConfigs> {
    const config = await this.env.MFE_CONFIG.get("mfe-config", "json");
    return config as AllConfigs ?? {};
  }

  // Get active version for a specific MFE
  async getActiveVersion(mfeName: string): Promise<string | null> {
    const configs = await this.getConfigs();
    return configs[mfeName]?.activeVersion ?? null;
  }

  // Set the active version (admin operation)
  async setActiveVersion(mfeName: string, version: string, adminEmail: string): Promise<void> {
    const configs = await this.getConfigs();

    if (!configs[mfeName]) {
      throw new Error(`MFE '${mfeName}' not found`);
    }

    if (!configs[mfeName].availableVersions.includes(version)) {
      throw new Error(`Version '${version}' not available for '${mfeName}'`);
    }

    configs[mfeName].activeVersion = version;
    configs[mfeName].updatedAt = new Date().toISOString();
    configs[mfeName].updatedBy = adminEmail;

    await this.env.MFE_CONFIG.put("mfe-config", JSON.stringify(configs));

    // Purge CDN cache for this MFE
    await this.purgeCache(mfeName);
  }

  // Register a new version after deployment
  async registerVersion(mfeName: string, version: string): Promise<void> {
    const configs = await this.getConfigs();

    if (!configs[mfeName]) {
      configs[mfeName] = {
        activeVersion: version,
        availableVersions: [version],
        updatedAt: new Date().toISOString(),
        updatedBy: "system",
      };
    } else {
      if (!configs[mfeName].availableVersions.includes(version)) {
        configs[mfeName].availableVersions.push(version);
      }
    }

    await this.env.MFE_CONFIG.put("mfe-config", JSON.stringify(configs));
  }

  private async purgeCache(mfeName: string) {
    // Use Cloudflare API to purge cache by prefix
    // or use Cache-Tag based purging
    await fetch(
      `https://api.cloudflare.com/client/v4/zones/${this.env.ZONE_ID}/purge_cache`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${this.env.CF_API_TOKEN}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ prefixes: [`/${mfeName}/`] }),
      }
    );
  }

  // HTTP handler for admin UI
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (request.method === "GET" && url.pathname === "/configs") {
      return Response.json(await this.getConfigs());
    }

    if (request.method === "POST" && url.pathname === "/set-version") {
      const body = await request.json() as any;
      await this.setActiveVersion(body.mfeName, body.version, body.adminEmail);
      return Response.json({ success: true });
    }

    return new Response("Not found", { status: 404 });
  }
}

Alternativa basada en D1 (para registros de auditoria):

CREATE TABLE mfe_versions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  mfe_name TEXT NOT NULL,
  version TEXT NOT NULL,
  is_active BOOLEAN DEFAULT FALSE,
  deployed_at TEXT NOT NULL,
  activated_at TEXT,
  activated_by TEXT,
  UNIQUE(mfe_name, version)
);

CREATE TABLE version_audit_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  mfe_name TEXT NOT NULL,
  old_version TEXT,
  new_version TEXT NOT NULL,
  changed_by TEXT NOT NULL,
  changed_at TEXT NOT NULL,
  reason TEXT
);

D1 es mejor cuando necesitas:

  • Registros de auditoria de quien cambio que y cuando
  • Consultar el historial de versiones
  • Busquedas relacionales (ej., "que MFEs se actualizaron en las ultimas 24 horas?")
  • Garantias transaccionales al actualizar multiples MFEs de forma atomica

KV es mejor cuando necesitas:

  • Lecturas lo mas rapidas posible (replicado globalmente, consistencia eventual)
  • Busquedas simples clave-valor para el Worker router
  • Menor costo a escala

Enfoque híbrido recomendado: Usa D1 como fuente de verdad en el servicio de administracion, y sincroniza las versiones activas a KV para que el Worker router las lea con latencia sub-milisegundo.

Estrategias de Cache CDN

// In the router or asset-serving Worker
function getCacheHeaders(assetPath: string, mfeVersion: string): Headers {
  const headers = new Headers();

  // Fingerprinted assets (main.abc123.js) - immutable, cache forever
  if (assetPath.match(/\.[a-f0-9]{8,}\.(js|css|woff2?|png|jpg|svg)$/)) {
    headers.set("Cache-Control", "public, max-age=31536000, immutable");
  }
  // HTML files - short cache, revalidate often
  else if (assetPath.endsWith(".html") || assetPath === "/") {
    headers.set("Cache-Control", "public, max-age=0, must-revalidate");
    headers.set("CDN-Cache-Control", "max-age=60"); // Cloudflare edge caches 60s
  }
  // Manifests, service workers - no cache
  else if (assetPath.match(/manifest\.json|service-worker\.js/)) {
    headers.set("Cache-Control", "no-cache, no-store");
  }
  // Everything else
  else {
    headers.set("Cache-Control", "public, max-age=3600, s-maxage=86400");
  }

  // Cache-Tag for targeted purging when version changes
  headers.set("Cache-Tag", `mfe-${mfeVersion}`);

  return headers;
}

Purga de cache al cambiar de version: Cuando el administrador cambia una version activa, purga la cache CDN para ese MFE usando la API de Cloudflare (purga por prefijo o Cache-Tag).


4. Patron Backend-for-Frontend en Workers

Visión General de la Arquitectura

SVG-CF-03: Arquitectura BFF -- Workers BFF por MFE conectados via service bindings al API Gateway Arquitectura Backend-for-Frontend Navegador API Gateway Worker BFF-Dashboard Worker BFF-Settings Worker BFF-Navbar Worker svc binding Auth Service Worker User Service Worker APIs Externas (D1, R2, terceros) Service binding RPC / fetch

Worker BFF por Micro Frontend

Cada MFE tiene un Worker BFF dedicado que:

  • Agrega datos de multiples servicios backend
  • Transforma datos a la forma que el MFE necesita
  • Gestiona logica de negocio especifica del MFE
  • Mantiene el MFE ligero (sin orquestación compleja de APIs en el cliente)
// bff-dashboard/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

interface Env {
  AUTH_SERVICE: Service<AuthService>;
  USER_SERVICE: Service<UserService>;
  ANALYTICS_DB: D1Database;
  CACHE: KVNamespace;
}

export default class DashboardBFF extends WorkerEntrypoint<Env> {
  // Called by the dashboard MFE via API gateway
  async getDashboardData(userId: string): Promise<DashboardData> {
    // Parallel fetch from multiple sources
    const [user, recentActivity, stats] = await Promise.all([
      this.env.USER_SERVICE.getUser(userId),
      this.getRecentActivity(userId),
      this.getStats(userId),
    ]);

    // Transform into the exact shape the dashboard MFE expects
    return {
      user: {
        name: user.name,
        avatar: user.avatarUrl,
        plan: user.subscription.planName,
      },
      activity: recentActivity.map(a => ({
        id: a.id,
        description: a.description,
        timeAgo: formatTimeAgo(a.timestamp),
      })),
      stats: {
        totalProjects: stats.projects,
        activeCollaborators: stats.collaborators,
        storageUsed: formatBytes(stats.storageBytes),
      },
    };
  }

  private async getRecentActivity(userId: string) {
    // Check KV cache first
    const cached = await this.env.CACHE.get(`activity:${userId}`, "json");
    if (cached) return cached;

    const result = await this.env.ANALYTICS_DB.prepare(
      "SELECT * FROM activity WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20"
    ).bind(userId).all();

    // Cache for 5 minutes
    await this.env.CACHE.put(
      `activity:${userId}`,
      JSON.stringify(result.results),
      { expirationTtl: 300 }
    );

    return result.results;
  }

  private async getStats(userId: string) {
    return this.env.ANALYTICS_DB.prepare(
      "SELECT COUNT(DISTINCT project_id) as projects, COUNT(DISTINCT collaborator_id) as collaborators, SUM(storage_bytes) as storageBytes FROM user_stats WHERE user_id = ?"
    ).bind(userId).first();
  }

  // HTTP handler fallback
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const userId = request.headers.get("X-User-Id");

    if (!userId) return new Response("Unauthorized", { status: 401 });

    if (url.pathname === "/dashboard-data") {
      const data = await this.getDashboardData(userId);
      return Response.json(data);
    }

    return new Response("Not found", { status: 404 });
  }
}

Servicios Compartidos como Workers Separados

// shared/auth-service/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";

export default class AuthService extends WorkerEntrypoint<Env> {
  async validateToken(token: string) {
    return verifyJWT(token, this.env.JWT_SECRET);
  }

  async getUserPermissions(userId: string): Promise<string[]> {
    const result = await this.env.DB.prepare(
      "SELECT permission FROM user_permissions WHERE user_id = ?"
    ).bind(userId).all();
    return result.results.map(r => r.permission as string);
  }

  async hasPermission(userId: string, permission: string): Promise<boolean> {
    const perms = await this.getUserPermissions(userId);
    return perms.includes(permission);
  }
}

// shared/user-service/src/index.ts
export default class UserService extends WorkerEntrypoint<Env> {
  async getUser(userId: string): Promise<User> {
    return this.env.DB.prepare(
      "SELECT * FROM users WHERE id = ?"
    ).bind(userId).first();
  }

  async updateUser(userId: string, updates: Partial<User>): Promise<User> {
    // ... update logic
  }
}

Configuración de Service Bindings para el Patron BFF

# bff-dashboard/wrangler.toml
name = "bff-dashboard"
main = "src/index.ts"
compatibility_date = "2026-03-03"

[[services]]
binding = "AUTH_SERVICE"
service = "auth-service"

[[services]]
binding = "USER_SERVICE"
service = "user-service"

[[d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics"
database_id = "xxx"

[[kv_namespaces]]
binding = "CACHE"
id = "yyy"

Gestión de Entornos (dev/staging/prod)

Configuración por entorno:

# bff-dashboard/wrangler.toml
name = "bff-dashboard"
main = "src/index.ts"
compatibility_date = "2026-03-03"

# Shared (inheritable) config
[vars]
APP_NAME = "dashboard-bff"

# --- Development ---
[env.dev]
vars = { ENVIRONMENT = "development", LOG_LEVEL = "debug" }

[[env.dev.services]]
binding = "AUTH_SERVICE"
service = "auth-service-dev"

[[env.dev.services]]
binding = "USER_SERVICE"
service = "user-service-dev"

[[env.dev.d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics-dev"
database_id = "dev-xxx"

[[env.dev.kv_namespaces]]
binding = "CACHE"
id = "dev-yyy"

# --- Staging ---
[env.staging]
vars = { ENVIRONMENT = "staging", LOG_LEVEL = "info" }

[[env.staging.services]]
binding = "AUTH_SERVICE"
service = "auth-service-staging"

[[env.staging.services]]
binding = "USER_SERVICE"
service = "user-service-staging"

[[env.staging.d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics-staging"
database_id = "staging-xxx"

[[env.staging.kv_namespaces]]
binding = "CACHE"
id = "staging-yyy"

# --- Production ---
[env.production]
vars = { ENVIRONMENT = "production", LOG_LEVEL = "warn" }

[[env.production.services]]
binding = "AUTH_SERVICE"
service = "auth-service"

[[env.production.services]]
binding = "USER_SERVICE"
service = "user-service"

[[env.production.d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics"
database_id = "prod-xxx"

[[env.production.kv_namespaces]]
binding = "CACHE"
id = "prod-yyy"

Comandos de deploy:

# Deploy to dev
npx wrangler deploy --env dev

# Deploy to staging
npx wrangler deploy --env staging

# Deploy to production
npx wrangler deploy --env production

Secrets por entorno:

# Set secrets per environment
npx wrangler secret put JWT_SECRET --env production
npx wrangler secret put JWT_SECRET --env staging

Secrets de desarrollo local:

# .dev.vars (for default environment)
JWT_SECRET=local-dev-secret

# .dev.vars.staging (for staging environment locally)
JWT_SECRET=staging-secret

Importante: Los bindings y variables de entorno son no heredables en Wrangler. Debes redeclararlos en cada bloque de entorno. El nombre del entorno pasa a ser parte del nombre del Worker desplegado (ej., bff-dashboard-staging), que es lo que otros Workers referencian en sus service bindings.


5. Desarrollo Local con Wrangler

SVG-CF-06: Stack de desarrollo local -- Wrangler v4 + Miniflare con persistencia local Stack de Desarrollo Local Wrangler v4 Miniflare (runtime workerd) Tus Workers + Service Bindings Durable Objects (SQLite local) KV (local) D1 (SQLite local) R2 (fs local) Dev Reg (fs)

Ejecutando Multiples Workers Localmente

Metodo 1: Comando unico con multiples configuraciones (Recomendado)

Pasa multiples archivos de configuración a una sola invocacion de wrangler dev:

npx wrangler dev \
  --config ./gateway/wrangler.toml \
  --config ./bff-dashboard/wrangler.toml \
  --config ./auth-service/wrangler.toml \
  --config ./user-service/wrangler.toml

La primera configuración es el Worker principal expuesto via HTTP. Los Workers restantes solo son accesibles mediante service bindings desde el Worker principal.

Metodo 2: Sesiones de terminal separadas (también funciona)

Desde septiembre de 2025, los Workers ejecutandose en sesiones separadas de wrangler dev pueden comunicarse entre si mediante un dev registry. Esto significa que puedes:

# Terminal 1
cd gateway && npx wrangler dev

# Terminal 2
cd auth-service && npx wrangler dev

# Terminal 3
cd bff-dashboard && npx wrangler dev

Los service bindings se resuelven automaticamente entre comandos dev separados. El dev registry gestiona el descubrimiento.

Metodo 3: Bindings remotos para servicios que no son tuyos

Para servicios mantenidos por otros equipos, usa bindings remotos para conectar con versiones desplegadas:

# wrangler.toml (local development overrides)
[[services]]
binding = "AUTH_SERVICE"
service = "auth-service"
remote = true  # Hit the deployed auth-service instead of local

Testing Local de Durable Objects

Los Durable Objects funcionan localmente con wrangler dev sin configuración adicional. Miniflare (integrado en Wrangler) simula los DOs usando el mismo runtime workerd que se usa en produccion.

npx wrangler dev

Esto automaticamente:

  • Carga los bindings de DO desde tu wrangler.toml
  • Crea bases de datos SQLite locales para el almacenamiento de DOs
  • Gestiona conexiones WebSocket localmente
  • Simula el comportamiento de hibernacion

Limitacion importante: Los bindings de Durable Objects no pueden establecerse como remote: true. Debes:

  1. Ejecutar los DOs localmente (comportamiento por defecto)
  2. Desplegar el Worker con DOs y usar un service binding remoto desde tu Worker local para comunicarte con el

Agregando datos de prueba locales:

# Seed local KV data
npx wrangler kv key put --binding MFE_CONFIG "mfe-config" '{"dashboard":{"activeVersion":"v1.0.0"}}' --local

# Seed local D1 data
npx wrangler d1 execute analytics-dev --local --command "INSERT INTO ..."

Testing con Vitest

Cloudflare proporciona @cloudflare/vitest-pool-workers para testing aislado:

// __tests__/chat-room.test.ts
import { env, runInDurableObject, runDurableObjectAlarm } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("ChatRoom", () => {
  it("should accept WebSocket connections", async () => {
    const id = env.ROOMS.idFromName("test-room");
    const stub = env.ROOMS.get(id);

    const response = await stub.fetch("http://localhost/ws?username=alice", {
      headers: { Upgrade: "websocket" },
    });

    expect(response.status).toBe(101);
    expect(response.webSocket).toBeDefined();
  });

  it("should broadcast messages", async () => {
    const id = env.ROOMS.idFromName("test-room");
    const stub = env.ROOMS.get(id);

    await runInDurableObject(stub, async (instance) => {
      // Access the DO instance directly for testing
      const sessions = instance.ctx.getWebSockets();
      expect(sessions.length).toBeGreaterThan(0);
    });
  });
});

Como Funciona wrangler dev Internamente

  1. Wrangler lee tu configuración wrangler.toml
  2. Empaqueta tu codigo del Worker usando esbuild
  3. Inicia un proceso local de workerd (el mismo runtime que en produccion) via Miniflare
  4. Los bindings (KV, D1, DOs, R2) se simulan localmente usando SQLite
  5. La vigilancia de archivos detecta cambios y recarga el Worker en caliente
  6. Un dev registry (basado en sistema de archivos) permite la resolucion de service bindings entre sesiones

Flags principales:

# Default: fully local
npx wrangler dev

# Use remote resources (not recommended for DOs)
npx wrangler dev --remote

# Specify port
npx wrangler dev --port 8787

# Specify environment
npx wrangler dev --env staging

# With Inspector (Chrome DevTools debugging)
npx wrangler dev --inspector-port 9229

6. Recomendacion General de Arquitectura

Estructura de Proyecto Recomendada

cloudflare-micro-frontends/
├── packages/
│   ├── gateway/                    # API Gateway Worker
│   │   ├── src/index.ts
│   │   └── wrangler.toml
│   ├── router/                     # MFE Router Worker (frontend)
│   │   ├── src/index.ts
│   │   └── wrangler.toml
│   ├── mfe-dashboard/              # Dashboard MFE (static + optional SSR)
│   │   ├── src/
│   │   ├── dist/                   # Built assets
│   │   └── wrangler.toml
│   ├── mfe-settings/               # Settings MFE
│   │   ├── src/
│   │   ├── dist/
│   │   └── wrangler.toml
│   ├── mfe-navbar/                 # Navbar MFE (shared shell)
│   │   ├── src/
│   │   ├── dist/
│   │   └── wrangler.toml
│   ├── bff-dashboard/              # BFF for Dashboard
│   │   ├── src/index.ts
│   │   └── wrangler.toml
│   ├── bff-settings/               # BFF for Settings
│   │   ├── src/index.ts
│   │   └── wrangler.toml
│   ├── services/
│   │   ├── auth/                   # Shared Auth Service
│   │   │   ├── src/index.ts
│   │   │   └── wrangler.toml
│   │   ├── user/                   # Shared User Service
│   │   │   ├── src/index.ts
│   │   │   └── wrangler.toml
│   │   └── realtime/               # Durable Objects for WebSockets
│   │       ├── src/index.ts
│   │       └── wrangler.toml
│   ├── mfe-admin/                  # Admin panel for version management
│   │   ├── src/index.ts
│   │   └── wrangler.toml
│   └── shared/                     # Shared TypeScript types/utilities
│       ├── types/
│       └── utils/
├── package.json                    # Monorepo root (npm workspaces or turborepo)
├── turbo.json                      # If using Turborepo
└── tsconfig.base.json

Configuración del Worker Router (Patron MFE de Cloudflare)

# router/wrangler.toml
name = "mfe-router"
main = "src/index.ts"
compatibility_date = "2026-03-03"

# Service bindings to each MFE
[[services]]
binding = "DASHBOARD"
service = "mfe-dashboard"

[[services]]
binding = "SETTINGS"
service = "mfe-settings"

[[services]]
binding = "NAVBAR"
service = "mfe-navbar"

[vars]
ROUTES = '''
[
  { "path": "/dashboard", "binding": "DASHBOARD", "preload": true },
  { "path": "/settings", "binding": "SETTINGS" },
  { "path": "/", "binding": "NAVBAR" }
]
'''

# Optional: smooth transitions between MFEs
SMOOTH_TRANSITIONS = "true"

El router MFE de Cloudflare automaticamente:

  • Reescribe rutas de assets (ej., /assets/main.js se convierte en /dashboard/assets/main.js)
  • Gestiona reescrituras de CSS url()
  • Soporta View Transitions API para navegacion fluida
  • Inyecta Speculation Rules para prefetching en navegadores Chromium
  • Elimina los prefijos de montaje antes de reenviar a los Workers MFE

7. Problemas Comunes y Trampas

Durable Objects

  1. Desplegar codigo nuevo desconecta TODOS los WebSockets. Cada despliegue de codigo reinicia todas las instancias de DOs, terminando las conexiones existentes. Planifica logica de reconexion del lado del cliente con backoff exponencial.

  2. Los DOs no conocen su propio nombre/ID. Usa un metodo explicito init() o pasa la identidad mediante la URL de la solicitud al crear el DO por primera vez.

  3. El estado en memoria se pierde al ser desalojado. Las propiedades de clase se borran cuando un DO es desalojado de memoria por inactividad. Usa almacenamiento SQLite o serializeAttachment() para cualquier cosa que deba sobrevivir.

  4. blockConcurrencyWhile() mata el rendimiento. Solo usalo para inicializacion (migraciones de esquema). Limita el rendimiento a aproximadamente 200 req/seg si cada llamada tarda 5ms.

  5. Los alarms pueden dispararse mas de una vez. Haz los handlers de alarms idempotentes. Verifica el estado antes de realizar acciones.

  6. No hay hooks de apagado. No puedes ejecutar logica de limpieza de forma fiable cuando un DO esta siendo desalojado. Persiste el estado de forma incremental mientras procesas, no en un paso final de limpieza.

  7. Un unico singleton global es un anti-patron. Nunca canalices todo el trafico a traves de una sola instancia de DO. Encuentra limites naturales de particionado (por usuario, por sala, por documento).

Service Bindings y Workers

  1. Los service bindings tienen ambito de cuenta. Ambos Workers deben estar en la misma cuenta de Cloudflare. Los service bindings entre cuentas no estan soportados.

  2. Se crea una nueva instancia de WorkerEntrypoint por invocacion. Son sin estado. No almacenes estado en propiedades de instancia esperando que persistan entre llamadas.

  3. Siempre usa await en llamadas RPC. Olvidar el await traga errores silenciosamente, causando problemas dificiles de depurar.

  4. Los bindings de entorno no son heredables. En entornos de Wrangler, debes redeclarar KV, D1, R2 y service bindings en cada bloque [env.X]. No se heredan del nivel superior.

  5. El nombre del Worker cambia con el entorno. Un Worker llamado my-worker con entorno staging se despliega como my-worker-staging. Todos los service bindings que lo referencien deben usar el nombre completo.

Cache y Assets

  1. KV tiene consistencia eventual. Despues de actualizar un valor en KV, puede tardar hasta 60 segundos en propagarse globalmente. Para cambios de version, purga la cache CDN inmediatamente y acepta este retraso de propagación, o usa un patron de lectura a traves de la Cache API.

  2. Workers Static Assets no soporta Cache-Control personalizado via _headers para respuestas SSR. Si tu MFE usa SSR, establece los headers de cache directamente en el codigo de tu Worker, no en el archivo _headers.

  3. R2 no es un CDN por defecto. Las lecturas de R2 van a la region de almacenamiento mas cercana. Pon Cloudflare Cache delante de R2 para cache en el edge, o usa Workers para cachear respuestas.

Desarrollo Local

  1. Los bindings de Durable Objects no pueden ser remotos. Debes ejecutar los DOs localmente o acceder a ellos a traves de un Worker desplegado mediante un service binding remoto. No existe la opcion remote: true para bindings de DOs.

  2. wrangler dev con multiples configuraciones solo expone el primer Worker via HTTP. Los otros Workers en el mismo comando solo son accesibles mediante service bindings. Si necesitas probarlos directamente via HTTP, ejecutalos en sesiones de terminal separadas.

  3. El estado de almacenamiento se reinicia entre sesiones de wrangler dev. El almacenamiento local de KV, D1 y DOs es efimero a menos que uses --persist (habilitado por defecto en versiones recientes de Wrangler) o alimentes datos explicitamente.

  4. El dev registry entre sesiones esta basado en sistema de archivos. Si las sesiones estan en diferentes contextos de sistema de archivos (ej., contenedores Docker), el descubrimiento de service bindings entre sesiones puede no funcionar.

Especificos de Micro Frontends

  1. La reescritura de rutas es automatica pero no magica. El router MFE de Cloudflare reescribe prefijos de assets conocidos (/assets/, /static/, /build/, /_astro/). Si tu framework usa rutas no estandar, configura ASSET_PREFIXES explicitamente, o la carga de assets fallara.

  2. Cada despliegue de MFE es independiente pero el binding del router es estatico. Agregar un nuevo MFE requiere actualizar el wrangler.toml del router con un nuevo service binding y redesplegar el router. Eliminar un MFE también requiere una actualizacion del router.

  3. Los despliegues versionados no rastrean el estado de almacenamiento. Los datos de KV, R2, D1 y DOs no se versionan junto con el codigo de tu Worker. Un rollback a una version anterior del Worker no revierte cambios en el esquema de la base de datos ni datos almacenados.


Fuentes