Workers Core
Foundational patterns for writing Cloudflare Workers: entry points, bindings, request handling, and TypeScript setup.
Prerequisites: OVERVIEW.md
Overview
Section titled “Overview”Cloudflare Workers are serverless functions that run at the edge. This document covers the fundamentals of writing Workers code—the patterns every z0 Worker uses.
Key Concepts
Section titled “Key Concepts”| Concept | Description |
|---|---|
| Worker | A JavaScript/TypeScript module that handles requests |
| Binding | Connection to another Cloudflare resource (DO, D1, R2, etc.) |
| Environment | Object containing bindings and secrets |
| Handler | Function that responds to events (fetch, scheduled, queue) |
Worker Structure
Section titled “Worker Structure”Basic Worker (ES Modules)
Section titled “Basic Worker (ES Modules)”export interface Env { // Bindings declared here DB: D1Database; ENTITIES: DurableObjectNamespace; STORAGE: R2Bucket; QUEUE: Queue;
// Secrets and variables TWILIO_AUTH_TOKEN: string; ENVIRONMENT: string;}
export default { // HTTP requests async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { return handleRequest(request, env, ctx); },
// Cron triggers async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { await handleScheduled(event, env, ctx); },
// Queue consumers async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext): Promise<void> { await handleQueue(batch, env, ctx); },};
// Export Durable Object classesexport { EntityDO } from './durable-objects/entity';export { LedgerDO } from './durable-objects/ledger';wrangler.toml Configuration
Section titled “wrangler.toml Configuration”name = "z0-api"main = "src/index.ts"compatibility_date = "2024-01-01"
# Environment variables[vars]ENVIRONMENT = "production"
# D1 Database[[d1_databases]]binding = "DB"database_name = "z0-main"database_id = "xxxx-xxxx-xxxx"
# Durable Objects[durable_objects]bindings = [ { name = "ENTITIES", class_name = "EntityDO" }, { name = "LEDGERS", class_name = "LedgerDO" }]
[[migrations]]tag = "v1"new_classes = ["EntityDO", "LedgerDO"]
# R2 Bucket[[r2_buckets]]binding = "STORAGE"bucket_name = "z0-storage"
# Queue[[queues.producers]]binding = "QUEUE"queue = "z0-jobs"
[[queues.consumers]]queue = "z0-jobs"max_batch_size = 10max_batch_timeout = 30
# Cron triggers[triggers]crons = ["0 * * * *"] # Every hour
# Secrets (set via wrangler secret put)# TWILIO_AUTH_TOKEN# STRIPE_SECRET_KEYRequest Handling
Section titled “Request Handling”Router Pattern
Section titled “Router Pattern”type RouteHandler = ( request: Request, env: Env, ctx: ExecutionContext, params: Record<string, string>) => Promise<Response>;
interface Route { method: string; pattern: RegExp; handler: RouteHandler;}
const routes: Route[] = [ // Entities { method: 'GET', pattern: /^\/entities\/([^/]+)$/, handler: getEntity }, { method: 'POST', pattern: /^\/entities$/, handler: createEntity }, { method: 'PATCH', pattern: /^\/entities\/([^/]+)$/, handler: updateEntity },
// Facts { method: 'GET', pattern: /^\/entities\/([^/]+)\/facts$/, handler: listFacts }, { method: 'POST', pattern: /^\/entities\/([^/]+)\/facts$/, handler: appendFact },
// Webhooks { method: 'POST', pattern: /^\/webhooks\/twilio\/voice$/, handler: twilioVoice }, { method: 'POST', pattern: /^\/webhooks\/stripe$/, handler: stripeWebhook },];
export async function handleRequest( request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const url = new URL(request.url); const method = request.method; const path = url.pathname;
// Find matching route for (const route of routes) { if (route.method !== method) continue;
const match = path.match(route.pattern); if (match) { const params: Record<string, string> = {}; match.slice(1).forEach((value, index) => { params[`p${index}`] = value; });
try { return await route.handler(request, env, ctx, params); } catch (error) { return handleError(error); } } }
return new Response('Not Found', { status: 404 });}Request Parsing
Section titled “Request Parsing”// Parse JSON body with validationasync function parseJsonBody<T>(request: Request, schema: z.ZodSchema<T>): Promise<T> { const contentType = request.headers.get('content-type'); if (!contentType?.includes('application/json')) { throw new HttpError(400, 'Content-Type must be application/json'); }
let body: unknown; try { body = await request.json(); } catch { throw new HttpError(400, 'Invalid JSON'); }
const result = schema.safeParse(body); if (!result.success) { throw new HttpError(400, 'Validation failed', { errors: result.error.flatten().fieldErrors, }); }
return result.data;}
// Parse URL search paramsfunction parseSearchParams(url: URL): Record<string, string> { const params: Record<string, string> = {}; url.searchParams.forEach((value, key) => { params[key] = value; }); return params;}
// Parse form data (for Twilio webhooks)async function parseFormData(request: Request): Promise<Record<string, string>> { const formData = await request.formData(); const data: Record<string, string> = {}; formData.forEach((value, key) => { if (typeof value === 'string') { data[key] = value; } }); return data;}Response Helpers
Section titled “Response Helpers”export function json<T>(data: T, status = 200, headers: Record<string, string> = {}): Response { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', ...headers, }, });}
export function created<T>(data: T, location?: string): Response { const headers: Record<string, string> = {}; if (location) { headers['Location'] = location; } return json(data, 201, headers);}
export function noContent(): Response { return new Response(null, { status: 204 });}
export function notFound(message = 'Not found'): Response { return json({ error: message }, 404);}
export function badRequest(message: string, details?: unknown): Response { return json({ error: message, details }, 400);}Error Handling
Section titled “Error Handling”Custom Error Classes
Section titled “Custom Error Classes”export class HttpError extends Error { constructor( public status: number, message: string, public details?: unknown ) { super(message); this.name = 'HttpError'; }}
export class ValidationError extends HttpError { constructor(message: string, public errors: Record<string, string[]>) { super(400, message, errors); this.name = 'ValidationError'; }}
export class NotFoundError extends HttpError { constructor(resource: string, id: string) { super(404, `${resource} not found: ${id}`); this.name = 'NotFoundError'; }}
export class UnauthorizedError extends HttpError { constructor(message = 'Unauthorized') { super(401, message); this.name = 'UnauthorizedError'; }}
export class ForbiddenError extends HttpError { constructor(message = 'Forbidden') { super(403, message); this.name = 'ForbiddenError'; }}Error Handler
Section titled “Error Handler”export function handleError(error: unknown): Response { console.error('Error:', error);
if (error instanceof HttpError) { return json( { error: error.message, details: error.details, }, error.status ); }
// Don't leak internal errors return json( { error: 'Internal server error' }, 500 );}RFC 7807 Problem Details
Section titled “RFC 7807 Problem Details”// For more detailed error responsesinterface ProblemDetails { type: string; title: string; status: number; detail?: string; instance?: string; [key: string]: unknown;}
export function problemResponse(problem: ProblemDetails): Response { return new Response(JSON.stringify(problem), { status: problem.status, headers: { 'Content-Type': 'application/problem+json', }, });}
// Usagereturn problemResponse({ type: 'https://web1.co/errors/budget-exceeded', title: 'Budget Exceeded', status: 402, detail: 'Daily budget of $500 has been reached', instance: `/accounts/${accountId}`, budget_limit: 500, budget_spent: 512.50,});Authentication & Authorization
Section titled “Authentication & Authorization”API Key Authentication
Section titled “API Key Authentication”interface AuthContext { tenantId: string; userId?: string; scopes: string[];}
export async function authenticate( request: Request, env: Env): Promise<AuthContext> { const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) { throw new UnauthorizedError('Missing or invalid Authorization header'); }
const token = authHeader.slice(7);
// Look up API key const apiKey = await env.DB.prepare(` SELECT tenant_id, user_id, scopes, status FROM api_keys WHERE key_hash = ? `).bind(hashApiKey(token)).first();
if (!apiKey || apiKey.status !== 'active') { throw new UnauthorizedError('Invalid API key'); }
return { tenantId: apiKey.tenant_id as string, userId: apiKey.user_id as string | undefined, scopes: JSON.parse(apiKey.scopes as string), };}
function hashApiKey(key: string): string { // Use Web Crypto API const encoder = new TextEncoder(); const data = encoder.encode(key); return crypto.subtle.digest('SHA-256', data) .then(hash => btoa(String.fromCharCode(...new Uint8Array(hash))));}Scope Checking
Section titled “Scope Checking”export function requireScope(auth: AuthContext, scope: string): void { if (!auth.scopes.includes(scope) && !auth.scopes.includes('*')) { throw new ForbiddenError(`Missing required scope: ${scope}`); }}
// Usage in handlerasync function createEntity( request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const auth = await authenticate(request, env); requireScope(auth, 'entities:write');
// ... create entity}Webhook Signature Validation
Section titled “Webhook Signature Validation”// Twilio signature validationimport { validateRequest } from 'twilio';
export async function validateTwilioWebhook( request: Request, env: Env): Promise<boolean> { const signature = request.headers.get('X-Twilio-Signature'); if (!signature) return false;
const url = request.url; const params = Object.fromEntries(await request.clone().formData());
return validateRequest( env.TWILIO_AUTH_TOKEN, signature, url, params );}
// Stripe signature validationimport Stripe from 'stripe';
export async function validateStripeWebhook( request: Request, env: Env): Promise<Stripe.Event> { const signature = request.headers.get('stripe-signature'); if (!signature) { throw new UnauthorizedError('Missing Stripe signature'); }
const body = await request.text(); const stripe = new Stripe(env.STRIPE_SECRET_KEY);
try { return stripe.webhooks.constructEvent( body, signature, env.STRIPE_WEBHOOK_SECRET ); } catch (err) { throw new UnauthorizedError('Invalid Stripe signature'); }}Working with Bindings
Section titled “Working with Bindings”Durable Objects
Section titled “Durable Objects”// Get DO stub by IDasync function getEntityDO(env: Env, entityId: string): Promise<DurableObjectStub> { const id = env.ENTITIES.idFromName(entityId); return env.ENTITIES.get(id);}
// Call DO methodasync function appendFactToEntity( env: Env, entityId: string, fact: Fact): Promise<void> { const stub = await getEntityDO(env, entityId);
const response = await stub.fetch('https://do/facts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fact), });
if (!response.ok) { const error = await response.json(); throw new Error(`DO error: ${error.message}`); }}D1 Database
Section titled “D1 Database”// Query with parametersasync function getEntitiesByTenant( env: Env, tenantId: string, type?: string): Promise<Entity[]> { let query = 'SELECT * FROM entities WHERE tenant_id = ?'; const params: unknown[] = [tenantId];
if (type) { query += ' AND type = ?'; params.push(type); }
query += ' ORDER BY created_at DESC LIMIT 100';
const result = await env.DB.prepare(query).bind(...params).all(); return result.results as Entity[];}
// Batch operationsasync function insertFacts(env: Env, facts: Fact[]): Promise<void> { const stmt = env.DB.prepare(` INSERT INTO facts (id, type, subtype, entity_id, timestamp, data) VALUES (?, ?, ?, ?, ?, ?) `);
const batch = facts.map(f => stmt.bind(f.id, f.type, f.subtype, f.entity_id, f.timestamp, JSON.stringify(f.data)) );
await env.DB.batch(batch);}R2 Storage
Section titled “R2 Storage”// Store recordingasync function storeRecording( env: Env, callId: string, audio: ArrayBuffer): Promise<string> { const key = `recordings/${callId}.mp3`;
await env.STORAGE.put(key, audio, { httpMetadata: { contentType: 'audio/mpeg', }, customMetadata: { callId, uploadedAt: new Date().toISOString(), }, });
return key;}
// Get signed URL for playbackasync function getRecordingUrl( env: Env, key: string, expiresIn = 3600): Promise<string> { const object = await env.STORAGE.get(key); if (!object) { throw new NotFoundError('Recording', key); }
// R2 doesn't have native signed URLs, use presigned or proxy return `https://api.web1.co/recordings/${encodeURIComponent(key)}`;}Queues
Section titled “Queues”// Send message to queueasync function enqueueJob( env: Env, type: string, payload: unknown): Promise<void> { await env.QUEUE.send({ type, payload, enqueuedAt: Date.now(), });}
// Send batchasync function enqueueBatch( env: Env, messages: Array<{ type: string; payload: unknown }>): Promise<void> { await env.QUEUE.sendBatch( messages.map(m => ({ body: { type: m.type, payload: m.payload, enqueuedAt: Date.now(), }, })) );}
// Queue consumer handlerasync function handleQueue( batch: MessageBatch, env: Env, ctx: ExecutionContext): Promise<void> { for (const message of batch.messages) { try { const job = message.body as { type: string; payload: unknown };
switch (job.type) { case 'transcribe': await handleTranscription(env, job.payload); break; case 'analyze': await handleAnalysis(env, job.payload); break; default: console.warn(`Unknown job type: ${job.type}`); }
message.ack(); } catch (error) { console.error(`Job failed:`, error); message.retry(); } }}Execution Context
Section titled “Execution Context”waitUntil for Background Work
Section titled “waitUntil for Background Work”async function handleRequest( request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { // Process request const result = await processCall(request, env);
// Background work (doesn't block response) ctx.waitUntil(async () => { // Log to analytics await logToAnalytics(env, result);
// Sync to external systems await syncToCRM(env, result);
// Send notifications await sendNotifications(env, result); }());
// Return immediately return json(result);}Caching
Section titled “Caching”// Use Cache API for expensive operationsasync function getCachedConfig( env: Env, ctx: ExecutionContext, configId: string): Promise<Config> { const cache = caches.default; const cacheKey = new Request(`https://cache/configs/${configId}`);
// Check cache let response = await cache.match(cacheKey); if (response) { return response.json(); }
// Fetch from D1 const config = await getConfigFromD1(env, configId);
// Cache for 5 minutes response = new Response(JSON.stringify(config), { headers: { 'Cache-Control': 'max-age=300', }, }); ctx.waitUntil(cache.put(cacheKey, response.clone()));
return config;}TypeScript Setup
Section titled “TypeScript Setup”tsconfig.json
Section titled “tsconfig.json”{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "lib": ["ES2022"], "types": ["@cloudflare/workers-types"], "strict": true, "noEmit": true, "skipLibCheck": true, "isolatedModules": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, "include": ["src/**/*"], "exclude": ["node_modules"]}package.json
Section titled “package.json”{ "name": "z0-api", "type": "module", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "test": "vitest", "typecheck": "tsc --noEmit" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240117.0", "typescript": "^5.3.0", "wrangler": "^3.22.0", "vitest": "^1.2.0" }, "dependencies": { "zod": "^3.22.0" }}Type Definitions
Section titled “Type Definitions”// Entity typesexport interface Entity { id: string; type: EntityType; subtype?: string; name: string; identifier?: string; tenant_id: string; owner_id?: string; status: 'active' | 'inactive' | 'deleted'; metadata: Record<string, unknown>; created_at: number; updated_at: number;}
export type EntityType = | 'account' | 'asset' | 'tool' | 'vendor' | 'campaign' | 'user' | 'contact' | 'deal';
// Fact typesexport interface Fact { id: string; type: FactType; subtype?: string; timestamp: number; entity_id: string; tenant_id: string; source_id?: string; config_id?: string; config_version?: number; data: Record<string, unknown>;}
export type FactType = | 'invocation' | 'outcome' | 'charge' | 'payout' | 'cost' | 'lifecycle' | 'decision';
// Config typesexport interface Config { id: string; type: ConfigType; category: 'logic' | 'policy'; applies_to: string; scope: 'platform' | 'tenant' | 'account' | 'campaign' | 'asset'; version: number; settings: Record<string, unknown>; created_at: number; created_by: string;}
export type ConfigType = | 'pricing' | 'qualification' | 'routing' | 'budget' | 'hours' | 'autonomy' | 'webhook';Testing
Section titled “Testing”Vitest Setup
Section titled “Vitest Setup”import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'miniflare', environmentOptions: { modules: true, d1Databases: ['DB'], durableObjects: { ENTITIES: 'EntityDO', }, }, },});Unit Tests
Section titled “Unit Tests”import { describe, it, expect, beforeEach } from 'vitest';import { createEntity } from './entity';
describe('createEntity', () => { let env: Env;
beforeEach(() => { env = getMiniflareBindings(); });
it('creates an entity and returns 201', async () => { const request = new Request('https://api/entities', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'account', name: 'Test Account', tenant_id: 'tenant_001', }), });
const response = await createEntity(request, env, {} as ExecutionContext, {});
expect(response.status).toBe(201);
const body = await response.json(); expect(body.id).toBeDefined(); expect(body.type).toBe('account'); expect(body.name).toBe('Test Account'); });
it('returns 400 for invalid type', async () => { const request = new Request('https://api/entities', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'invalid', name: 'Test', }), });
const response = await createEntity(request, env, {} as ExecutionContext, {});
expect(response.status).toBe(400); });});Performance Best Practices
Section titled “Performance Best Practices”Cold Start Optimization
Section titled “Cold Start Optimization”// Avoid top-level await// BADconst config = await fetchConfig(); // Blocks cold start
// GOODlet cachedConfig: Config | null = null;async function getConfig(env: Env): Promise<Config> { if (!cachedConfig) { cachedConfig = await fetchConfig(env); } return cachedConfig;}Minimize Dependencies
Section titled “Minimize Dependencies”// Prefer built-in APIs over npm packages// BADimport moment from 'moment'; // Large bundle
// GOODconst date = new Date().toISOString();
// Use Web Crypto APIconst hash = await crypto.subtle.digest('SHA-256', data);
// Use native fetchconst response = await fetch(url);Streaming Responses
Section titled “Streaming Responses”// Stream large responsesasync function streamLargeResult( env: Env, query: string): Promise<Response> { const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder();
// Start streaming in background (async () => { try { const cursor = await env.DB.prepare(query).raw();
writer.write(encoder.encode('[')); let first = true;
for await (const row of cursor) { if (!first) writer.write(encoder.encode(',')); writer.write(encoder.encode(JSON.stringify(row))); first = false; }
writer.write(encoder.encode(']')); } finally { writer.close(); } })();
return new Response(readable, { headers: { 'Content-Type': 'application/json' }, });}Security Checklist
Section titled “Security Checklist”| Check | Description |
|---|---|
| Input validation | Validate all user input with Zod schemas |
| Authentication | Verify API keys/tokens on every request |
| Authorization | Check scopes before operations |
| Tenant isolation | Always filter by tenant_id |
| Webhook validation | Verify signatures (Twilio, Stripe) |
| Error messages | Don’t leak internal details |
| CORS | Configure allowed origins |
| Rate limiting | Use Cloudflare rate limiting or DO-based |
| Secrets | Never log or expose secrets |
| SQL injection | Always use parameterized queries |
CORS Configuration
Section titled “CORS Configuration”const CORS_HEADERS = { 'Access-Control-Allow-Origin': 'https://app.web1.co', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400',};
function handleOptions(): Response { return new Response(null, { headers: CORS_HEADERS });}
function addCorsHeaders(response: Response): Response { const newHeaders = new Headers(response.headers); Object.entries(CORS_HEADERS).forEach(([key, value]) => { newHeaders.set(key, value); }); return new Response(response.body, { status: response.status, headers: newHeaders, });}Summary
Section titled “Summary”| Concept | Implementation |
|---|---|
| Entry points | fetch, scheduled, queue handlers |
| Configuration | wrangler.toml for bindings, triggers |
| Request handling | Router pattern with typed handlers |
| Error handling | Custom error classes, RFC 7807 |
| Authentication | API keys, webhook signatures |
| Bindings | DO, D1, R2, Queue via env object |
| Background work | ctx.waitUntil() for async tasks |
| TypeScript | ES2022 target, workers-types |
| Testing | Vitest with miniflare |
| Performance | Minimize cold start, stream large responses |
This document covers the foundational patterns. See service-specific docs for Durable Objects, D1, Queues, etc.