El formulario de la lista de espera pide tres datos —nombre, teléfono y correo— y detrás tenía un backend Django en una EC2 que estaba apagada a propósito. Encenderla para recibir un POST cada tanto me parecía caro en plata y en superficie de ataque. Así que moví el endpoint al edge: un Pages Function en Cloudflare Workers que valida, limita por IP y envía dos correos con la API de Brevo. Cero servidores.
El problema: un formulario que no justifica un servidor
El sitio ya vivía en Cloudflare Pages como artefacto estático de Astro. El backend Django seguía en el repo —modelo, migración, admin— pero la instancia estaba apagada y el túnel de Cloudflare caído. La opción "obvia" era encenderla, pero para tres campos y dos correos implicaba gunicorn, base de datos, túnel, certificados, backups y un cold start cada vez que nadie la visitaba. También implicaba que un bot rellenando el formulario golpeara Django antes de que yo pudiera filtrarlo.
La restricción real era otra: el POST tenía que ser same-origin. Si el navegador llamaba a otro dominio, volvían el preflight, los CORS y una dependencia más. Con el endpoint en el mismo dominio, el formulario no sabe que hay un Worker: hace fetch('/api/lista-espera') y recibe JSON.
Cloudflare Workers: una ruta propia y el resto al artefacto estático
Pages tiene dos formas de ejecutar código. Elegí el modo avanzado: un único _worker.js que intercepta la ruta y delega todo lo demás a env.ASSETS. Es el equivalente a un router mínimo, sin framework y sin dependencias instaladas.
const RUTA = '/api/lista-espera';
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === RUTA) {
if (request.method !== 'POST') {
return json({ success: false, error: 'Método no permitido.' }, 405);
}
return manejarListaEspera(request, env);
}
// El resto del sitio sigue siendo el artefacto estático de Astro.
return env.ASSETS.fetch(request);
},
}; Ese último return es la línea que más me costó entender: en modo avanzado el Worker es el único punto de entrada. Si no delegas, el Worker no tiene nada que devolver para el resto del sitio y el artefacto estático queda inaccesible aunque el build esté perfecto. No hay una ruta "por defecto" que te salve.
Validar en el borde: honeypot, longitudes y rate limit por IP
El Worker no confía en nada de lo que llega. Valida nombre, teléfono y correo con longitudes máximas, exige entre 8 y 15 dígitos en el teléfono y descarta el cuerpo si no es JSON. El honeypot es un campo website invisible: si un bot lo rellena, respondo 201 sin enviar nada. Al humano no le cuesta nada; al bot lo saca del flujo sin darle una señal de que fue detectado.
Para el rate limit no levanté KV ni Durable Objects. Usé la Cache API con una clave por IP y TTL de 60 segundos:
async function limitarPorIp(request) {
const ip = request.headers.get('cf-connecting-ip') || 'sin-ip';
const clave = new Request(
`https://limite.lista-espera.cuotito.com/${encodeURIComponent(ip)}`,
);
try {
if (await caches.default.match(clave)) return false;
await caches.default.put(clave, new Response('1', {
headers: { 'cache-control': 'max-age=60' },
}));
} catch {
// Si la Cache API no está disponible, no bloqueo el formulario.
}
return true;
} Es best-effort y lo digo con todas las letras: la Cache API vive por colo, así que una IP que rote entre PoPs puede pasar dos veces. Para una lista de espera alcanza. Para un endpoint de cobro no lo usaría: ahí necesito idempotencia real en base de datos o un Durable Object. El patrón sirve para este caso, no para todos.
Brevo: dos correos con prioridades distintas
El Worker manda dos mensajes: un aviso interno con replyTo del interesado y una confirmación al interesado. El orden es deliberado. Si falla el aviso interno, devuelvo 502 y el formulario muestra un error accionable. Si falla la confirmación, el lead ya quedó registrado: respondo 201 con confirmacion_enviada: false en vez de fingir que todo falló y perder el contacto.
try {
await enviarBrevo(env, notificacion);
} catch (error) {
return json({
success: false,
code: 'BREVO_ERROR',
error: 'No pudimos registrar tus datos en este momento.',
}, 502);
}
let confirmacionEnviada = true;
try {
await enviarBrevo(env, confirmacion);
} catch {
confirmacionEnviada = false;
}
return json({ success: true, confirmacion_enviada: confirmacionEnviada }, 201); También escapé cada valor antes de meterlo en el HTML del correo. El nombre y el teléfono vienen del usuario; sin escaparHtml, un <script> en el nombre viaja al buzón del equipo. Y si falta BREVO_API_KEY, el Worker responde 503 BREVO_NO_CONFIGURADO en vez de decir "listo": un formulario que miente es peor que uno caído.
Los tres tropiezos del despliegue
- El archivo en la carpeta equivocada.
_worker.jsva enpublic/, no en la raíz del frontend. Astro solo copia adist/clientlo que está enpublic/. Si está fuera, el artefacto se publica sin Function y la ruta cae en el 404 del sitio. - El script de artefacto no lo validaba. Empaquetaba el sitio borrando
_routes.jsonywrangler.json—correcto para un deploy estático— pero no comprobaba que_worker.jssiguiera ahí. Un build que lo perdiera pasaba igual. Ahora está en la lista de archivos críticos y el script aborta si falta. - El validador de assets con hash lo marcaba como inválido. Una Pages Function no es un asset inmutable. La exceptué en
verify-astro-baseline.js(RNF-ASTRO-15) para que el baseline no bloqueara el build por un archivo que no debe llevar hash.
CI/CD: el 405 como contrato
El deploy no lo doy por bueno con "subió". El workflow de CD verifica en producción que GET /api/lista-espera devuelva 405, además de las rutas del sitio (200) y una inexistente (404). Ese check convierte el contrato del Worker en algo que el pipeline puede romper: si mañana alguien mueve el archivo o cambia el método, el deploy falla antes de que un usuario vea un formulario que no envía.
Y un detalle que no es código: BREVO_API_KEY es un secret de Pages. Cambiarlo no actualiza la Function ya desplegada; hace falta un deployment nuevo. Lo aprendí cuando el 503 seguía apareciendo con la clave ya cargada.
Resultados
GET /api/lista-espera→ 405 en producción; el resto del sitio sigue sirviendo el artefacto estático.POSTde prueba → 201,success: true,confirmacion_enviada: truey dos correos reales recibidos en[email protected].- 0 servidores: la EC2 y el túnel siguen apagados; el formulario no los toca.
- 1 secret en Pages y 0 claves expuestas en el cliente.
- 4 escenarios cubiertos por el test local del Worker, sin red.
- Deploy canónico
78c913b, verificado por el workflow y por curl antes de purgar caché.
El patrón no reemplaza un backend: reemplaza un backend que no hacía falta. Para tres campos, dos correos y un rate limit de 60 segundos, el edge alcanza y sobra. Cuando el formulario tenga que escribir en PostgreSQL o cobrar, vuelvo al servidor —y ahí sí, idempotencia de verdad. El código de este y otros proyectos está en GitHub; lo demás que escribo, en danieltini.dev.