Files
claudekit/skills/devops/references/cloudflare-workers.md
T

544 lines
15 KiB
Markdown

# DevOps — Cloudflare Workers Patterns
# Cloudflare Workers & Pages
## Overview
Edge-first deployment patterns for Cloudflare's platform. Covers Workers (compute), Pages (static + SSR), R2 (object storage), D1 (SQLite at edge), KV (key-value), Durable Objects (stateful), and Queues (async processing). Focused on the Python/TypeScript stack this kit targets.
## When to Use
- Deploying APIs or full-stack apps to Cloudflare's edge network
- Building serverless functions with Workers
- Deploying Next.js or static sites via Cloudflare Pages
- Using D1 (edge SQLite), R2 (S3-compatible storage), or KV (low-latency reads)
- Implementing real-time coordination with Durable Objects
- Background job processing with Cloudflare Queues
## When NOT to Use
- **Long-running compute** (> 30s CPU) — use traditional servers or containers
- **Heavy database workloads** — D1 is SQLite; use Postgres/Mongo for complex queries
- **GPU/ML inference** (unless using Workers AI) — use dedicated compute
- **Local-only development** — Workers run on V8 isolates, not Node.js
---
## Quick Reference
| I need... | Go to |
|-----------|-------|
| Worker project structure | § Project Structure below |
| Hono framework on Workers | § Hono Framework below |
| D1 database patterns | § D1 (Edge SQLite) below |
| R2 object storage | § R2 (Object Storage) below |
| KV key-value store | § KV below |
| Durable Objects | § Durable Objects below |
| Pages deployment (Next.js) | § Cloudflare Pages below |
| CI/CD with GitHub Actions | § CI/CD below |
| Wrangler config reference | See `wrangler-patterns.md` in this skill's directory |
---
## Project Structure
```
my-worker/
├── wrangler.toml # Wrangler config (bindings, routes, env)
├── src/
│ ├── index.ts # Entry point (fetch handler)
│ ├── routes/ # Route handlers
│ ├── middleware/ # Auth, CORS, logging
│ ├── services/ # Business logic
│ └── types.ts # Env bindings type
├── migrations/ # D1 migrations
├── test/ # Vitest tests
└── package.json
```
### Entry point
```typescript
// src/index.ts
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/health') {
return Response.json({ status: 'ok' });
}
// Route to handlers...
return new Response('Not found', { status: 404 });
},
} satisfies ExportedHandler<Env>;
```
### Type-safe bindings
```typescript
// src/types.ts
export interface Env {
DB: D1Database;
BUCKET: R2Bucket;
CACHE: KVNamespace;
API_KEY: string;
ENVIRONMENT: 'development' | 'staging' | 'production';
}
```
---
## Hono Framework (Recommended)
Hono is the de facto framework for Workers — ultralight (~14KB), type-safe, and built for edge runtimes.
```typescript
// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { HTTPException } from 'hono/http-exception';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
type Bindings = {
DB: D1Database;
BUCKET: R2Bucket;
API_KEY: string;
};
const app = new Hono<{ Bindings: Bindings }>();
app.use('*', logger());
app.use('*', cors({ origin: ['https://app.example.com'], credentials: true }));
// Health check
app.get('/health', (c) => c.json({ status: 'ok' }));
// Validated endpoint
const createUserSchema = z.object({
email: z.string().email().max(254),
name: z.string().min(1).max(100),
});
app.post('/v1/users', zValidator('json', createUserSchema), async (c) => {
const { email, name } = c.req.valid('json');
const result = await c.env.DB
.prepare('INSERT INTO users (id, email, name) VALUES (?, ?, ?) RETURNING *')
.bind(crypto.randomUUID(), email, name)
.first();
return c.json(result, 201);
});
// Error handling — RFC 9457 Problem Details
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({
type: `https://api.example.com/problems/${err.status}`,
title: err.message,
status: err.status,
}, err.status);
}
console.error(err);
return c.json({
type: 'https://api.example.com/problems/internal-error',
title: 'Internal server error',
status: 500,
}, 500);
});
export default app;
```
---
## D1 (Edge SQLite)
Cloudflare's serverless SQL database. SQLite at the edge with automatic replication.
### Migrations
```bash
# Create migration
npx wrangler d1 migrations create my-db create-users
# Apply locally
npx wrangler d1 migrations apply my-db --local
# Apply to production
npx wrangler d1 migrations apply my-db --remote
```
```sql
-- migrations/0001_create-users.sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT DEFAULT 'member' CHECK(role IN ('admin', 'member', 'viewer')),
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_email ON users(email);
```
### Querying with prepared statements
```typescript
// Always use prepared statements — never concatenate SQL
async function getUser(db: D1Database, id: string) {
return db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first();
}
async function listUsers(db: D1Database, cursor?: string, limit = 20) {
const stmt = cursor
? db.prepare('SELECT * FROM users WHERE id > ? ORDER BY id LIMIT ?').bind(cursor, limit)
: db.prepare('SELECT * FROM users ORDER BY id LIMIT ?').bind(limit);
return stmt.all();
}
// Batch multiple statements in a transaction
async function transferCredits(db: D1Database, from: string, to: string, amount: number) {
const results = await db.batch([
db.prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?').bind(amount, from),
db.prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?').bind(amount, to),
]);
return results;
}
```
### D1 limitations to know
- **No JOINs across databases** — one D1 database per binding
- **5MB max row size**, 10GB max database
- **Read replicas are automatic** but writes go to a single leader
- **No stored procedures / triggers** — SQLite subset
- **Prepared statements are mandatory** — `db.exec()` with raw SQL is for migrations only
---
## R2 (Object Storage)
S3-compatible object storage without egress fees.
```typescript
// Upload
app.put('/v1/files/:key', async (c) => {
const key = c.req.param('key');
const body = await c.req.arrayBuffer();
const contentType = c.req.header('Content-Type') ?? 'application/octet-stream';
await c.env.BUCKET.put(key, body, {
httpMetadata: { contentType },
customMetadata: { uploadedBy: c.get('userId') },
});
return c.json({ key, size: body.byteLength }, 201);
});
// Download
app.get('/v1/files/:key', async (c) => {
const obj = await c.env.BUCKET.get(c.req.param('key'));
if (!obj) return c.json({ error: 'Not found' }, 404);
return new Response(obj.body, {
headers: {
'Content-Type': obj.httpMetadata?.contentType ?? 'application/octet-stream',
'ETag': obj.etag,
},
});
});
// List with prefix
app.get('/v1/files', async (c) => {
const prefix = c.req.query('prefix') ?? '';
const listed = await c.env.BUCKET.list({ prefix, limit: 100 });
return c.json({ objects: listed.objects.map((o) => ({ key: o.key, size: o.size })) });
});
```
### Presigned URLs for direct upload
```typescript
// Generate a presigned URL so clients upload directly to R2
app.post('/v1/upload-url', async (c) => {
const key = `uploads/${crypto.randomUUID()}`;
// Use the S3-compatible API for presigned URLs
// Requires R2 API token with write access
return c.json({ key, uploadUrl: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com/${BUCKET_NAME}/${key}` });
});
```
---
## KV (Key-Value Store)
Global low-latency reads (~10ms worldwide), eventually consistent writes.
```typescript
// Set with TTL
await c.env.CACHE.put('session:abc123', JSON.stringify(sessionData), {
expirationTtl: 3600, // 1 hour
});
// Get with type safety
const raw = await c.env.CACHE.get('session:abc123');
const session = raw ? JSON.parse(raw) as SessionData : null;
// List keys by prefix
const keys = await c.env.CACHE.list({ prefix: 'session:' });
// Delete
await c.env.CACHE.delete('session:abc123');
```
**Use KV for:** session tokens, feature flags, cached API responses, configuration. **Not for:** frequently updated counters, multi-key transactions (use Durable Objects).
---
## Durable Objects
Stateful, single-instance coordination. Each Durable Object has a unique ID and runs in exactly one location.
```typescript
// src/counter.ts
export class Counter implements DurableObject {
private count = 0;
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/increment') {
this.count++;
await this.state.storage.put('count', this.count);
return Response.json({ count: this.count });
}
this.count = (await this.state.storage.get<number>('count')) ?? 0;
return Response.json({ count: this.count });
}
}
// In the Worker, route to the Durable Object:
app.post('/v1/counters/:name/increment', async (c) => {
const id = c.env.COUNTER.idFromName(c.req.param('name'));
const stub = c.env.COUNTER.get(id);
const res = await stub.fetch(new Request('https://dummy/increment'));
return c.json(await res.json());
});
```
**Use Durable Objects for:** rate limiting, WebSocket rooms, collaborative editing, distributed locks, shopping carts. **Not for:** read-heavy caching (use KV).
---
## Cloudflare Pages
### Next.js on Pages
```bash
# Deploy Next.js to Cloudflare Pages
npx wrangler pages deploy .next --project-name=my-app
```
Use `@cloudflare/next-on-pages` for full App Router + Server Components support:
```bash
pnpm add @cloudflare/next-on-pages
```
```typescript
// next.config.ts
import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
if (process.env.NODE_ENV === 'development') {
await setupDevPlatform();
}
const nextConfig = { /* ... */ };
export default nextConfig;
```
### Static site on Pages
```bash
# Build and deploy
pnpm build
npx wrangler pages deploy dist/ --project-name=my-site
```
Pages auto-deploys from GitHub: connect your repo in the Cloudflare dashboard, set the build command and output directory. Preview deploys on every PR.
---
## Wrangler Config
```toml
# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
[vars]
ENVIRONMENT = "production"
# D1 database
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# R2 bucket
[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-bucket"
# KV namespace
[[kv_namespaces]]
binding = "CACHE"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Durable Object
[[durable_objects.bindings]]
name = "COUNTER"
class_name = "Counter"
[[migrations]]
tag = "v1"
new_classes = ["Counter"]
# Environment overrides
[env.staging]
vars = { ENVIRONMENT = "staging" }
[env.staging.d1_databases]
binding = "DB"
database_name = "my-db-staging"
database_id = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"
```
**`compatibility_date`** pins your Worker to a specific runtime version. Always set it to a recent date and update periodically. **`nodejs_compat`** enables Node.js built-in APIs (Buffer, crypto, streams) — required for most npm packages.
---
## CI/CD
### GitHub Actions deploy
```yaml
# .github/workflows/deploy.yml
name: Deploy Worker
on:
push:
branches: [main]
pull_request:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: pnpm install
- name: Run tests
run: pnpm test
- name: Apply D1 migrations (production)
if: github.ref == 'refs/heads/main'
run: npx wrangler d1 migrations apply my-db --remote
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
- name: Deploy to staging (PR)
if: github.event_name == 'pull_request'
run: npx wrangler deploy --env staging
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
- name: Deploy to production
if: github.ref == 'refs/heads/main'
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
```
### Local development
```bash
# Start local dev server with all bindings (D1, R2, KV, DO)
npx wrangler dev
# With local D1 persistence
npx wrangler dev --persist-to .wrangler/state
```
`wrangler dev` uses Miniflare under the hood — a local simulator for all Cloudflare primitives. Test against real bindings locally before deploying.
---
## Testing
Use **Vitest + Miniflare** (via `@cloudflare/vitest-pool-workers`):
```typescript
// vitest.config.ts
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config';
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.toml' },
},
},
},
});
```
```typescript
// test/index.spec.ts
import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src/index';
describe('Worker', () => {
it('returns health check', async () => {
const request = new Request('http://localhost/health');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.status).toBe(200);
const body = await response.json();
expect(body).toEqual({ status: 'ok' });
});
});
```
---
## Common Pitfalls
1. **Using Node.js APIs without `nodejs_compat`.** Workers run on V8, not Node.js. Without the flag, `Buffer`, `crypto`, `process` are undefined.
2. **Blocking the event loop.** Workers have strict CPU time limits (10ms free, 30s paid). Heavy computation blocks all concurrent requests. Use `ctx.waitUntil()` for background work.
3. **Ignoring D1's eventually consistent reads.** Writes go to the leader; reads from replicas may lag by seconds. Design for eventual consistency.
4. **Using KV for frequently updated data.** KV is eventually consistent with ~60s propagation. Use Durable Objects for strong consistency.
5. **Not setting `compatibility_date`.** Without it, you get the oldest runtime behavior. Always pin to a recent date.
6. **Forgetting `ctx.waitUntil()`.** Background work (logging, analytics) must be wrapped in `waitUntil()` or it gets killed when the response is sent.
7. **Large Worker bundles.** Workers have a 10MB compressed limit (free: 1MB). Tree-shake aggressively; avoid heavy npm packages.
8. **Not testing locally with Miniflare.** `wrangler dev` simulates all bindings locally. Deploying untested changes to edge = debugging in production.
---
## Related Skills
- `docker` — alternative deployment model (containers vs edge)
- `github-actions` — CI/CD pipeline for deploying Workers
- `vitest` — testing Workers with Miniflare pool