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,