← Proyectos/aws-event-driven-image-processing
Live💻 Full Stack

AWS Event-Driven Image Processing Platform

Plataforma fullstack de procesamiento de imágenes con pipeline event-driven. Sube una imagen y Lambda genera 3 variantes automáticamente vía S3 → SQS → Lambda → DynamoDB.

AstroReact 19Node.jsExpressLambdaSQSS3DynamoDBCloudFrontTerraform

Problema & Solución

Problema

Una plataforma necesita procesar imágenes subidas por usuarios (resize a múltiples resoluciones) sin bloquear el request del cliente. El usuario debe poder subir la imagen y ver los resultados en tiempo real a medida que Lambda los genera, sin recargar la página.

Solución

Frontend en Astro 6 + React 19 con polling automático post-upload. Backend Express 5 que sube imágenes al bucket S3 input, disparando el pipeline event-driven: S3 → SQS → Lambda (Python + Pillow) → 3 variantes en S3 output + metadata en DynamoDB. Las imágenes se sirven con Signed URLs de CloudFront. El frontend hace polling cada 4s hasta detectar las imágenes procesadas.

Diagrama de Arquitectura


  Browser (Astro + React 19)
     │
     ├─ POST /api/v1/files/upload   → S3 input (dispara pipeline)
     ├─ GET  /api/v1/files          → lista resized/ (polling cada 4s)
     ├─ GET  /api/v1/files/signed-url → URL firmada CloudFront
     └─ DELETE /api/v1/files        → elimina 3 variantes en paralelo
          │
          ▼
  ┌──────────────────────────────────┐
  │  Express 5 Backend               │
  │  • Sube a image-resize/input/    │
  │  • Lista image-resize/resized/   │
  │  • Genera Signed URLs CloudFront │
  └──────────────┬───────────────────┘
                 │ S3 Event (ObjectCreated)
                 ▼
  ┌──────────────────────────────────────┐
  │  SQS Queue → Lambda (Python + Pillow)│
  │  → 800×600, 400×300, 150×150         │
  │  → DynamoDB (metadata + status)      │
  │  → SNS (notificación)                │
  └──────────────────────────────────────┘
                 │
                 ▼ Signed URLs
  CloudFront Distribution (OAC → S3)

Implementación

1

Frontend — Upload con React 19 + polling automático

ImageUploader valida MIME type en cliente antes de enviar. Tras un upload exitoso, ImageProcessor activa isProcessing=true e incrementa refreshTrigger. ImageGallery detecta el cambio y comienza polling cada 4 segundos consultando GET /api/v1/files. Cuando el número de grupos de imágenes aumenta (las variantes están listas), el polling se detiene automáticamente.

2

Backend — Upload a S3 y disparo del pipeline

El backend sube la imagen al prefijo image-resize/input/ del bucket S3. Este upload dispara automáticamente la S3 Event Notification → SQS → Lambda. El backend responde al cliente con la URL firmada de la imagen original antes de que Lambda la procese.

3

Pipeline Lambda — 3 variantes + DynamoDB

Lambda (Python + Pillow) descarga la imagen de S3 input, genera 3 versiones (800×600, 400×300, 150×150) y las sube a S3 resized/. Implementa idempotencia via DynamoDB: si la imagen ya fue procesada, no la reprocesa. SQS con MaxReceiveCount=3 y DLQ para mensajes fallidos.

4

Frontend — Galería agrupada con preview y eliminación

ImageGallery agrupa las imágenes por filename extrayéndolo del S3 key (image-resize/resized/800x600/abc.jpg). Muestra las 3 variantes en una grid. Al eliminar un grupo, ejecuta DELETE en paralelo (Promise.all) para las 3 variantes. ImagePreviewModal muestra las dimensiones reales del archivo usando img.naturalWidth/naturalHeight.

Tech Stack

Frontend

Astro 6.xReact 19.2Tailwind CSS 4.xTypeScript 6.xpnpm 11

Backend

Node.js ≥ 20Express 5.2.xAWS SDK v3 (S3 + CloudFront)@aws-sdk/cloudfront-signerhelmetexpress-rate-limitnanoid

Pipeline AWS (Lambda)

Python 3.12Pillow (imagen resize)boto3SQS + DLQDynamoDB (metadata)SNS (notificaciones)

Infraestructura

S3 (input + output)CloudFront + Signed URLsKMS (SSE-KMS)Lambda Layers (Pillow)Terraform

Decisiones Técnicas

Polling vs WebSockets para actualizar la galería

Elegido

Polling con intervalo adaptativo (4s)

Alternativas

  • WebSockets — tiempo real verdadero, requiere servidor adicional y más complejidad
  • Server-Sent Events — unidireccional, pero requiere conexión HTTP persistente al backend
  • AppSync Subscriptions — managed WebSockets, más costo y configuración

Razón

WebSockets requieren un servidor stateful adicional (ECS task o API Gateway WebSockets). Polling cada 4s es suficiente para una demo — Lambda procesa imágenes en 2-5 segundos. El polling se detiene automáticamente al detectar las imágenes nuevas, minimizando requests innecesarios.

Astro Islands con React vs SPA pura

Elegido

Astro Islands (React como isla interactiva)

Alternativas

  • Next.js SPA — hidrata toda la página, más JS en el bundle
  • Remix — server-side forms, más complejo para esta demo

Razón

La página tiene contenido estático (explicación de la arquitectura) que no necesita JavaScript. Solo la sección de demo (upload + galería) es interactiva. Astro Islands permite hidratación selectiva: solo los componentes React que necesitan interactividad se envían al cliente.

Snippets de Código

Frontend — Hook de polling en ImageGallery (React 19)typescript
// Polling automático cuando isProcessing = true
// Se detiene cuando detecta nuevas imágenes o tras 30s de timeout
useEffect(() => {
  if (!isProcessing) return;

  let attempts = 0;
  const MAX_ATTEMPTS = 30000 / 4000; // 30s / 4s = 7 intentos

  const interval = setInterval(async () => {
    attempts++;
    const result = await listImages();
    if (!result.success) return;

    const newGroups = groupByFilename(result.data.files);
    if (newGroups.length > prevGroupCount) {
      setGroups(newGroups);
      onProcessingComplete();
      clearInterval(interval);
    }

    if (attempts >= MAX_ATTEMPTS) {
      onProcessingComplete();
      clearInterval(interval);
    }
  }, 4000);

  return () => clearInterval(interval);
}, [isProcessing, prevGroupCount]);
Frontend — Eliminación en paralelo de 3 variantestypescript
async function handleDeleteGroup(group: ImageGroup) {
  const keys = [
    group.variants['800x600']?.key,
    group.variants['400x300']?.key,
    group.variants['150x150']?.key,
  ].filter(Boolean) as string[];

  // Elimina las 3 variantes en paralelo
  const results = await Promise.all(keys.map(key => deleteImage(key)));

  const allOk = results.every(r => r.success);
  if (allOk) {
    setGroups(prev => prev.filter(g => g.filename !== group.filename));
  }
}