2026/05/108 min. de lectura

Diseñando Trimtok: una arquitectura serverless orientada a eventos en AWS

AWSServerlessArchitectureTypeScript
Hero image for Diseñando Trimtok: una arquitectura serverless orientada a eventos en AWS

Trimtok nació de una necesidad concreta: quería una herramienta sencilla para descargar videos de TikTok, recortarlos por rango de tiempo y exportarlos como mp4 o MP3, sin depender de servicios de terceros. Lo que empezó como un script local terminó convirtiéndose en una arquitectura serverless completa desplegada en AWS.

En este post hago un recorrido detallado por la arquitectura de Trimtok, explicando las decisiones de diseño, los servicios de AWS utilizados, trade-offs y consideraciones, y los costos estimados.

Diagrama de arquitectura de Trimtok
Diagrama de arquitectura de Trimtok

Sobre la Arquitectura y los Servicios

La arquitectura de Trimtok es completamente serverless y orientada a eventos. No hay servidores que gestionar, no hay contenedores que escalar manualmente. Cada componente se activa exactamente cuando se necesita y se apaga cuando termina su trabajo.

A continuación describo los principales servicios y su función en la arquitectura, con los fragmentos del IaC en TypeScript gestionado con SST 4 (Ion/Pulumi).

1. DynamoDB — Single-Table Design

DynamoDB es la base de datos principal de Trimtok. En lugar de múltiples tablas, utilizo un único esquema (TrimtokTable) que aloja todas las entidades del dominio: Jobs, artefactos en caché, conexiones WebSocket activas y locks distribuidos para deduplicación.

El diseño single-table permite acceder a cualquier entidad en O(1) con la clave primaria pk + sk, y el GSI1 resuelve el único patrón de acceso que no cabe en la clave principal: encontrar todas las conexiones WebSocket suscritas a un job específico para enviar la notificación de completado.

El campo expiresAt activa el TTL nativo de DynamoDB para limpiar registros automáticamente (7 días por defecto), sin necesidad de lambdas de limpieza.

const table = new sst.aws.Dynamo("TrimtokTable", {
fields: {
pk: "string",
sk: "string",
gsi1pk: "string",
gsi1sk: "string",
},
primaryIndex: { hashKey: "pk", rangeKey: "sk" },
globalIndexes: {
gsi1: { hashKey: "gsi1pk", rangeKey: "gsi1sk" },
},
ttl: "expiresAt",
});

Las entidades modeladas en la tabla son:

EntidadPKSKPropósito
JobJOB#{jobId}METADATAEstado y metadatos del job
CacheArtifactARTIFACT#{videoId}{FORMAT}#{type}#{trimStart}#{trimEnd}Índice S3 para cache hits
DistributedLockLOCK#{videoId}LOCKMutex para descargas concurrentes
WebSocketConnectionCONN#{connId}METADATAConexiones WS activas
ProcessingEventJOB#{jobId}EVENT#{timestamp}#{ulid}Auditoría inmutable

2. Amazon S3 — Artefactos con Lifecycle Rules

S3 almacena todos los artefactos generados: videos originales descargados, recortes MP4, GIFs y MP3. El acceso a los artefactos siempre se hace a través de presigned URLs con validez de 1 hora — Lambda nunca actúa como proxy de bytes, lo que evita costos de transferencia innecesarios y mantiene el payload de las respuestas pequeño.

Cada tipo de artefacto tiene una política de ciclo de vida diferente según su criticidad:

const bucket = new sst.aws.Bucket("ArtifactsBucket", {
versioning: false,
transform: {
bucket: {
lifecycleRules: [
{
id: "expire-originals",
enabled: true,
prefix: "originals/",
expiration: { days: 2 },
abortIncompleteMultipartUploadDays: 1,
},
{
id: "expire-trims",
enabled: true,
prefix: "trims/",
expiration: { days: 1 },
abortIncompleteMultipartUploadDays: 1,
},
{
id: "expire-gifs",
enabled: true,
prefix: "gifs/",
expiration: { days: 1 },
abortIncompleteMultipartUploadDays: 1,
},
{
id: "expire-mp3s-originals",
enabled: true,
prefix: "mp3s/originals/",
expiration: { days: 2 },
abortIncompleteMultipartUploadDays: 1,
},
{
id: "expire-mp3s-trims",
enabled: true,
prefix: "mp3s/trims/",
expiration: { days: 1 },
abortIncompleteMultipartUploadDays: 1,
},
],
},
},
});

3. Amazon SQS — Cuatro Colas + Cuatro DLQs

Cada tipo de operación tiene su propia cola SQS con su Dead Letter Queue dedicada. Esta separación es deliberada: una descarga puede tardar varios minutos (yt-dlp + upload a S3 de 50–150 MB), mientras que un recorte con -c copy suele resolverse en segundos.

Todas las colas comparten la misma configuración de timeout y permiten hasta 2 reintentos antes de enviar el mensaje a la DLQ.

const downloadDlq = new sst.aws.Queue("DownloadDLQ");
const downloadQueue = new sst.aws.Queue("DownloadQueue", {
dlq: { queue: downloadDlq.arn, retry: 2 },
visibilityTimeout: "240 seconds",
});

const trimDlq = new sst.aws.Queue("TrimDLQ");
const trimQueue = new sst.aws.Queue("TrimQueue", {
dlq: { queue: trimDlq.arn, retry: 2 },
visibilityTimeout: "240 seconds",
});

const gifDlq = new sst.aws.Queue("GifDLQ");
const gifQueue = new sst.aws.Queue("GifQueue", {
dlq: { queue: gifDlq.arn, retry: 2 },
visibilityTimeout: "240 seconds",
});

const mp3Dlq = new sst.aws.Queue("Mp3DLQ");
const mp3Queue = new sst.aws.Queue("Mp3Queue", {
dlq: { queue: mp3Dlq.arn, retry: 2 },
visibilityTimeout: "240 seconds",
});

CloudWatch Alarms monitorean las DLQs: cualquier mensaje que llegue a ellas genera una alarma que me permite detectar fallos en los workers de forma proactiva.

4. Lambda Layers — yt-dlp y ffmpeg en ARM64

yt-dlp y ffmpeg son binarios externos que no pueden instalarse como dependencias npm. La solución son Lambda Layers: archivos ZIP con los binarios precompilados para ARM64 (Amazon Linux 2023) que se montan en /opt/bin/ dentro de cada función Lambda.

El caso de ffmpeg merece mención especial: el binario pesa aproximadamente 150 MB, superando el límite de 70 MB para uploads directos de Lambda. La solución es subirlo primero a S3 y referenciar ese objeto en la definición del Layer. En este caso se subieron ambos binarios a S3, aunque yt-dlp podría haberse incluido directamente en el paquete de la función al pesar menos de 70 MB.

const ytdlpLayerZip = new aws.s3.BucketObjectv2("YtdlpLayerZip", {
bucket: bucket.name,
key: "layers/ytdlp.zip",
source: new $util.asset.FileArchive(
path.join($cli.paths.root, "layers/ytdlp"),
),
});
const ytdlpLayer = new aws.lambda.LayerVersion("YtdlpLayer", {
layerName: "trimtok-ytdlp-arm64",
s3Bucket: ytdlpLayerZip.bucket,
s3Key: ytdlpLayerZip.key,
compatibleArchitectures: ["arm64"],
compatibleRuntimes: ["provided.al2023"],
});

// ffmpeg — upload vía S3 (> 70 MB, límite de upload directo)
const ffmpegLayerZip = new aws.s3.BucketObjectv2("FfmpegLayerZip", {
bucket: bucket.name,
key: "layers/ffmpeg.zip",
source: new $util.asset.FileArchive(
path.join($cli.paths.root, "layers/ffmpeg"),
),
});
const ffmpegLayer = new aws.lambda.LayerVersion("FfmpegLayer", {
layerName: "trimtok-ffmpeg-arm64",
s3Bucket: ffmpegLayerZip.bucket,
s3Key: ffmpegLayerZip.key,
compatibleArchitectures: ["arm64"],
compatibleRuntimes: ["provided.al2023"],
});

5. API Gateway WebSocket — Notificaciones en Tiempo Real

El procesamiento de videos es asíncrono: el usuario no puede estar en un loading spinner bloqueante durante varios minutos. La solución natural serían los Server-Sent Events (SSE), pero API Gateway tiene un límite de 29 segundos por request que los hace inviables.

API Gateway WebSocket resuelve esto limpiamente: el frontend abre una conexión persistente, envía un mensaje { action: "subscribe", jobId }, y cuando el worker termina su trabajo, hace un POST a la Management API de API GW para empujar la notificación directamente a la conexión del cliente.

const wsApi = new sst.aws.ApiGatewayWebSocket("WsApi", {
accessLog: { retention: "1 week" },
});

wsApi.route("$connect", {
handler: "src/handlers/websocket/connect.handler",
link: [...baseLinks],
logging: { retention: "1 week" },
environment: { LOG_LEVEL: logLevel },
});
wsApi.route("$disconnect", {
handler: "src/handlers/websocket/disconnect.handler",
link: [...baseLinks],
logging: { retention: "1 week" },
environment: { LOG_LEVEL: logLevel },
});
wsApi.route("subscribe", {
handler: "src/handlers/websocket/subscribe.handler",
link: [...baseLinks, wsApi],
logging: { retention: "1 week" },
environment: { LOG_LEVEL: logLevel },
});

La gestión de las conexiones activas vive en DynamoDB: al conectar se escribe CONN#{connId}, al suscribirse se actualiza con el jobId y las claves del GSI1 (gsi1pk = JOB#{jobId}, gsi1sk = CONN#{connId}). Esto permite al worker hacer una query en GSI1 para encontrar todas las conexiones suscritas a un job y notificarlas todas de una vez.

6. API Gateway HTTP v2 — REST API con Authorizer de Cloudflare

La API HTTP expone cinco endpoints síncronos. La configuración de CORS es estricta en producción: solo acepta peticiones desde trimtok.pool-llerena.com. El endpoint directo de API Gateway está deshabilitado (disableExecuteApiEndpoint: true) en producción para forzar todo el tráfico a pasar por Cloudflare, que actúa como proxy.

La autenticación se implementa con un Lambda Authorizer que verifica un header secreto Custom-secret inyectado por Cloudflare en cada request. Esto protege la API de llamadas directas sin necesidad de implementar JWT o sesiones de usuario en esta primera versión.

const api = new sst.aws.ApiGatewayV2("Api", {
link: baseLinks,
accessLog: { retention: "1 week" },
cors: {
allowOrigins: isProduction
? ["https://trimtok.pool-llerena.com"]
: ["*"],
allowHeaders: ["content-type", "authorization"],
allowMethods: ["GET", "POST", "OPTIONS"],
maxAge: "1 day",
},
domain: isProduction
? {
name: "trimtok-backend.pool-llerena.com",
dns: sst.cloudflare.dns({ proxy: true }),
}
: undefined,
transform: {
api: (args) => {
args.disableExecuteApiEndpoint = isProduction;
},
},
});

const cfAuthorizer = isProduction
? api.addAuthorizer({
name: "CloudflareAuthorizer",
lambda: {
function: {
handler: "src/handlers/authorizer/authorizer.handler",
link: cfSecret ? [cfSecret] : [],
logging: { retention: "1 week" },
environment: { LOG_LEVEL: logLevel },
},
response: "simple",
identitySources: ["$request.header.Custom-secret"],
ttl: "5 seconds",
},
})
: undefined;

// Rutas
api.route("POST /v1/jobs", { handler: "src/handlers/api/create-job.handler", ...handlerOpts }, cfAuth);
api.route("GET /v1/jobs/{jobId}", { handler: "src/handlers/api/get-job.handler", ...handlerOpts }, cfAuth);
api.route("POST /v1/jobs/{jobId}/trim", { handler: "src/handlers/api/request-trim.handler", ...handlerOpts }, cfAuth);
api.route("POST /v1/jobs/{jobId}/gif", { handler: "src/handlers/api/request-gif.handler", ...handlerOpts }, cfAuth);
api.route("POST /v1/jobs/{jobId}/mp3", { handler: "src/handlers/api/request-mp3.handler", ...handlerOpts }, cfAuth);

7. Workers SQS — Procesamiento con yt-dlp y ffmpeg

Cuatro funciones Lambda (ARM64, 1 GB de RAM) procesan los mensajes de sus respectivas colas. Cada worker sigue el mismo patrón: leer el mensaje, ejecutar el binario en /tmp, subir el resultado a S3, actualizar el estado del Job en DynamoDB y notificar al cliente por WebSocket.

WorkerBinarioOperación
download-workeryt-dlp + ffmpegDescarga el video original y lo sube a originals/
trim-workerffmpeg-ss {start} -to {end} -c copy (stream copy, sin re-encode)
gif-workerffmpegDos pasadas: palettegen + paletteuse para GIF de alta calidad
mp3-workerffmpeg-vn -acodec libmp3lame -q:a 2 para audio en alta calidad
downloadQueue.subscribe(
{
handler: "src/handlers/workers/download-worker.handler",
link: [...workerLinks, downloadQueue],
timeout: "240 seconds",
memory: "1024 MB",
layers: [ytdlpLayer.arn, ffmpegLayer.arn],
architecture: "arm64",
logging: { retention: "1 week" },
dev: false,
environment: {
FFMPEG_PATH: "/opt/bin/ffmpeg",
LOG_LEVEL: logLevel,
},
},
{ batch: { partialResponses: true } },
);

trimQueue.subscribe(
{
handler: "src/handlers/workers/trim-worker.handler",
link: [...workerLinks, trimQueue],
timeout: "240 seconds",
memory: "1024 MB",
layers: [ffmpegLayer.arn],
architecture: "arm64",
logging: { retention: "1 week" },
dev: false,
environment: { LOG_LEVEL: logLevel },
},
{ batch: { partialResponses: true } },
);

La opción dev: false es importante: evita que SST intente sustituir estas funciones por un proxy de desarrollo local durante sst dev, ya que no tiene sentido ejecutar yt-dlp o ffmpeg en una máquina de desarrollo de esta forma. partialResponses: true permite que un batch con múltiples mensajes reporte los fallos individualmente en lugar de rechazar todo el batch.

El download-worker además implementa deduplicación con un mutex distribuido: ejecuta un PutItem condicional (attribute_not_exists(pk)) para crear el lock LOCK#{videoId}. Si otro worker ya está descargando el mismo video, la escritura falla y el segundo worker puede esperar a que el artefacto aparezca en el caché antes de responder.

Flujo de Datos End-to-End

El flujo completo desde que el usuario pega una URL hasta que descarga el archivo:

1. Usuario introduce URL de TikTok en Frontend
└─ Validación client-side con regex (dominio + path @user/video/ID)

2. POST /v1/jobs { tiktokUrl, format: "mp4" }
├─ Cache HIT → 200 OK, status: "ready", downloadUrl (presigned S3, 1h)
└─ Cache MISS → 201 Created, status: "pending", jobId

3. Frontend abre WebSocket
└─ Envía: { action: "subscribe", jobId }
└─ DynamoDB: CONN#{connId} actualizado con gsi1pk = JOB#{jobId}

4. create-job.handler encola en DownloadQueue
└─ SQS → download-worker.handler

5. download-worker (Lambda arm64, 1 GB, 240s)
├─ Adquiere LOCK#{videoId} (PutItem condicional)
├─ Ejecuta yt-dlp → /tmp (video 50–150 MB)
├─ Sube a S3: originals/{videoId}/{videoId}.mp4
├─ Escribe ARTIFACT#{videoId} en DynamoDB (caché)
├─ Actualiza Job → status: "ready"
└─ Query GSI1 → PostToConnection para cada WS suscrito

6. Frontend recibe WS: { type: "job_update", status: "ready" }
└─ GET /v1/jobs/:jobId → downloadUrl (presigned S3)
└─ Navega a previsualización del video

7. Operaciones adicionales (MP3, Trim, GIF)
└─ POST .../mp3 | .../trim | .../gif
└─ Cola correspondiente → worker → ffmpeg → S3 → WS → presigned URL

Trade-offs y Consideraciones

WebSocket vs SSE vs Polling

API Gateway tiene un límite de 29 segundos por request, lo que elimina directamente SSE y long-polling como opciones viables. La alternativa habría sido polling corto (cada 2–3 segundos), pero introduce latencia perceptible y carga innecesaria. API Gateway WebSocket resuelve el problema de raíz, aunque añade algo de complejidad: hay que gestionar conexiones, suscripciones y desconexiones.

Single-Table vs Múltiples Tablas

Un diseño multi-tabla hubiera sido más sencillo de razonar inicialmente, pero habría implicado múltiples tablas de DynamoDB (jobs, artifacts, connections, locks), cada una con su propio punto de configuración, monitoreo y facturación. El single-table design centraliza todo y funciona bien porque los patrones de acceso están bien definidos desde el diseño: la clave primaria resuelve el 90% de los casos, y GSI1 cubre el patrón restante (encontrar conexiones por jobId).

Cuatro Colas vs Una Sola

Una cola única hubiera mezclado operaciones de tiempos radicalmente distintos. Si el worker de descarga falla repetidamente y llena la DLQ, habría bloqueado también los recortes y los GIFs. Colas separadas permiten:

Lambda Layers para Binarios Externos

La alternativa habría sido incluir los binarios directamente en el paquete de la función. El problema es que el límite de despliegue de Lambda es 250 MB sin comprimir. yt-dlp y ffmpeg juntos superan ese límite. Los Lambda Layers además permiten actualizar los binarios de forma independiente al código de la función, lo que es útil cuando yt-dlp lanza una nueva versión para soportar cambios en la API de TikTok.

TTLs Cortos en S3

48 horas para los originals y 24 horas para los derivados son ventanas suficientes para la mayoría de los casos de uso (el usuario descarga el archivo inmediatamente o en las siguientes horas). TTLs más largos aumentarían el costo de almacenamiento sin proporcionar valor real, dado que el usuario puede regenerar cualquier derivado siempre que el original siga en S3.

SST 4 como IaC

SST (Ion/Pulumi) permite describir la infraestructura en TypeScript con el mismo lenguaje que el resto del código del proyecto. La integración entre recursos es directa (.link() comparte automáticamente variables de entorno y permisos IAM mínimos entre funciones). La alternativa hubiera sido CDK o Terraform, pero SST ofrece una experiencia de desarrollo local mucho más ergonómica con sst dev.

Costos Estimados

Los costos mensuales estimados son bajos gracias a la naturaleza serverless y al uso del AWS Free Tier.

ServicioBajo (1K req/mes)Moderado (10K req/mes)Alto (100K req/mes)
Lambda~$0.00~$0.02~$32.00
DynamoDB (on-demand)~$0.01~$0.07~$0.69
S3 (almacenamiento + req)~$0.38~$3.89~$38.85
SQS~$0.00~$0.00~$0.00
API GW HTTP v2~$0.00~$0.04~$0.40
API GW WebSocket~$0.01~$0.06~$0.61
Total mensual~$0.40~$4.08~$72.55

Desglose por Servicio

AWS Lambda

Amazon DynamoDB (on-demand)

Amazon S3

Amazon SQS

API Gateway HTTP v2

API Gateway WebSocket

Conclusión

Construir Trimtok fue un reto en el que cada decisión de arquitectura tuvo que justificarse con las restricciones reales de la plataforma: el límite de 29 segundos de API Gateway forzó el uso de WebSocket, el tamaño de ffmpeg forzó el upload vía S3, y los tiempos de ejecución heterogéneos de las operaciones justificaron las cuatro colas separadas.

El resultado es una arquitectura que escala de cero a miles de requests por mes sin cambiar una sola línea de IaC, con un costo marginal en tráfico bajo y sin servidores que mantener. SST 4 hizo que toda la infraestructura fuese describible en el mismo TypeScript que el resto del proyecto, lo que reduce significativamente la fricción entre el código de la aplicación y la infraestructura que lo soporta.

Los próximos pasos naturales son implementar autenticación real para persistir el historial de descargas del usuario, explorar el uso de S3 Intelligent-Tiering para reducir costos de almacenamiento en escenarios de alto tráfico y agregar cloudfront delante de S3 para mejorar la latencia, disponibilidad, seguridad y reducir costos de transferencia a internet. Pero incluso en su estado actual, Trimtok es una herramienta robusta, escalable y eficiente que cumple perfectamente con su propósito.