17 KiB
Development Best Practices
Patterns and best practices for building robust, maintainable Workers applications.
Testing
Vitest Integration
Workers has first-class Vitest integration for unit and integration testing.
Setup:
npm install -D vitest @cloudflare/vitest-pool-workers
vitest.config.ts:
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.toml" },
},
},
},
});
Basic Test:
import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import worker from "./index";
describe("Worker", () => {
it("responds with JSON", async () => {
const request = new Request("http://example.com/api/users");
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveProperty("users");
});
it("handles errors gracefully", async () => {
const request = new Request("http://example.com/api/error");
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(500);
});
});
Testing with Bindings:
import { env } from "cloudflare:test";
describe("KV operations", () => {
it("reads and writes to KV", async () => {
// env provides access to bindings configured in wrangler.toml
await env.MY_KV.put("test-key", "test-value");
const value = await env.MY_KV.get("test-key");
expect(value).toBe("test-value");
});
});
Testing Durable Objects:
import { env, runInDurableObject } from "cloudflare:test";
describe("Counter Durable Object", () => {
it("increments counter", async () => {
await runInDurableObject(env.COUNTER, async (instance, state) => {
const request = new Request("http://do/increment");
const response = await instance.fetch(request);
const data = await response.json();
expect(data.count).toBe(1);
});
});
});
Run Tests:
npm test
# or
npx vitest
Integration Testing
Test full request/response cycles with external services.
describe("External API integration", () => {
it("fetches data from external API", async () => {
const request = new Request("http://example.com/api/external");
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(200);
// Verify external API was called correctly
const data = await response.json();
expect(data).toHaveProperty("externalData");
});
});
Mocking
Mock external dependencies for isolated tests.
import { vi } from "vitest";
describe("Mocked fetch", () => {
it("handles fetch errors", async () => {
// Mock global fetch
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
const request = new Request("http://example.com/api/data");
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
expect(response.status).toBe(503);
});
});
Error Handling
Global Error Handling
Catch all errors and return appropriate responses.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try {
return await handleRequest(request, env, ctx);
} catch (error) {
console.error("Uncaught error:", error);
return Response.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
);
}
},
};
async function handleRequest(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Your handler logic
}
Custom Error Classes
Define custom error types for better error handling.
class NotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "NotFoundError";
}
}
class UnauthorizedError extends Error {
constructor(message: string) {
super(message);
this.name = "UnauthorizedError";
}
}
class ValidationError extends Error {
constructor(public fields: Record<string, string>) {
super("Validation failed");
this.name = "ValidationError";
}
}
async function handleRequest(request: Request, env: Env): Promise<Response> {
try {
// Your logic
const user = await getUser(userId);
if (!user) {
throw new NotFoundError("User not found");
}
return Response.json(user);
} catch (error) {
if (error instanceof NotFoundError) {
return Response.json({ error: error.message }, { status: 404 });
}
if (error instanceof UnauthorizedError) {
return Response.json({ error: error.message }, { status: 401 });
}
if (error instanceof ValidationError) {
return Response.json(
{ error: "Validation failed", fields: error.fields },
{ status: 400 }
);
}
// Unknown error
console.error("Unexpected error:", error);
return Response.json({ error: "Internal server error" }, { status: 500 });
}
}
Retry Logic
Implement retry logic for transient failures.
async function fetchWithRetry(
url: string,
options: RequestInit = {},
maxRetries = 3
): Promise<Response> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) {
return response;
}
// Don't retry on client errors (4xx)
if (response.status >= 400 && response.status < 500) {
return response;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error as Error;
}
// Exponential backoff
if (i < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
throw lastError!;
}
Performance Optimization
Caching Strategies
Use the Cache API effectively.
async function handleCachedRequest(request: Request): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, request);
// Try to get from cache
let response = await cache.match(cacheKey);
if (!response) {
// Cache miss - fetch from origin
response = await fetchFromOrigin(request);
// Cache successful responses
if (response.ok) {
response = new Response(response.body, {
...response,
headers: {
...Object.fromEntries(response.headers),
"Cache-Control": "public, max-age=3600",
},
});
// Don't await - cache in background
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
}
return response;
}
Cache with custom keys:
function getCacheKey(request: Request, userId?: string): Request {
const url = new URL(request.url);
// Include user ID in cache key for personalized content
if (userId) {
url.searchParams.set("userId", userId);
}
return new Request(url.toString(), request);
}
Response Streaming
Stream responses for large data.
export default {
async fetch(request: Request): Promise<Response> {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
// Stream data in background
(async () => {
try {
const data = await fetchLargeDataset();
for (const item of data) {
await writer.write(new TextEncoder().encode(JSON.stringify(item) + "\n"));
}
await writer.close();
} catch (error) {
await writer.abort(error);
}
})();
return new Response(readable, {
headers: {
"Content-Type": "application/x-ndjson",
"Transfer-Encoding": "chunked",
},
});
},
};
Batching Operations
Batch multiple operations for better performance.
// Bad: Sequential operations
for (const userId of userIds) {
await env.KV.get(`user:${userId}`);
}
// Good: Batch operations
const users = await Promise.all(
userIds.map((id) => env.KV.get(`user:${id}`))
);
// Even better: Use batch APIs when available
const results = await env.DB.batch([
env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(1),
env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(2),
env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(3),
]);
Background Tasks
Use ctx.waitUntil() for non-critical work.
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Process request
const response = await handleRequest(request, env);
// Log analytics in background (don't block response)
ctx.waitUntil(
env.ANALYTICS.writeDataPoint({
blobs: [request.url, request.method],
doubles: [performance.now()],
})
);
// Update cache in background
ctx.waitUntil(updateCache(request, response, env));
return response;
},
};
Debugging
Console Logging
Use console methods for debugging.
console.log("Info:", { userId, action });
console.error("Error occurred:", error);
console.warn("Deprecated API used");
console.debug("Debug info:", data);
// Structured logging
console.log(JSON.stringify({
level: "info",
timestamp: Date.now(),
userId,
action,
}));
Wrangler Tail
View real-time logs during development.
# Tail logs
wrangler tail
# Filter by status
wrangler tail --status error
# Filter by method
wrangler tail --method POST
# Pretty format
wrangler tail --format pretty
Source Maps
Enable source maps for better error traces.
tsconfig.json:
{
"compilerOptions": {
"sourceMap": true
}
}
wrangler.toml:
upload_source_maps = true
Local Debugging
Use DevTools for debugging.
# Start with inspector
wrangler dev --inspector
Then open chrome://inspect in Chrome and connect to the worker.
Breakpoints
Set breakpoints in your code.
export default {
async fetch(request: Request): Promise<Response> {
debugger; // Execution will pause here
const data = await fetchData();
return Response.json(data);
},
};
Code Organization
Router Pattern
Organize routes cleanly.
interface Route {
pattern: URLPattern;
handler: (request: Request, env: Env, params: URLPatternResult) => Promise<Response>;
}
const routes: Route[] = [
{
pattern: new URLPattern({ pathname: "/api/users" }),
handler: handleGetUsers,
},
{
pattern: new URLPattern({ pathname: "/api/users/:id" }),
handler: handleGetUser,
},
{
pattern: new URLPattern({ pathname: "/api/users" }),
handler: handleCreateUser,
},
];
export default {
async fetch(request: Request, env: Env): Promise<Response> {
for (const route of routes) {
const match = route.pattern.exec(request.url);
if (match) {
return route.handler(request, env, match);
}
}
return Response.json({ error: "Not found" }, { status: 404 });
},
};
async function handleGetUsers(request: Request, env: Env): Promise<Response> {
const users = await env.DB.prepare("SELECT * FROM users").all();
return Response.json(users.results);
}
Middleware Pattern
Chain middleware for cross-cutting concerns.
type Middleware = (
request: Request,
env: Env,
next: () => Promise<Response>
) => Promise<Response>;
const corsMiddleware: Middleware = async (request, env, next) => {
if (request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
},
});
}
const response = await next();
response.headers.set("Access-Control-Allow-Origin", "*");
return response;
};
const authMiddleware: Middleware = async (request, env, next) => {
const token = request.headers.get("Authorization");
if (!token) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Validate token
const isValid = await validateToken(token, env);
if (!isValid) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
return next();
};
const loggingMiddleware: Middleware = async (request, env, next) => {
const start = Date.now();
const response = await next();
const duration = Date.now() - start;
console.log({
method: request.method,
url: request.url,
status: response.status,
duration,
});
return response;
};
function applyMiddleware(
handler: (request: Request, env: Env) => Promise<Response>,
middlewares: Middleware[]
): (request: Request, env: Env) => Promise<Response> {
return (request: Request, env: Env) => {
let index = -1;
const dispatch = async (i: number): Promise<Response> => {
if (i <= index) {
throw new Error("next() called multiple times");
}
index = i;
if (i === middlewares.length) {
return handler(request, env);
}
const middleware = middlewares[i];
return middleware(request, env, () => dispatch(i + 1));
};
return dispatch(0);
};
}
// Usage
const handler = applyMiddleware(
async (request, env) => {
return Response.json({ message: "Hello!" });
},
[loggingMiddleware, corsMiddleware, authMiddleware]
);
export default { fetch: handler };
Dependency Injection
Use environment for dependencies.
interface Env {
DB: D1Database;
CACHE: KVNamespace;
}
class UserService {
constructor(private env: Env) {}
async getUser(id: string) {
// Try cache first
const cached = await this.env.CACHE.get(`user:${id}`);
if (cached) return JSON.parse(cached);
// Fetch from database
const user = await this.env.DB.prepare(
"SELECT * FROM users WHERE id = ?"
).bind(id).first();
// Update cache
if (user) {
await this.env.CACHE.put(`user:${id}`, JSON.stringify(user), {
expirationTtl: 3600,
});
}
return user;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userService = new UserService(env);
const user = await userService.getUser("123");
return Response.json(user);
},
};
Security Best Practices
Input Validation
Always validate and sanitize user input.
import { z } from "zod";
const userSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().positive().max(150),
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
const body = await request.json();
const validated = userSchema.parse(body);
// Use validated data
await createUser(validated, env);
return Response.json({ success: true });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: "Validation failed", issues: error.errors },
{ status: 400 }
);
}
throw error;
}
},
};
Rate Limiting
Implement rate limiting to prevent abuse.
async function checkRateLimit(
identifier: string,
env: Env,
limit = 100,
window = 60
): Promise<boolean> {
const key = `ratelimit:${identifier}`;
const current = await env.CACHE.get(key);
if (!current) {
await env.CACHE.put(key, "1", { expirationTtl: window });
return true;
}
const count = parseInt(current);
if (count >= limit) {
return false;
}
await env.CACHE.put(key, String(count + 1), { expirationTtl: window });
return true;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
const allowed = await checkRateLimit(ip, env);
if (!allowed) {
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
}
return handleRequest(request, env);
},
};
CSRF Protection
Protect against cross-site request forgery.
function generateCSRFToken(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array));
}
async function validateCSRFToken(
token: string,
sessionId: string,
env: Env
): Promise<boolean> {
const stored = await env.SESSIONS.get(`csrf:${sessionId}`);
return stored === token;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method === "POST") {
const sessionId = request.headers.get("X-Session-ID");
const csrfToken = request.headers.get("X-CSRF-Token");
if (!sessionId || !csrfToken) {
return Response.json({ error: "Missing tokens" }, { status: 403 });
}
const isValid = await validateCSRFToken(csrfToken, sessionId, env);
if (!isValid) {
return Response.json({ error: "Invalid CSRF token" }, { status: 403 });
}
}
return handleRequest(request, env);
},
};