miércoles, noviembre 04, 2026

Cómo portar un proyecto de Azure a Cloudflare: Cómo lo hicimos con Weave.js de Inditex Tech Open Source

Cuando Inditex Tech Open Source, conocida por su cultura técnica, excelencia y comunidad sacó esta propuesta sabíamos que teníamos que colaborar y aportar nuestro granito de arena a la comunidad. Da gusto ver a una empresa española apostar por la tecnología, el talento y la innovación, y hacerlo con un propósito que va más allá de lo que construye: también comparte ese trabajo para que otros podamos aprender, experimentar y llevarlo un paso más allá.

Figura 1: Cómo portar un proyecto de Azure a Cloudflare.
Cómo lo hicimos con Weave.js de Inditex Tech Open Source

Esa forma de entender la tecnología encaja de lleno con el ADN de Cloudflare. Nosotros también creemos que compartir lo que aprendemos y contar abiertamente cómo construimos nuestra tecnología es parte del trabajo. De ahí nuestro blog de Cloudflare, donde entramos hasta la cocina de lo que hacemos, nuestra apuesta por la transparencia y un tier gratuito amplio y accesible para todos los desarrolladores del mundo.

Así que decidimos llevar esa afinidad al terreno que más nos gusta: construir. Y ahí apareció un reto a la altura, Weave.js, una herramienta para crear aplicaciones de colaboración en tiempo real, como pizarras colaborativas y editores de diagramas. Nuestra forma de contribuir sería explorar cómo llevarla a Cloudflare y compartir con la comunidad lo que aprendiéramos por el camino.


Al mirar todas las piezas el backend, el almacenamiento, la sincronización, la autenticación y la IA, nuestra primera pregunta técnica fue inevitable: 

«¿Cuánto tardaría en hacer funcionar todo esto en Workers?».

Nadie nos lo pidió. Lo hicimos porque nos picó el gusanillo y porque (seremos sinceros) en Cloudflare todos somos builders: nos gusta construir, probar ideas y hacer shipping, no dejarlas en una presentación. Y aquí teníamos un nuevo reto para poner todo eso en práctica. }:)

Lo interesante era comprobar cuánto podíamos simplificar el despliegue y la operación al tener todas las piezas en una misma plataforma: Workers para la lógica, D1 para los datos, Queues para las tareas en segundo plano y Access para la autenticación. Menos infraestructura que administrar y más tiempo para el producto. Y ahí entraban la «magia» de los Durable Objects para coordinar las salas en tiempo real, R2 para almacenar contenido sin cargos por transferencia de salida y Workers AI para integrar inferencia de baja latencia sin gestionar servidores de modelos.

Figura 3: El reto de migrar Weaver.js a Cloudflare

No se trataba solo de hacerlo funcionar, sino de explorar lo que permite hoy la plataforma: reducir complejidad, ajustar buena parte del coste al uso real y llevar las ideas antes a producción. Todo con una aplicación de verdad, no un «hola, mundo», y descubriendo por el camino qué encajaba, qué había que adaptar y dónde estaban los límites. Lo que os voy a contar hoy es esa aventura. Y os adelanto que fue más divertida ¡y accidentada! de lo que esperaba.

El punto de partida: el repo de Inditex tal cual

Lo primero que hice fue clonar los dos repositorios públicos (weavejs-backend y weavejs-frontend) y leer el código de arriba a abajo. La arquitectura original de Weave.js era la que podríais imaginar para una aplicación Node.js moderna y bien hecha:

Figura 4: Diagrama de arquitectura de Weave.js
  • Backend: Express.js corriendo en un servidor Node.
  • Almacenamiento: Azure Blob Storage para guardar el estado de las salas (documentos Yjs en binario) y los assets (imágenes, vídeos).
  • Base de datos: PostgreSQL para todo lo demás como salas, páginas, hilos de comentarios, chats o tareas de exportación.
  • Cola de trabajos: pg-boss, una librería que usa PostgreSQL como backend de colas para gestionar los trabajos asíncronos (generación de imágenes con IA, exportaciones a PDF, etc.).
  • Tiempo real: Azure Web PubSub para sincronizar el documento Yjs entre todos los clientes conectados a una sala y para el canal de eventos secundario (lo que llamábamos el "comm-bus").
  • Autenticación: better-auth con su propia tabla en PostgreSQL.
  • IA: Google Generative AI para la generación de imágenes.
  • Frontend: TanStack Start + Vite + Nitro, desplegado también en un servidor Node.
No está nada mal para una aplicación colaborativa en tiempo real. La gente de Inditex Tech lo tiene muy bien hecho. Pero desde mi perspectiva había una pregunta obvia: 

¿Podemos hacer todo esto con Cloudflare y sin tocar apenas el código del frontend?


El proyecto del Porting a Cloudflare

Como es un proyecto complejo, decidimos hacerlo por partes, así que antes de entrar en detalle de cada una de las partes, os dejo el resumen de lo que cambiamos. Cuáles fueron los cambiso que realizamos pieza por pieza para que Weave.js siguiera funcionando perfectamente, pero en Cloudflare.

Figura 5: El mapa del cambio

Y ahora, vamos con cada una de las partes, que hay mucho que contar de todas y cada una de ellas, como vais a poder leer aquí.

De Express a Hono: el cambio más fácil

Este fue el cambio más agradable de todos. Hono es un framework HTTP tan parecido a Express que la migración de rutas fue casi mecánica. La diferencia importante es el modelo de ejecución. Express espera vivir en un proceso Node.js persistente. Hono está diseñado para correr en un Worker de Cloudflare, es decir, sin estado global entre peticiones, con acceso a los bindings de la plataforma (env.DB, env.IMAGES_BUCKET, etcétera) directamente desde el contexto de Hono.
// Antes: Express
app.get('/rooms/:roomId/images', requireAuth, async (req, res) => {
  const images = await db.query('SELECT ...');
  res.json(images);
});

// Después: Hono en Workers
imagesRouter.get('/rooms/:roomId/images', requireAuth, async (c) => {
  const result = await c.env.DB.prepare('SELECT ...').all();
  return c.json(result.results);
});
El router de Hono se monta en el Worker, y el Worker exporta los handlers de fetch y queue. Nada más.

De Azure Blob Storage a R2: sin egress, sin dramas

Azure Blob Storage y R2 son conceptualmente idénticos: almacenamiento de objetos con clave. La migración fue directa. Lo que antes era esto:
await blobClient.uploadData(buffer, { 
    blobHTTPHeaders: { blobContentType: contentType } });
Pasó a ser esto:
await c.env.IMAGES_BUCKET.put(key, buffer, { httpMetadata: { contentType } });
Creamos cuatro buckets en R2:
  • weavejs-rooms: estado binario de los documentos Yjs
  • weavejs-images: imágenes subidas por los usuarios
  • weavejs-videos: vídeos
  • weavejs-generated-images: imágenes generadas por IA
La gran ventaja: R2 no cobra egress. En Azure, cada vez que un cliente descarga una imagen, pagas. En R2, no. Para una aplicación colaborativa donde se sirven imágenes constantemente, eso no es un detalle menor.

De PostgreSQL a D1 + Durable Objects: aquí sí que hay que pensar

Este fue el cambio más interesante. Y el que más hay que entender bien. D1 es un SQLite gestionado por Cloudflare. Perfecto para datos relacionales que no necesitan fuerte consistencia distribuida en tiempo real: salas, páginas, hilos de comentarios, metadatos de imágenes. Definimos las migraciones en SQL y listo.
CREATE TABLE rooms (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  kind TEXT NOT NULL DEFAULT 'showcase',
  status TEXT NOT NULL DEFAULT 'active',
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);
Pero hay un tipo de dato que D1 no puede manejar bien: el estado de conexión en tiempo real. ¿Qué clientes están conectados ahora mismo a qué sala? Eso requiere consistencia fuerte, una sola fuente de verdad. Para eso usamos un Durable Object.

El ConnectionRegistry es un DO singleton que usa SQLite interno (la API ctx.storage.sql de los DOs de nueva generación) para rastrear las conexiones activas. Una sola instancia, en un solo lugar del planeta, con garantía de consistencia fuerte. Exactamente lo que necesitas para algo así.

De Azure Web PubSub a Durable Objects: el cambio más grande

Azure Web PubSub era el corazón de la sincronización en tiempo real. Cada sala tenía un "hub" donde se publicaban y suscribían los eventos del documento Yjs. Reemplazarlo fue el reto más técnico de toda la migración. 

La solución: dos Durable Objects por sala.

1.- RoomSync: el documento Yjs vivo

RoomSync es el DO que mantiene el documento Yjs de cada sala. Implementa el protocolo y-websocket directamente, de forma que el cliente no sabe que está hablando con un Worker en lugar de con el servidor Node original.

Cuando un cliente se conecta, el Worker negocia la URL del WebSocket y el cliente conecta directamente al DO. El DO usa la Hibernation API de Cloudflare, lo que significa que cuando no hay mensajes, el DO hiberna y no consume CPU. Los WebSockets se mantienen abiertos sin coste.

El estado del documento Yjs se persiste en R2 cada vez que hay cambios, para que sobreviva reinicios del DO.
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
  // Protocolo y-websocket: sync, update, awareness...
  const data = typeof message === 'string'
    ? new TextEncoder().encode(message)
    : new Uint8Array(message as ArrayBuffer);
  // broadcast a todos los clientes conectados
  for (const client of this.ctx.getWebSockets()) {
    if (client !== ws && client.readyState === WebSocket.READY_STATE_OPEN) {
      client.send(data);
    }
  }
}

2.- CommBus: el canal de eventos secundario

Además del documento Yjs, la aplicación tiene un segundo canal de WebSocket para eventos más ligeros: notificaciones de que una imagen se ha procesado, progress de exportaciones, heartbeats. A esto lo llamábamos el "comm-bus".

CommBus es otro DO por sala, más simple que RoomSync. Solo hace broadcast de mensajes JSON a todos los clientes conectados. También usa Hibernation API.

Lo más interesante: con Azure Web PubSub, el backend publicaba eventos en el hub y Azure se encargaba del fanout. Con los DOs, el Worker llama directamente al DO de la sala y el DO hace el fanout. Sin intermediarios, sin latencia adicional de round-trip a Azure.

De pg-boss a Cloudflare Queues: colas sin PostgreSQL

En el caso de pg-boss éste usaba PostgreSQL como backend de cola. Los trabajos asíncronos (generación de imágenes con IA, transformaciones de imágenes, exportaciones a PDF) se encolaban en una tabla de PostgreSQL y un worker de Node los procesaba.

En nuestros Workers, esto se convierte en Cloudflare Queues. El producer es el Worker del backend: cuando llega una petición de exportación o generación de imagen, encola el mensaje y devuelve un 202 Accepted con un taskId.

await c.env.WORKLOADS_QUEUE.send({ jobType: 'generate-images', taskId, roomId, payload: body, }); return c.json({ taskId, status: 'pending' }, 202);

El consumer es el mismo Worker, pero en su handler de queue:
 
async queue(batch: MessageBatch%lt;WorkloadMessage%gt;, env: Env) {
  for (const message of batch.messages) {
    await handleWorkload(message.body, env);
    message.ack();
  }
}

El estado de cada tarea se guarda en D1 (tabla tasks), y el frontend hace polling hasta que el estado cambia a completed.

De better-auth a Cloudflare Access: autenticación sin base de datos

Vamos con better-auth que también estaba en PostgreSQL e implicaba gestionar sesiones, tokens de refresco, tablas de usuarios... Todo eso desapareció:

Pusimos Cloudflare Access delante de la aplicación.

Access gestiona el login (con el proveedor de identidad que quieras: Google, GitHub, OTP, passkeys) y emite un JWT firmado que llega a cada petición como la cookie CF_Authorization o el header Cf-Access-Jwt-Assertion. El backend solo tiene que verificar ese JWT:

const { payload } = await jwtVerify(token, JWKS, {
  issuer: env.CF_ACCESS_TEAM_DOMAIN,
  audience: env.CF_ACCESS_AUD,
});
c.set('userId', payload.sub ?? payload.email);

Sin tabla de usuarios. Sin tabla de sesiones. Sin refresh tokens. El JWKS se rota automáticamente cada seis semanas y jose lo gestiona con createRemoteJWKSet, que cachea y rota transparentemente. No está mal para eliminar toda una capa de complejidad.

De Google Generative AI a Workers AI: IA en el edge

La generación de imágenes con IA pasó de llamar a Google Generative AI desde un servidor Node a usar el binding AI de Workers, con los modelos FLUX disponibles en Workers AI, enrutado todo a través de AI Gateway.
const result = await env.AI.run(
  '@cf/black-forest-labs/flux-1-schnell',
  { prompt, steps: 8 },
  { gateway: { id: env.AI_GATEWAY_ID, skipCache: false } }
);

AI Gateway añade logging, caché semántica y control de costes sin cambiar una línea del código de inferencia. Si el mismo prompt ya se generó antes, devuelve la imagen cacheada en milisegundos.


Para los modelos que no están en Workers AI (como gpt-image-1 de OpenAI) seguimos usando la API directa, pero también enrutada a través de AI Gateway para tener el mismo sistema de observabilidad.



El chat con IA también pasó a Workers AI, usando el modelo llama-3.3-70b-instruct-fp8-fast con streaming. El Worker devuelve el ReadableStream de SSE directamente desde el binding, sin transformaciones intermedias.

De Node.js con sharp a Cloudflare Images: transformaciones sin binarios nativos

Aquí nos encontramos con la limitación más dura del modelo de Workers: no puedes ejecutar binarios nativos. sharp, skia-canvas, ffmpeg son todos binarios de Node.js que no pueden correr en un isolate de V8. Para las transformaciones de imagen básicas (flip, escala de grises, eliminar fondo) usamos el Cloudflare Images binding, que hace las transformaciones de forma nativa en el edge:
  
const result = await env.IMAGES
  .input({ imageKey: `${roomId}/${imageId}` })
  .transform({ flip: 'h' })
  .output({ format: 'image/webp' });

Para las operaciones más complejas que no soporta el binding, como la inversión de colores, el Worker devuelve una señal al frontend indicando que debe procesar la imagen client-side usando la Canvas API. No es perfecto, pero funciona. Podríamos haber implementado esta funcionalidad con Cloudflare Containers, pero dejamos esto para el roadmap futuro.

Las exportaciones a PDF y las capturas de página para la presentación son el único caso pendiente. Esas tareas se encolan y quedan marcadas como requires-external-processing. La solución pendiente es desplegar un Cloudflare Container que ejecute el código Node.js original de exportación y escriba el resultado de vuelta a R2.

El frontend: TanStack Start en Workers con Nitro

El frontend ya usaba TanStack Start + Vite + Nitro. Nitro tiene un preset cloudflare_module que compila el servidor SSR directamente a un módulo de Worker. Un único cambio de configuración:
NITRO_PRESET=cloudflare_module vite build --mode production
Y el resultado es un index.mjs listo para wrangler deploy, con los assets estáticos en .output/public/ servidos desde Workers Static Assets. Los únicos cambios que tuvimos que hacer en el frontend:
  • Eliminar sharp (no puede importarse en un Worker): añadimos un stub que lanza un error descriptivo.
  • Reescribir el middleware de "coming soon" para usar la Web Crypto API (crypto.subtle) en lugar de crypto.createHmac de Node.
  • Reemplazar useGetAzureWebPubSubProvider por useGetWebsocketsProvider, que usa WeaveStoreWebsockets en lugar del cliente de Azure.
Ese último cambio fue casi poético en su simplicidad: una sola línea diferente en el componente de la sala.

Los bugs que encontramos por el camino

La migración no fue perfecta desde el primer momento. Os cuento los más divertidos.
  • El race condition del WeaveProvider. El SDK de Weave llama a registerStore() dentro de un método start() asíncrono. Si React desmontaba el componente <WeaveProvider> antes de que start() terminara —lo que pasaba cuando cambiaba el key del componente al resolverse la primera página— destroy() se llamaba antes de que this.store estuviera asignado. 
Resultado: TypeError: Cannot read properties of undefined (reading 'disconnect'). La solución: no montar <WeaveProvider> hasta que actualPageId esté definido, para que la key sea estable desde el primer render.
  • La identidad del usuario. Las salas que había creado con el sistema original de better-auth usaban un UUID v4 generado por la propia aplicación. Cloudflare Access usa el sub del JWT, que es el UUID de la identidad en Access: otro valor completamente distinto. Así que al arrancar con Access, el backend no encontraba mis salas en D1 porque los IDs no coincidían.
La solución fue buscar el sub de mi usuario de Access mediante la API de Cloudflare y lanzar una query de migración directamente en D1 para reasignar los room_users al nuevo userId. Desde fuera me imagino que esto suena a obviedad, pero cuando estás depurando a las 11 de la noche y las salas "desaparecen"... no es tan obvio. }:)
  • Las rutas del frontend apuntando a endpoints inexistentes. Al auditar todos los endpoints descubrimos que varios frontends llamaban a rutas que en el nuevo backend no existían: /rooms/:roomId/chats en lugar de /rooms/:roomId/ai/chats, el endpoint de presentación en /api/v1 en lugar de /api/v2, las rutas de templates con scope de sala que el backend global no tenía... Una auditoría completa de todos los apiFetch del frontend contra todos los routers del backend reveló diez discrepancias. Las corregimos todas.
El resultado final

La arquitectura final tiene exactamente dos Workers desplegados:
  • Backend: Hono + D1 + R2 (×4) + Durable Objects (×3 clases) + Queues + Workers AI + Cloudflare Images + Cloudflare Access.
  • Frontend: TanStack Start + Nitro + Workers Static Assets.
Sin servidores que escalar. Sin bases de datos que parchear. Sin factura de Azure Web PubSub por conexiones WebSocket. Sin egress de Azure Blob Storage.

Figura 8: Arquitectura de Weave.js en Cloudflare

Y todo desplegable con dos comandos:
  • cd backend && npm run deploy
  • cd frontend && npm run deploy
¿Mereció la pena? Para mí, rotundamente sí. Y no solo como ejercicio técnico. La respuesta a mi pregunta inicial "¿cuánto tarda en correr esto en Workers?" resultó ser "un par de días quemando tokens". Para una aplicación colaborativa en tiempo real con sincronización CRDT, IA generativa, colas de trabajos asíncronos y autenticación Zero Trust.

Lo más valioso que me llevé no es el código. Es la comprensión de cómo los Durable Objects resuelven el problema de estado distribuido en tiempo real de una forma que ningún servicio gestionado externo puede igualar: el estado vive en el edge, co-localizado con los clientes, sin round-trips a una región central.

Eso, para una aplicación de colaboración en tiempo real, es EL detalle.

Y dicho esto: si alguien de Inditex Tech Open Source está leyendo esto y quiere charlar sobre la migración, ya sabéis dónde encontrarme, que seguro que podemos hacer más proyectos de estos juntos.

Saludos,

Autor: Carlos Luengo,  Senior Account Executive en Cloudflare. 

Entrada destacada

Hacking IA: Jailbreak, Prompt Injection, Hallucinations & Unalignment. Nuestro nuevo libro en 0xWord

Pocas veces me ha hecho tanta ilusión que saliera un nuevo libro en 0xWord como con este libro de " Hacking IA: Jailbreak, Prompt Inje...

Entradas populares