CLOUDFLARE WORKERS ENGINEERING STANDARD
Stack de referencia: Cloudflare Workers + D1 (SQLite) + KV + R2 + Wrangler Depende de: BACKEND_ENGINEERING_STANDARD.md (Nivel 1), SECURITY_ENGINEERING_STANDARD.md Aplica a: Todo backend desplegado en Cloudflare Workers
01. Estructura del Worker
1.1 Organización por handler, no por archivo
[REQUIRED] Cada endpoint (o grupo de endpoints relacionados) vive en su propio archivo en src/handlers/. El src/index.ts solo enruta y exporta el fetch handler.
worker/
├── src/
│ ├── index.ts # Router central
│ ├── middleware/
│ │ ├── auth.ts # requireAuth(), requireAdmin()
│ │ ├── cors.ts # corsHeaders(), withCORS()
│ │ ├── rate-limit.ts # checkRateLimit()
│ │ └── validation.ts # validateBody(schema)
│ ├── handlers/
│ │ ├── auth.ts # POST /auth/login, /register, /refresh
│ │ ├── users.ts # GET/POST/PATCH /users
│ │ └── orders.ts # GET/POST /orders
│ ├── db/
│ │ ├── schema.ts # Tipos generados de D1
│ │ └── queries.ts # Queries tipadas
│ ├── lib/
│ │ ├── errors.ts # AppError, errorResponse()
│ │ ├── response.ts # ok(), fail(), jsonRes()
│ │ └── env.ts # Variables de entorno tipadas
│ └── types.ts # Tipos compartidos
├── migrations/ # Migraciones SQL
├── wrangler.toml # Configuración
├── .dev.vars # Secrets locales (NUNCA en .env)
└── tsconfig.json1.2 El Worker exporta UN handler
[REQUIRED] El index.ts exporta un objeto { fetch(request, env, ctx) } que enruta internamente:
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname.replace(/^\/api/, '') || '/';
const method = request.method;
// CORS preflight
if (method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(request, env) });
}
// Enrutamiento
try {
if (method === 'POST' && path === '/auth/login') {
return withCORS(await handleLogin(request, env), request, env);
}
// ... más rutas
return withCORS(jsonRes({ error: 'Not found' }, 404), request, env);
} catch (err) {
return withCORS(jsonRes({ error: 'Internal error' }, 500), request, env);
}
}
};02. Variables de Entorno y Secrets
2.1 Separación estricta de secrets
[REQUIRED] Los secrets NUNCA viven en .env del frontend ni en el código fuente. Se inyectan mediante wrangler secret put en producción y .dev.vars en desarrollo.
# wrangler.toml — variables públicas (solo URLs y anon keys)
[vars]
API_URL = "https://api.tuapp.com"
SUPABASE_URL = "https://xxx.supabase.co"
# secrets (NUNCA en wrangler.toml — se configuran con wrangler secret put)
# JWT_SECRET = (usar wrangler secret put JWT_SECRET)
# STRIPE_SECRET_KEY = (usar wrangler secret put STRIPE_SECRET_KEY)2.2 .dev.vars para desarrollo
[REQUIRED] Los secrets locales viven en .dev.vars (NUNCA en .env):
# .dev.vars (gitignored por defecto)
JWT_SECRET=tu-secret-local
STRIPE_SECRET_KEY=sk_test_...2.3 Tipado de env
[REQUIRED] Definir la interfaz Env en wrangler.toml o en un archivo de tipos:
// src/env.ts
export interface Env {
// Variables públicas
API_URL: string;
SUPABASE_URL: string;
// Secrets
JWT_SECRET: string;
STRIPE_SECRET_KEY: string;
// Bindings
DB: D1Database;
CACHE: KVNamespace;
BUCKET: R2Bucket;
}03. D1 (SQLite en Edge)
3.1 Queries parametrizadas SIEMPRE
[REQUIRED] Nunca concatenar strings en queries. Siempre usar .bind():
// ❌ SQL INJECTION
const user = await env.DB.prepare(`SELECT * FROM users WHERE email = '${email}'`).first();
// ✅ PARAMETRIZADO
const user = await env.DB.prepare('SELECT id, email, name FROM users WHERE email = ?')
.bind(email.trim().toLowerCase())
.first();3.2 Columnas explícitas (DB-001)
[REQUIRED] Nunca SELECT *. Especificar columnas:
// ❌ DB-001 VIOLATION
const { results } = await env.DB.prepare('SELECT * FROM orders').all();
// ✅ COLUMNAS EXPLÍCITAS
const { results } = await env.DB.prepare(
'SELECT id, status, total_cents, created_at FROM orders WHERE user_id = ?'
).bind(userId).all();3.3 Transacciones con batch
[REQUIRED] Operaciones atómicas usan env.DB.batch():
await env.DB.batch([
env.DB.prepare('UPDATE orders SET status = ? WHERE id = ?').bind('paid', orderId),
env.DB.prepare('INSERT INTO order_events (order_id, event) VALUES (?, ?)').bind(orderId, 'payment_received'),
]);3.4 Migraciones versionadas
[REQUIRED] Toda migración vive en migrations/ con nombre incremental:
migrations/
├── 0001_create_users.sql
├── 0002_create_orders.sql
└── 0003_add_payment_fields.sql[REQUIRED] Ejecutar migraciones con:
wrangler d1 migrations apply mi-db --remote
wrangler d1 migrations apply mi-db --local04. Autenticación en Workers
4.1 JWT con jose
[REQUIRED] Usar jose para JWT (no jsonwebtoken, que no funciona en Workers):
import { SignJWT, jwtVerify, importJWK } from 'jose';
async function createTokens(user: User, env: Env): Promise<TokenPair> {
const secret = new TextEncoder().encode(env.JWT_SECRET);
const accessToken = await new SignJWT({ uid: user.id, email: user.email, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // REQUIRED: 15 minutos máximo
.sign(secret);
const refreshToken = await new SignJWT({ uid: user.id, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
return { accessToken, refreshToken };
}4.2 Middleware de auth
[REQUIRED] Toda ruta protegida pasa por requireAuth():
async function requireAuth(request: Request, env: Env): Promise<User | null> {
const token = getBearerToken(request);
if (!token) return null;
try {
const { payload } = await jwtVerify(token, new TextEncoder().encode(env.JWT_SECRET));
if (!payload.uid) return null;
const user = await env.DB.prepare('SELECT id, email, role FROM users WHERE id = ?')
.bind(payload.uid).first();
return user as User | null;
} catch {
return null;
}
}
async function requireAdmin(request: Request, env: Env): Promise<Response | User> {
const user = await requireAuth(request, env);
if (!user) return jsonRes({ error: 'Unauthorized' }, 401);
if (user.role !== 'admin') return jsonRes({ error: 'Forbidden' }, 403);
return user;
}05. CORS en Workers
5.1 CORS con whitelist
[REQUIRED] Nunca Access-Control-Allow-Origin: *. Siempre orígenes explícitos:
function corsHeaders(request: Request, env: Env): Record<string, string> {
const origin = request.headers.get('Origin');
const allowed = (env.ALLOWED_ORIGINS || '').split(',').map(o => o.trim());
const matched = origin && allowed.includes(origin) ? origin : allowed[0] || '';
return {
'Access-Control-Allow-Origin': matched,
'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
'Vary': 'Origin',
};
}5.2 Preflight handler
[REQUIRED] Responder OPTIONS antes de cualquier lógica:
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(request, env) });
}06. Respuestas Estándar
6.1 Envelope de respuesta
[REQUIRED] Toda respuesta usa el envelope ok() / fail():
function jsonRes(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function ok(data: unknown): Response {
return jsonRes({ ok: true, data }, 200);
}
function fail(error: string, status = 400): Response {
return jsonRes({ ok: false, error }, status);
}6.2 Headers de seguridad
[REQUIRED] Agregar headers de seguridad en CADA respuesta:
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Strict-Transport-Security': 'max-age=63072000; includeSubDomains',
'Referrer-Policy': 'strict-origin-when-cross-origin',
};07. Rate Limiting
7.1 Rate limit con Durable Objects o KV
[REQUIRED] Toda API pública tiene rate limiting:
async function checkRateLimit(
request: Request,
env: Env,
config: { max: number; windowMs: number }
): Promise<{ allowed: boolean; remaining: number }> {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const key = `ratelimit:${ip}:${new URL(request.url).pathname}`;
const now = Date.now();
const windowStart = now - config.windowMs;
// Usar KV para almacenar timestamps
const raw = await env.CACHE.get(key);
const timestamps: number[] = raw ? JSON.parse(raw) : [];
const recent = timestamps.filter(t => t > windowStart);
if (recent.length >= config.max) {
return { allowed: false, remaining: 0 };
}
recent.push(now);
await env.CACHE.put(key, JSON.stringify(recent), { expirationTtl: Math.ceil(config.windowMs / 1000) });
return { allowed: true, remaining: config.max - recent.length };
}08. Wrangler Configuration
8.1 wrangler.toml mínimo
[REQUIRED] Toda configuración de Workers sigue esta estructura:
name = "mi-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[vars]
API_URL = "https://api.tuapp.com"
[[d1_databases]]
binding = "DB"
database_name = "mi-db"
database_id = "xxx-xxx-xxx"
[[kv_namespaces]]
binding = "CACHE"
id = "xxx-xxx-xxx"
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "mi-bucket"8.2 Scripts de package.json
[REQUIRED] Scripts mínimos para Workers:
{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"db:migrate:local": "wrangler d1 migrations apply mi-db --local",
"db:migrate:remote": "wrangler d1 migrations apply mi-db --remote",
"db:studio": "wrangler d1 execute mi-db --remote --command 'SELECT id, email, created_at FROM users LIMIT 10'"
}
}Checklist Pre-Deploy Workers
- [ ]
wrangler.tomlcon binding de DB/KV/R2 - [ ] Secrets configurados con
wrangler secret put - [ ]
.dev.varsen.gitignore - [ ] CORS con orígenes explícitos
- [ ] Rate limiting implementado
- [ ] Queries parametrizadas (nunca concatenar)
- [ ] SELECT * eliminado
- [ ] JWT con TTL 15min (access) / 7d (refresh)
- [ ] Headers de seguridad en cada respuesta
- [ ]
wrangler deployfunciona sin errores