Arhitecturi full-stack la margine: studiu de caz de la zero la producție
Un studiu de caz cuprinzător care construiește un API REST complet folosind Cloudflare Workers ca calcul, D1 pentru baza de date relațională și R2 pentru active și încărcări, cu CI/CD prin GitHub Actions.
Ce vom construi: CatalogAPI
În acest articol construim CatalogAPI: O API REST pentru un catalog produse cu autentificare JWT, încărcare de imagini, căutare full-text și limitare a ratei. Utilizări ale arhitecturii Singur Cloudflare: lucrători pentru calcul, D1 pentru baza de date relațional, R2 pentru stocarea activelor, KV pentru sesiuni și limitarea ratei. Zero servere pentru a gestiona, zero VPC, implementare globală automată la peste 300 de PoP-uri.
Stiva tehnologică de studiu de caz
- Calcula: Lucrători Cloudflare (TypeScript, cadru Hono.js)
- Baze de date: D1 (SQLite) cu migrari automate
- Depozitarea obiectelor: R2 pentru imaginile produselor
- Cache/Sesiuni: Muncitori KV
- Auth: JWT cu cheie privată în Workers Secret
- Limitarea ratei: Fereastra culisanta cu KV atomic
- CI/CD: Acțiuni GitHub cu testare și implementare automată
- Monitorizare: Lucrătorii Cloudflare Logpush la R2
Structura proiectului
catalog-api/
src/
index.ts # Entry point, setup Hono
middleware/
auth.ts # JWT verification middleware
rateLimit.ts # Sliding window rate limiter
cors.ts # CORS headers
routes/
products.ts # CRUD prodotti
categories.ts # CRUD categorie
uploads.ts # Upload immagini su R2
auth.ts # Login / refresh token
services/
jwt.ts # Firma e verifica JWT
search.ts # Full-text search su D1
types/
env.ts # Interfaccia Env (binding)
models.ts # Interfacce dati
migrations/
0001_initial.sql # Schema D1 iniziale
0002_full_text.sql # FTS5 extension
test/
routes/
products.test.ts
auth.test.ts
integration/
upload.test.ts
wrangler.toml
vitest.config.ts
Schema D1: Proiectarea bazei de date
-- migrations/0001_initial.sql
CREATE TABLE categories (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE products (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
price REAL NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
category_id TEXT REFERENCES categories(id) ON DELETE SET NULL,
image_key TEXT, -- chiave R2 per l'immagine principale
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'deleted')),
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_status ON products(status);
CREATE INDEX idx_products_price ON products(price);
CREATE TABLE users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('admin', 'editor', 'viewer')),
created_at INTEGER DEFAULT (unixepoch())
);
-- migrations/0002_full_text.sql
-- FTS5 per ricerca full-text efficiente
CREATE VIRTUAL TABLE products_fts USING fts5(
id UNINDEXED,
name,
description,
content='products',
content_rowid='rowid'
);
-- Trigger per mantenere sincronizzata la FTS table
CREATE TRIGGER products_ai AFTER INSERT ON products BEGIN
INSERT INTO products_fts(rowid, id, name, description)
VALUES (new.rowid, new.id, new.name, new.description);
END;
CREATE TRIGGER products_au AFTER UPDATE ON products BEGIN
UPDATE products_fts SET name = new.name, description = new.description
WHERE id = new.id;
END;
CREATE TRIGGER products_ad AFTER DELETE ON products BEGIN
DELETE FROM products_fts WHERE id = old.id;
END;
Punct de intrare: Hono.js pentru lucrători
Hono.js este cel mai folosit cadru web pe Workers: este scris pentru edge (fără dependențe Node.js), are un router ultra-rapid, middleware compus și sigur de tipare cu TypeScript.
// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { secureHeaders } from 'hono/secure-headers';
import { timing } from 'hono/timing';
import { authMiddleware } from './middleware/auth';
import { rateLimitMiddleware } from './middleware/rateLimit';
import { productsRouter } from './routes/products';
import { categoriesRouter } from './routes/categories';
import { uploadsRouter } from './routes/uploads';
import { authRouter } from './routes/auth';
import type { Env } from './types/env';
const app = new Hono<{ Bindings: Env }>();
// Middleware globali
app.use('*', cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400,
}));
app.use('*', secureHeaders());
app.use('*', timing());
// Rate limiting su tutte le route API
app.use('/api/*', rateLimitMiddleware);
// Route pubbliche (no auth)
app.route('/api/auth', authRouter);
app.get('/api/products', async (c) => {
// GET list e pubblico, senza autenticazione
const { default: handler } = await import('./routes/products');
return handler.fetch(c.req.raw, c.env, c.executionCtx);
});
// Route protette (richiedono JWT)
app.use('/api/products/*', authMiddleware);
app.use('/api/categories/*', authMiddleware);
app.use('/api/uploads/*', authMiddleware);
app.route('/api/products', productsRouter);
app.route('/api/categories', categoriesRouter);
app.route('/api/uploads', uploadsRouter);
// Health check
app.get('/health', (c) => c.json({ status: 'ok', timestamp: Date.now() }));
// 404 handler
app.notFound((c) => c.json({ error: 'Not Found' }, 404));
// Error handler
app.onError((err, c) => {
console.error('Unhandled error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
export default app;
Auth: JWT cu secretele lucrătorilor
// src/services/jwt.ts
// Web Crypto API (disponibile in Workers, nessuna dipendenza esterna)
const JWT_ALGORITHM = 'HS256';
export async function signJWT(
payload: Record<string, unknown>,
secret: string,
expiresInSeconds = 3600
): Promise<string> {
const header = { alg: JWT_ALGORITHM, typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const fullPayload = {
...payload,
iat: now,
exp: now + expiresInSeconds,
};
const encodedHeader = btoa(JSON.stringify(header)).replace(/=/g, '');
const encodedPayload = btoa(JSON.stringify(fullPayload)).replace(/=/g, '');
const data = `${encodedHeader}.${encodedPayload}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
const encodedSignature = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=/g, '');
return `${data}.${encodedSignature}`;
}
export async function verifyJWT(
token: string,
secret: string
): Promise<Record<string, unknown> | null> {
try {
const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');
if (!encodedHeader || !encodedPayload || !encodedSignature) return null;
// Verifica firma
const data = `${encodedHeader}.${encodedPayload}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signatureBytes = Uint8Array.from(atob(encodedSignature.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const valid = await crypto.subtle.verify('HMAC', key, signatureBytes, new TextEncoder().encode(data));
if (!valid) return null;
// Verifica scadenza
const payload = JSON.parse(atob(encodedPayload)) as { exp: number };
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload as Record<string, unknown>;
} catch {
return null;
}
}
// src/middleware/auth.ts
import type { MiddlewareHandler } from 'hono';
import { verifyJWT } from '../services/jwt';
import type { Env } from '../types/env';
export const authMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401);
}
const token = authHeader.slice(7);
const payload = await verifyJWT(token, c.env.JWT_SECRET);
if (!payload) {
return c.json({ error: 'Invalid or expired token' }, 401);
}
// Passa il payload al contesto per i route handler successivi
c.set('user', payload);
await next();
};
Limitarea ratei: fereastră glisantă cu KV
// src/middleware/rateLimit.ts
// Sliding window rate limiter: massimo N richieste per finestra temporale
import type { MiddlewareHandler } from 'hono';
import type { Env } from '../types/env';
const WINDOW_SIZE_SECONDS = 60; // Finestra di 1 minuto
const MAX_REQUESTS = 100; // 100 richieste per finestra (per IP)
export const rateLimitMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
// Usa l'IP del client come chiave (Cloudflare lo espone in cf.connectingIp)
const clientIp = c.req.header('CF-Connecting-IP') ??
c.req.header('X-Forwarded-For') ??
'unknown';
const now = Math.floor(Date.now() / 1000);
const windowStart = now - WINDOW_SIZE_SECONDS;
const key = `ratelimit:${clientIp}:${Math.floor(now / WINDOW_SIZE_SECONDS)}`;
// Recupera il contatore corrente per questa finestra
const currentCount = parseInt(await c.env.RATE_LIMIT_KV.get(key) ?? '0');
if (currentCount >= MAX_REQUESTS) {
const resetAt = (Math.floor(now / WINDOW_SIZE_SECONDS) + 1) * WINDOW_SIZE_SECONDS;
return c.json(
{ error: 'Rate limit exceeded', resetAt },
429,
{
'X-RateLimit-Limit': String(MAX_REQUESTS),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': String(resetAt),
'Retry-After': String(resetAt - now),
}
);
}
// Incrementa il contatore
const newCount = currentCount + 1;
c.executionCtx.waitUntil(
c.env.RATE_LIMIT_KV.put(key, String(newCount), {
expirationTtl: WINDOW_SIZE_SECONDS * 2,
})
);
// Aggiungi header informativi
c.header('X-RateLimit-Limit', String(MAX_REQUESTS));
c.header('X-RateLimit-Remaining', String(MAX_REQUESTS - newCount));
await next();
};
Traseu produs: CRUD cu D1
// src/routes/products.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import type { Env } from '../types/env';
const productsRouter = new Hono<{ Bindings: Env }>();
// Schema di validazione con Zod
const createProductSchema = z.object({
name: z.string().min(1).max(200),
slug: z.string().min(1).regex(/^[a-z0-9-]+$/),
description: z.string().max(5000).optional(),
price: z.number().min(0),
stock: z.number().int().min(0).default(0),
category_id: z.string().uuid().optional(),
});
// GET /api/products - Lista con paginazione, filtri e ricerca
productsRouter.get('/', async (c) => {
const { page = '1', limit = '20', category, search, min_price, max_price } = c.req.query();
const pageNum = Math.max(1, parseInt(page));
const limitNum = Math.min(100, Math.max(1, parseInt(limit)));
const offset = (pageNum - 1) * limitNum;
let query: string;
const params: (string | number)[] = [];
if (search) {
// Full-text search via FTS5
query = `
SELECT p.*, c.name as category_name
FROM products_fts f
JOIN products p ON f.id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE products_fts MATCH ? AND p.status = 'active'
`;
params.push(search);
} else {
query = `
SELECT p.*, c.name as category_name
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.status = 'active'
`;
}
if (category) {
query += ` AND c.slug = ?`;
params.push(category);
}
if (min_price) {
query += ` AND p.price >= ?`;
params.push(parseFloat(min_price));
}
if (max_price) {
query += ` AND p.price <= ?`;
params.push(parseFloat(max_price));
}
// Count totale per paginazione
const countQuery = `SELECT COUNT(*) as total FROM (${query})`;
const countResult = await c.env.DB.prepare(countQuery).bind(...params).first<{ total: number }>();
const total = countResult?.total ?? 0;
// Query paginata
query += ` ORDER BY p.created_at DESC LIMIT ? OFFSET ?`;
params.push(limitNum, offset);
const products = await c.env.DB.prepare(query).bind(...params).all();
return c.json({
data: products.results,
pagination: {
page: pageNum,
limit: limitNum,
total,
pages: Math.ceil(total / limitNum),
},
});
});
// GET /api/products/:id
productsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
const product = await c.env.DB.prepare(`
SELECT p.*, c.name as category_name, c.slug as category_slug
FROM products p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.id = ? AND p.status != 'deleted'
`).bind(id).first();
if (!product) {
return c.json({ error: 'Product not found' }, 404);
}
// Aggiungi URL pre-firmato per l'immagine R2 (valido 1 ora)
let imageUrl: string | null = null;
if ((product as { image_key?: string }).image_key) {
const key = (product as { image_key: string }).image_key;
// Usa URL pubblica R2 (richiede bucket pubblico o URL custom domain)
imageUrl = `https://assets.myapp.com/${key}`;
}
return c.json({ ...product, imageUrl });
});
// POST /api/products - Crea prodotto (richiede auth admin/editor)
productsRouter.post('/', zValidator('json', createProductSchema), async (c) => {
const user = c.get('user') as { role: string } | undefined;
if (!user || !['admin', 'editor'].includes(user.role)) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
const data = c.req.valid('json');
// Verifica univocità slug
const existing = await c.env.DB.prepare(
'SELECT id FROM products WHERE slug = ?'
).bind(data.slug).first();
if (existing) {
return c.json({ error: 'Slug already exists' }, 409);
}
const result = await c.env.DB.prepare(`
INSERT INTO products (name, slug, description, price, stock, category_id)
VALUES (?, ?, ?, ?, ?, ?)
RETURNING *
`).bind(
data.name,
data.slug,
data.description ?? null,
data.price,
data.stock,
data.category_id ?? null
).first();
return c.json(result, 201);
});
// PATCH /api/products/:id
productsRouter.patch('/:id', async (c) => {
const id = c.req.param('id');
const user = c.get('user') as { role: string } | undefined;
if (!user || !['admin', 'editor'].includes(user.role)) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
const body = await c.req.json() as Partial<{ name: string; price: number; stock: number; status: string }>;
const updates: string[] = [];
const values: (string | number)[] = [];
if (body.name !== undefined) { updates.push('name = ?'); values.push(body.name); }
if (body.price !== undefined) { updates.push('price = ?'); values.push(body.price); }
if (body.stock !== undefined) { updates.push('stock = ?'); values.push(body.stock); }
if (body.status !== undefined) { updates.push('status = ?'); values.push(body.status); }
if (updates.length === 0) {
return c.json({ error: 'No fields to update' }, 400);
}
updates.push('updated_at = unixepoch()');
values.push(id);
const result = await c.env.DB.prepare(
`UPDATE products SET ${updates.join(', ')} WHERE id = ? AND status != 'deleted' RETURNING *`
).bind(...values).first();
if (!result) {
return c.json({ error: 'Product not found' }, 404);
}
return c.json(result);
});
export { productsRouter };
Încărcați imagini în R2
// src/routes/uploads.ts
import { Hono } from 'hono';
import type { Env } from '../types/env';
const uploadsRouter = new Hono<{ Bindings: Env }>();
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
uploadsRouter.post('/product-image/:productId', async (c) => {
const productId = c.req.param('productId');
const user = c.get('user') as { role: string } | undefined;
if (!user || !['admin', 'editor'].includes(user.role)) {
return c.json({ error: 'Insufficient permissions' }, 403);
}
// Verifica che il prodotto esista
const product = await c.env.DB.prepare(
"SELECT id FROM products WHERE id = ? AND status != 'deleted'"
).bind(productId).first();
if (!product) {
return c.json({ error: 'Product not found' }, 404);
}
const formData = await c.req.formData();
const file = formData.get('image') as File | null;
if (!file) {
return c.json({ error: 'No image file provided' }, 400);
}
// Validazione tipo e dimensione
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json({
error: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`
}, 400);
}
if (file.size > MAX_SIZE_BYTES) {
return c.json({ error: 'File too large. Maximum 5MB' }, 400);
}
// Genera chiave R2 univoca
const ext = file.type.split('/')[1];
const r2Key = `products/${productId}/${crypto.randomUUID()}.${ext}`;
// Upload su R2
await c.env.ASSETS_BUCKET.put(r2Key, file.stream(), {
httpMetadata: {
contentType: file.type,
cacheControl: 'public, max-age=31536000, immutable',
},
customMetadata: {
productId,
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
});
// Aggiorna il prodotto con la nuova chiave R2
// Prima elimina l'immagine precedente se esiste
const oldProduct = await c.env.DB.prepare(
'SELECT image_key FROM products WHERE id = ?'
).bind(productId).first<{ image_key: string | null }>();
if (oldProduct?.image_key) {
// Elimina la vecchia immagine in background
c.executionCtx.waitUntil(
c.env.ASSETS_BUCKET.delete(oldProduct.image_key)
);
}
await c.env.DB.prepare(
'UPDATE products SET image_key = ?, updated_at = unixepoch() WHERE id = ?'
).bind(r2Key, productId).run();
return c.json({
key: r2Key,
url: `https://assets.myapp.com/${r2Key}`,
size: file.size,
type: file.type,
}, 201);
});
// DELETE /api/uploads/product-image/:productId
uploadsRouter.delete('/product-image/:productId', async (c) => {
const productId = c.req.param('productId');
const user = c.get('user') as { role: string } | undefined;
if (!user || user.role !== 'admin') {
return c.json({ error: 'Admin required' }, 403);
}
const product = await c.env.DB.prepare(
'SELECT image_key FROM products WHERE id = ?'
).bind(productId).first<{ image_key: string | null }>();
if (!product?.image_key) {
return c.json({ error: 'No image found for this product' }, 404);
}
await c.env.ASSETS_BUCKET.delete(product.image_key);
await c.env.DB.prepare(
'UPDATE products SET image_key = NULL, updated_at = unixepoch() WHERE id = ?'
).bind(productId).run();
return new Response(null, { status: 204 });
});
export { uploadsRouter };
wrangler.toml Complete
# wrangler.toml
name = "catalog-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
# Worker deve avere Workers Paid Plan per D1, R2 e CPU > 10ms
account_id = "YOUR_ACCOUNT_ID"
[[d1_databases]]
binding = "DB"
database_name = "catalog-db"
database_id = "YOUR_D1_DATABASE_ID"
migrations_dir = "migrations" # wrangler d1 migrations apply catalog-db
[[r2_buckets]]
binding = "ASSETS_BUCKET"
bucket_name = "catalog-assets"
# Configura custom domain per URL pubblici: https://assets.myapp.com
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "YOUR_KV_NAMESPACE_ID"
# Secrets (impostati con: wrangler secret put JWT_SECRET)
# JWT_SECRET = ... (non nel file, usa wrangler secret)
[vars]
ENVIRONMENT = "production"
MAX_UPLOAD_MB = "5"
# Configurazione per staging
[env.staging]
name = "catalog-api-staging"
vars = { ENVIRONMENT = "staging" }
[[env.staging.d1_databases]]
binding = "DB"
database_name = "catalog-db-staging"
database_id = "YOUR_STAGING_D1_ID"
# Routes: mappa il Worker al custom domain
[[routes]]
pattern = "api.myapp.com/*"
zone_id = "YOUR_ZONE_ID"
Migrație D1: Aplicați Schema
# Crea il database D1
npx wrangler d1 create catalog-db
# Applica le migration iniziali
npx wrangler d1 migrations apply catalog-db
# In staging (dry run per verificare)
npx wrangler d1 migrations apply catalog-db --env staging --dry-run
# Query diretta per debug (locale)
npx wrangler d1 execute catalog-db --local --command "SELECT count(*) FROM products"
# Esporta snapshot per backup
npx wrangler d1 export catalog-db --output backup-$(date +%Y%m%d).sql
CI/CD: Acțiuni GitHub finalizate
# .github/workflows/deploy.yml
name: Test and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: TypeScript check
run: npx tsc --noEmit
- name: Lint
run: npm run lint
- name: Unit + Integration tests
run: npx vitest run --coverage
env:
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
deploy-staging:
name: Deploy Staging
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Apply D1 migrations (staging)
run: npx wrangler d1 migrations apply catalog-db --env staging
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Deploy to staging
run: npx wrangler deploy --env staging
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Run smoke tests against staging
run: npm run test:e2e
env:
API_BASE_URL: https://catalog-api-staging.myapp.com
deploy-production:
name: Deploy Production
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Apply D1 migrations (production)
run: npx wrangler d1 migrations apply catalog-db
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Deploy to production
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
- name: Purge Cloudflare Cache dopo deploy
run: |
curl -X POST "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
Observabilitate: jurnalele și monitorizarea
// src/middleware/logging.ts
// Middleware di logging strutturato verso R2 (via Logpush) o console
import type { MiddlewareHandler } from 'hono';
import type { Env } from '../types/env';
interface RequestLog {
timestamp: string;
method: string;
path: string;
status: number;
durationMs: number;
country: string;
datacenter: string;
ip: string;
userAgent: string;
userId?: string;
}
export const loggingMiddleware: MiddlewareHandler<{ Bindings: Env }> = async (c, next) => {
const startTime = Date.now();
await next();
const durationMs = Date.now() - startTime;
const user = c.get('user') as { id?: string } | undefined;
const log: RequestLog = {
timestamp: new Date().toISOString(),
method: c.req.method,
path: new URL(c.req.url).pathname,
status: c.res.status,
durationMs,
country: c.req.header('CF-IPCountry') ?? 'unknown',
datacenter: (c.req.raw as Request & { cf?: { colo?: string } }).cf?.colo ?? 'unknown',
ip: c.req.header('CF-Connecting-IP') ?? 'unknown',
userAgent: c.req.header('User-Agent') ?? 'unknown',
userId: user?.id,
};
// Log strutturato verso console (visibile in Cloudflare Dashboard)
console.log(JSON.stringify(log));
// Configura Cloudflare Logpush nel dashboard per inviare automaticamente
// i Workers logs verso R2, Datadog, Splunk, ecc.
};
// Alerting: monitora errori 5xx su Workers Analytics Engine
Costuri și scalare: Analiză economică
| Componentă | Preţ | Inclus în planul plătit (5 USD/lună) |
|---|---|---|
| Cererile muncitorilor | 0,30 USD / milion (după inclus) | 10 milioane/luna |
| Timpul CPU al lucrătorilor | 0,02 USD / milion CPU-ms | 30 milioane CPU-ms/lună |
| D1 rânduri citite | 0,001 USD / milion | 25 miliarde/luna |
| D1 rânduri scrise | 1,00 USD / milion | 50 milioane/luna |
| Stocare R2 | 0,015 USD/GB/lună | 10 GB |
| Operații R2 | 0,36 USD / milion Clasa A | 1 milion Clasa A, 10M Clasa B |
| KV citește | 0,50 USD / milion | 10 milioane/luna |
Pentru un API cu trafic mediu (1 milion de solicitări/lună, 100.000 scrieri D1, 10 GB R2): costul total este de aprox 5-15 USD/lună. O arhitectură echivalent pe AWS (API Gateway + Lambda + RDS + S3 + CloudFront) ar costa cu ușurință 150-400 USD/luna la aceeasi scara.
Concluziile seriei
Acest studiu de caz a demonstrat că este posibil să se construiască un API REST producție completă, cu autentificare, încărcare, căutare full-text și limitare a ratei, folosind Singur platforma Cloudflare. Rezultatul este o aplicație distribuite în peste 300 de centre de date globale, cu latență sub milisecundă, scalare infinit automat și costă o fracțiune din arhitecturile tradiționale cloud.
Izolatele V8, pe care le-am introdus în articolul 1, sunt fundamentul tuturor: vă permit să rulați cod TypeScript oriunde în lume fără pornire la rece, fără management server, fără VPC. Complexitatea operațională este eliminată; complexitatea afacerii rămâne a ta.
Seria conexe: Alte resurse
- Kubernetes la scară (Seria 36): Când Muncitorii nu sunt de ajuns și aveți nevoie de ei de containere cu mai mult CPU și RAM.
- Arhitectură bazată pe evenimente (Seria 39): Lucrători + Legături de coadă pentru modele asincrone și conducte de procesare.
- DevSecOps (Seria 05): Scanarea de securitate a dependențelor și a politicii ca cod aplicate ecosistemului Muncitorilor.







