Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:13 +08:00
commit cfc837568a
22 changed files with 5232 additions and 0 deletions

232
templates/alarms-api-do.ts Normal file
View File

@@ -0,0 +1,232 @@
/**
* Alarms API Example: Batcher
*
* Demonstrates:
* - storage.setAlarm() to schedule future tasks
* - alarm() handler method
* - Guaranteed at-least-once execution
* - Retry behavior
* - Idempotent alarm patterns
*/
import { DurableObject, DurableObjectState } from 'cloudflare:workers';
interface Env {
BATCHER: DurableObjectNamespace<Batcher>;
// Example: API to send batch to
// API_ENDPOINT: string;
}
interface AlarmInfo {
retryCount: number;
isRetry: boolean;
}
export class Batcher extends DurableObject<Env> {
buffer: string[];
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Restore buffer from storage
ctx.blockConcurrencyWhile(async () => {
this.buffer = await ctx.storage.get<string[]>('buffer') || [];
console.log(`Batcher constructor: restored ${this.buffer.length} items`);
});
}
/**
* Add item to batch
*/
async addItem(item: string): Promise<void> {
this.buffer.push(item);
await this.ctx.storage.put('buffer', this.buffer);
// Schedule alarm for 10 seconds from now (if not already set)
const currentAlarm = await this.ctx.storage.getAlarm();
if (currentAlarm === null) {
// No alarm set - schedule one
await this.ctx.storage.setAlarm(Date.now() + 10000);
console.log(`Alarm scheduled for ${new Date(Date.now() + 10000).toISOString()}`);
} else {
console.log(`Alarm already scheduled for ${new Date(currentAlarm).toISOString()}`);
}
}
/**
* Alarm handler - called when alarm fires
*
* CRITICAL:
* - Guaranteed at-least-once execution
* - Retried up to 6 times with exponential backoff (2s, 4s, 8s, ...)
* - Implement idempotent operations
*/
async alarm(alarmInfo: AlarmInfo): Promise<void> {
console.log(`Alarm fired (retry: ${alarmInfo.isRetry}, count: ${alarmInfo.retryCount})`);
// Reload buffer from storage (may have changed since constructor)
this.buffer = await this.ctx.storage.get<string[]>('buffer') || [];
if (this.buffer.length === 0) {
console.log('No items to process');
return; // Alarm will be deleted automatically
}
// Limit retries
if (alarmInfo.retryCount > 3) {
console.error('Alarm failed after 3 retries, giving up');
// Still clear buffer to avoid infinite retries
this.buffer = [];
await this.ctx.storage.put('buffer', []);
return;
}
try {
// Process batch (idempotent operation)
await this.processBatch(this.buffer);
// Clear buffer after successful processing
this.buffer = [];
await this.ctx.storage.put('buffer', []);
console.log('Batch processed successfully');
// Alarm is automatically deleted after successful execution
} catch (error) {
console.error('Batch processing failed:', error);
// Throwing error will trigger retry
throw error;
}
}
/**
* Process batch - idempotent operation
*/
private async processBatch(items: string[]): Promise<void> {
console.log(`Processing batch of ${items.length} items:`, items);
// Example: Send to external API
// const response = await fetch(this.env.API_ENDPOINT, {
// method: 'POST',
// headers: { 'content-type': 'application/json' },
// body: JSON.stringify({ items }),
// });
//
// if (!response.ok) {
// throw new Error(`API error: ${response.status}`);
// }
// Simulate processing
await new Promise(resolve => setTimeout(resolve, 100));
}
/**
* Get current alarm time (if set)
*/
async getAlarmTime(): Promise<number | null> {
return await this.ctx.storage.getAlarm();
}
/**
* Cancel alarm
*/
async cancelAlarm(): Promise<void> {
await this.ctx.storage.deleteAlarm();
console.log('Alarm cancelled');
}
}
// CRITICAL: Export the class
export default Batcher;
/**
* Alternative pattern: Periodic cleanup with alarms
*/
export class PeriodicCleaner extends DurableObject {
constructor(ctx: DurableObjectState, env: any) {
super(ctx, env);
// Schedule alarm on first run
ctx.blockConcurrencyWhile(async () => {
const alarm = await ctx.storage.getAlarm();
if (alarm === null) {
// Schedule first cleanup in 1 hour
await ctx.storage.setAlarm(Date.now() + 3600000);
}
});
}
/**
* Periodic cleanup alarm
*/
async alarm(): Promise<void> {
console.log('Running periodic cleanup');
try {
// Cleanup expired data
await this.cleanup();
// Schedule next cleanup in 1 hour
await this.ctx.storage.setAlarm(Date.now() + 3600000);
} catch (error) {
console.error('Cleanup failed:', error);
throw error; // Will retry
}
}
private async cleanup(): Promise<void> {
const now = Date.now();
const oneDayAgo = now - (24 * 60 * 60 * 1000);
// Get all keys
const map = await this.ctx.storage.list();
// Delete old entries
const keysToDelete: string[] = [];
for (const [key, value] of map.entries()) {
if (typeof value === 'object' && value !== null && 'timestamp' in value) {
if ((value as any).timestamp < oneDayAgo) {
keysToDelete.push(key);
}
}
}
if (keysToDelete.length > 0) {
await this.ctx.storage.delete(keysToDelete);
console.log(`Deleted ${keysToDelete.length} old entries`);
}
}
}
/**
* Alternative pattern: Reminder/notification with alarms
*/
export class ReminderDO extends DurableObject {
async setReminder(message: string, fireAt: Date): Promise<void> {
// Store reminder data
await this.ctx.storage.put('reminder', { message, fireAt: fireAt.getTime() });
// Schedule alarm
await this.ctx.storage.setAlarm(fireAt);
console.log(`Reminder set for ${fireAt.toISOString()}`);
}
async alarm(): Promise<void> {
const reminder = await this.ctx.storage.get<{ message: string; fireAt: number }>('reminder');
if (reminder) {
console.log(`REMINDER: ${reminder.message}`);
// Send notification (e.g., via email, webhook, etc.)
// await this.sendNotification(reminder.message);
// Clear reminder
await this.ctx.storage.delete('reminder');
}
}
}

107
templates/basic-do.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Basic Durable Object Example: Counter
*
* Demonstrates:
* - DurableObject class structure
* - RPC methods (recommended pattern)
* - Key-value storage API
* - State persistence
*/
import { DurableObject, DurableObjectState } from 'cloudflare:workers';
interface Env {
COUNTER: DurableObjectNamespace<Counter>;
}
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Optional: Initialize from storage
// Use blockConcurrencyWhile to load before handling requests
ctx.blockConcurrencyWhile(async () => {
const value = await ctx.storage.get<number>('value');
if (value === undefined) {
// First time initialization
await ctx.storage.put('value', 0);
}
});
}
// RPC method: increment counter
async increment(): Promise<number> {
let value = await this.ctx.storage.get<number>('value') || 0;
value += 1;
await this.ctx.storage.put('value', value);
return value;
}
// RPC method: decrement counter
async decrement(): Promise<number> {
let value = await this.ctx.storage.get<number>('value') || 0;
value -= 1;
await this.ctx.storage.put('value', value);
return value;
}
// RPC method: get current value
async get(): Promise<number> {
return await this.ctx.storage.get<number>('value') || 0;
}
// RPC method: reset counter
async reset(): Promise<void> {
await this.ctx.storage.put('value', 0);
}
}
// CRITICAL: Export the class
export default Counter;
/**
* Worker that uses the Counter DO
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Get DO stub (using named DO for global counter)
const id = env.COUNTER.idFromName('global-counter');
const stub = env.COUNTER.get(id);
// Or use shortcut for named DOs:
// const stub = env.COUNTER.getByName('global-counter');
// Route requests
if (url.pathname === '/increment' && request.method === 'POST') {
const count = await stub.increment();
return new Response(JSON.stringify({ count }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/decrement' && request.method === 'POST') {
const count = await stub.decrement();
return new Response(JSON.stringify({ count }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/reset' && request.method === 'POST') {
await stub.reset();
return new Response(JSON.stringify({ count: 0 }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/get' && request.method === 'GET') {
const count = await stub.get();
return new Response(JSON.stringify({ count }), {
headers: { 'content-type': 'application/json' },
});
}
return new Response('Not found', { status: 404 });
},
};

238
templates/location-hints.ts Normal file
View File

@@ -0,0 +1,238 @@
/**
* Location Hints and Geographic Routing
*
* Demonstrates:
* - Location hints for geographic routing
* - Jurisdiction restrictions (EU, FedRAMP)
* - When to use each approach
* - Limitations and best practices
*/
import { DurableObject, DurableObjectState } from 'cloudflare:workers';
interface Env {
USER_DATA: DurableObjectNamespace<UserDataDO>;
}
export class UserDataDO extends DurableObject<Env> {
async storeUserData(data: any): Promise<void> {
await this.ctx.storage.put('userData', data);
}
async getUserData(): Promise<any> {
return await this.ctx.storage.get('userData');
}
}
// CRITICAL: Export the class
export default UserDataDO;
/**
* Worker demonstrating location hints and jurisdiction
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
if (!userId) {
return new Response('Missing userId', { status: 400 });
}
// Pattern 1: Location Hints (Best-Effort)
// Use when you want to create DO near user's location for lower latency
// Get user's location from request
const userRegion = request.cf?.continent as string || 'NA';
// Map continent to location hint
const locationHint = getLocationHint(userRegion);
// Create DO with location hint
const id = env.USER_DATA.idFromName(userId);
const stub = env.USER_DATA.get(id, { locationHint });
// ⚠️ Location hint only affects FIRST creation
// Subsequent access uses existing DO location
await stub.storeUserData({ userId, region: userRegion });
return new Response(JSON.stringify({ success: true, locationHint }), {
headers: { 'content-type': 'application/json' },
});
},
};
/**
* Map user region to Cloudflare location hint
*/
function getLocationHint(continent: string): string {
switch (continent) {
case 'NA':
return 'enam'; // Eastern North America
case 'EU':
return 'weur'; // Western Europe
case 'AS':
return 'apac'; // Asia-Pacific
case 'SA':
return 'sam'; // South America
case 'AF':
return 'afr'; // Africa
case 'OC':
return 'oc'; // Oceania
default:
return 'enam'; // Default
}
}
/**
* Available location hints:
*
* - 'wnam' - Western North America
* - 'enam' - Eastern North America
* - 'sam' - South America
* - 'weur' - Western Europe
* - 'eeur' - Eastern Europe
* - 'apac' - Asia-Pacific
* - 'oc' - Oceania
* - 'afr' - Africa
* - 'me' - Middle East
*/
/**
* Pattern 2: Jurisdiction Restriction (Strictly Enforced)
* Use for regulatory compliance (GDPR, FedRAMP)
*/
export const jurisdictionWorker = {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
const requireEU = url.searchParams.get('requireEU') === 'true';
if (!userId) {
return new Response('Missing userId', { status: 400 });
}
if (requireEU) {
// STRICT: DO MUST stay in EU
// Cannot combine jurisdiction with location hints
const euId = env.USER_DATA.newUniqueId({ jurisdiction: 'eu' });
const stub = env.USER_DATA.get(euId);
// Store ID for future access
// await env.KV.put(`user:${userId}`, euId.toString());
await stub.storeUserData({ userId, jurisdiction: 'eu' });
return new Response(JSON.stringify({
success: true,
jurisdiction: 'eu',
id: euId.toString(),
}), {
headers: { 'content-type': 'application/json' },
});
}
// Non-EU user: use location hint
const id = env.USER_DATA.idFromName(userId);
const stub = env.USER_DATA.get(id, { locationHint: 'enam' });
await stub.storeUserData({ userId });
return new Response(JSON.stringify({ success: true }), {
headers: { 'content-type': 'application/json' },
});
},
};
/**
* Available jurisdictions:
*
* - 'eu' - European Union (GDPR compliance)
* - 'fedramp' - FedRAMP (US government)
*/
/**
* Location Hints vs Jurisdiction: Decision Matrix
*
* | Requirement | Use |
* |-------------|-----|
* | Lower latency (nice-to-have) | Location hints |
* | Data residency (MUST) | Jurisdiction |
* | Regulatory compliance (GDPR, FedRAMP) | Jurisdiction |
* | Optimize for user location | Location hints |
* | Strict data sovereignty | Jurisdiction |
* | Performance optimization | Location hints |
*/
/**
* CRITICAL Limitations:
*
* ❌ Location hints are BEST-EFFORT (not guaranteed)
* ❌ Location hints only affect FIRST creation
* ❌ Cannot move existing DOs to new location
* ❌ Cannot combine jurisdiction with location hints
*
* ✅ Jurisdiction is STRICTLY ENFORCED
* ✅ Jurisdiction guarantees data never leaves region
*/
/**
* Example: Multi-region routing based on user IP
*/
export const multiRegionWorker = {
async fetch(request: Request, env: Env): Promise<Response> {
const userId = new URL(request.url).searchParams.get('userId');
if (!userId) {
return new Response('Missing userId', { status: 400 });
}
// Get user's country from request
const country = request.cf?.country as string;
// Determine if user is in EU
const euCountries = ['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'PL', 'PT', 'GR', /* ... */];
const isEU = euCountries.includes(country);
if (isEU) {
// EU user: MUST use jurisdiction
const euId = env.USER_DATA.newUniqueId({ jurisdiction: 'eu' });
const stub = env.USER_DATA.get(euId);
// Store ID for future access
// await env.DB.prepare('INSERT INTO user_do_ids (user_id, do_id) VALUES (?, ?)')
// .bind(userId, euId.toString())
// .run();
await stub.storeUserData({ userId, jurisdiction: 'eu' });
return new Response(JSON.stringify({ region: 'EU', id: euId.toString() }), {
headers: { 'content-type': 'application/json' },
});
}
// Non-EU user: use location hint for optimization
const locationHint = getLocationHintFromCountry(country);
const id = env.USER_DATA.idFromName(userId);
const stub = env.USER_DATA.get(id, { locationHint });
await stub.storeUserData({ userId });
return new Response(JSON.stringify({ region: locationHint }), {
headers: { 'content-type': 'application/json' },
});
},
};
function getLocationHintFromCountry(country: string): string {
// Simplified mapping - expand as needed
if (['US', 'CA', 'MX'].includes(country)) return 'enam';
if (['GB', 'FR', 'DE', 'ES', 'IT'].includes(country)) return 'weur';
if (['CN', 'JP', 'KR', 'SG', 'IN'].includes(country)) return 'apac';
if (['BR', 'AR', 'CL'].includes(country)) return 'sam';
if (['AU', 'NZ'].includes(country)) return 'oc';
if (['ZA', 'EG', 'KE'].includes(country)) return 'afr';
return 'enam'; // Default
}

View File

@@ -0,0 +1,283 @@
/**
* Multi-DO Coordination Example
*
* Demonstrates:
* - Multiple DO instances working together
* - Inter-DO communication via RPC
* - Coordinator pattern
* - Hierarchical DO structures
*/
import { DurableObject, DurableObjectState } from 'cloudflare:workers';
interface Env {
GAME_COORDINATOR: DurableObjectNamespace<GameCoordinator>;
GAME_ROOM: DurableObjectNamespace<GameRoom>;
PLAYER: DurableObjectNamespace<Player>;
}
/**
* Coordinator DO: Manages multiple game rooms
*/
export class GameCoordinator extends DurableObject<Env> {
async createGame(gameId: string): Promise<void> {
// Get game room DO (creates if doesn't exist)
const gameRoom = this.env.GAME_ROOM.getByName(gameId);
// Initialize game room
await gameRoom.initialize();
// Track in coordinator
await this.ctx.storage.put(`game:${gameId}`, {
id: gameId,
created: Date.now(),
status: 'waiting',
});
console.log(`Game created: ${gameId}`);
}
async listGames(): Promise<any[]> {
const games = await this.ctx.storage.list({ prefix: 'game:' });
return Array.from(games.values());
}
async deleteGame(gameId: string): Promise<void> {
// Get game room DO
const gameRoom = this.env.GAME_ROOM.getByName(gameId);
// Tell game room to clean up
await gameRoom.cleanup();
// Remove from coordinator
await this.ctx.storage.delete(`game:${gameId}`);
console.log(`Game deleted: ${gameId}`);
}
async getGameStatus(gameId: string): Promise<any> {
const gameRoom = this.env.GAME_ROOM.getByName(gameId);
return await gameRoom.getStatus();
}
}
/**
* Game Room DO: Manages players in a single game
*/
export class GameRoom extends DurableObject<Env> {
async initialize(): Promise<void> {
await this.ctx.storage.put('state', {
players: [],
started: false,
created: Date.now(),
});
}
async addPlayer(playerId: string, playerName: string): Promise<void> {
const state = await this.ctx.storage.get<any>('state');
if (!state) {
await this.initialize();
return this.addPlayer(playerId, playerName);
}
// Check if player already in game
if (state.players.some((p: any) => p.id === playerId)) {
throw new Error('Player already in game');
}
// Add player
state.players.push({ id: playerId, name: playerName, joined: Date.now() });
await this.ctx.storage.put('state', state);
// Notify player DO
const playerDO = this.env.PLAYER.getByName(playerId);
await playerDO.joinedGame(this.ctx.id.toString());
console.log(`Player ${playerName} joined game`);
}
async removePlayer(playerId: string): Promise<void> {
const state = await this.ctx.storage.get<any>('state');
if (!state) {
return;
}
// Remove player
state.players = state.players.filter((p: any) => p.id !== playerId);
await this.ctx.storage.put('state', state);
// Notify player DO
const playerDO = this.env.PLAYER.getByName(playerId);
await playerDO.leftGame(this.ctx.id.toString());
console.log(`Player ${playerId} left game`);
}
async startGame(): Promise<void> {
const state = await this.ctx.storage.get<any>('state');
if (!state) {
throw new Error('Game not initialized');
}
if (state.players.length < 2) {
throw new Error('Not enough players');
}
state.started = true;
state.startedAt = Date.now();
await this.ctx.storage.put('state', state);
// Notify all players
for (const player of state.players) {
const playerDO = this.env.PLAYER.getByName(player.id);
await playerDO.gameStarted(this.ctx.id.toString());
}
console.log('Game started');
}
async getStatus(): Promise<any> {
const state = await this.ctx.storage.get<any>('state');
return state || { players: [], started: false };
}
async cleanup(): Promise<void> {
const state = await this.ctx.storage.get<any>('state');
if (state) {
// Notify all players
for (const player of state.players) {
const playerDO = this.env.PLAYER.getByName(player.id);
await playerDO.gameEnded(this.ctx.id.toString());
}
}
// Delete all storage
await this.ctx.storage.deleteAll();
console.log('Game room cleaned up');
}
}
/**
* Player DO: Manages individual player state
*/
export class Player extends DurableObject<Env> {
async joinedGame(gameId: string): Promise<void> {
// Track which game player is in
await this.ctx.storage.put('currentGame', gameId);
console.log(`Player tracking: joined game ${gameId}`);
}
async leftGame(gameId: string): Promise<void> {
const currentGame = await this.ctx.storage.get<string>('currentGame');
if (currentGame === gameId) {
await this.ctx.storage.delete('currentGame');
}
console.log(`Player tracking: left game ${gameId}`);
}
async gameStarted(gameId: string): Promise<void> {
console.log(`Player notified: game ${gameId} started`);
// Update player stats
const stats = await this.ctx.storage.get<any>('stats') || { gamesPlayed: 0 };
stats.gamesPlayed += 1;
await this.ctx.storage.put('stats', stats);
}
async gameEnded(gameId: string): Promise<void> {
console.log(`Player notified: game ${gameId} ended`);
const currentGame = await this.ctx.storage.get<string>('currentGame');
if (currentGame === gameId) {
await this.ctx.storage.delete('currentGame');
}
}
async getStats(): Promise<any> {
return await this.ctx.storage.get('stats') || { gamesPlayed: 0 };
}
async getCurrentGame(): Promise<string | null> {
return await this.ctx.storage.get<string>('currentGame') || null;
}
}
// CRITICAL: Export classes
export { GameCoordinator, GameRoom, Player };
export default GameCoordinator;
/**
* Worker that orchestrates multiple DOs
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Global coordinator (singleton)
const coordinator = env.GAME_COORDINATOR.getByName('global');
if (url.pathname === '/games/create' && request.method === 'POST') {
const { gameId } = await request.json<{ gameId: string }>();
await coordinator.createGame(gameId);
return new Response(JSON.stringify({ success: true }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/games/list' && request.method === 'GET') {
const games = await coordinator.listGames();
return new Response(JSON.stringify({ games }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname.startsWith('/games/') && url.pathname.endsWith('/join')) {
const gameId = url.pathname.split('/')[2];
const { playerId, playerName } = await request.json<{ playerId: string; playerName: string }>();
const gameRoom = env.GAME_ROOM.getByName(gameId);
await gameRoom.addPlayer(playerId, playerName);
return new Response(JSON.stringify({ success: true }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname.startsWith('/games/') && url.pathname.endsWith('/start')) {
const gameId = url.pathname.split('/')[2];
const gameRoom = env.GAME_ROOM.getByName(gameId);
await gameRoom.startGame();
return new Response(JSON.stringify({ success: true }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname.startsWith('/players/') && url.pathname.endsWith('/stats')) {
const playerId = url.pathname.split('/')[2];
const player = env.PLAYER.getByName(playerId);
const stats = await player.getStats();
return new Response(JSON.stringify({ stats }), {
headers: { 'content-type': 'application/json' },
});
}
return new Response('Not found', { status: 404 });
},
};

17
templates/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "cloudflare-durable-objects-examples",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail",
"types": "wrangler types"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20251014.0",
"wrangler": "^4.43.0",
"typescript": "^5.7.2"
}
}

233
templates/rpc-vs-fetch.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* RPC vs HTTP Fetch Patterns
*
* Demonstrates:
* - RPC methods (recommended for compat_date >= 2024-04-03)
* - HTTP fetch handler (for HTTP flows or legacy compatibility)
* - When to use each pattern
*/
import { DurableObject, DurableObjectState } from 'cloudflare:workers';
interface Env {
RPC_EXAMPLE: DurableObjectNamespace<RpcExample>;
FETCH_EXAMPLE: DurableObjectNamespace<FetchExample>;
}
/**
* Pattern 1: RPC Methods (Recommended)
*
* ✅ Use when:
* - New project (compat_date >= 2024-04-03)
* - Type safety is important
* - Simple method calls (not HTTP-specific logic)
* - Auto-serialization of structured data
*/
export class RpcExample extends DurableObject<Env> {
count: number = 0;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.count = await ctx.storage.get<number>('count') || 0;
});
}
// Public RPC methods (automatically exposed)
async increment(): Promise<number> {
this.count += 1;
await this.ctx.storage.put('count', this.count);
return this.count;
}
async decrement(): Promise<number> {
this.count -= 1;
await this.ctx.storage.put('count', this.count);
return this.count;
}
async get(): Promise<number> {
return this.count;
}
async reset(): Promise<void> {
this.count = 0;
await this.ctx.storage.put('count', 0);
}
// Complex return types work seamlessly
async getStats(): Promise<{ count: number; timestamp: number }> {
return {
count: this.count,
timestamp: Date.now(),
};
}
// Methods can accept complex parameters
async addMultiple(numbers: number[]): Promise<number> {
const sum = numbers.reduce((acc, n) => acc + n, 0);
this.count += sum;
await this.ctx.storage.put('count', this.count);
return this.count;
}
}
/**
* Worker using RPC pattern
*/
const rpcWorker = {
async fetch(request: Request, env: Env): Promise<Response> {
// Get stub
const stub = env.RPC_EXAMPLE.getByName('my-counter');
// Call RPC methods directly (type-safe)
const count = await stub.increment();
const stats = await stub.getStats();
return new Response(JSON.stringify({ count, stats }), {
headers: { 'content-type': 'application/json' },
});
},
};
/**
* Pattern 2: HTTP Fetch Handler (Legacy / HTTP-specific flows)
*
* ✅ Use when:
* - Need HTTP request/response pattern
* - Complex routing logic
* - WebSocket upgrade (requires fetch)
* - Legacy compatibility (pre-2024-04-03)
*/
export class FetchExample extends DurableObject<Env> {
count: number = 0;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.count = await ctx.storage.get<number>('count') || 0;
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Route based on path
if (url.pathname === '/increment' && request.method === 'POST') {
this.count += 1;
await this.ctx.storage.put('count', this.count);
return new Response(JSON.stringify({ count: this.count }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/decrement' && request.method === 'POST') {
this.count -= 1;
await this.ctx.storage.put('count', this.count);
return new Response(JSON.stringify({ count: this.count }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/get' && request.method === 'GET') {
return new Response(JSON.stringify({ count: this.count }), {
headers: { 'content-type': 'application/json' },
});
}
if (url.pathname === '/reset' && request.method === 'POST') {
this.count = 0;
await this.ctx.storage.put('count', 0);
return new Response(JSON.stringify({ count: 0 }), {
headers: { 'content-type': 'application/json' },
});
}
// Complex HTTP logic (headers, cookies, etc.)
if (url.pathname === '/stats' && request.method === 'GET') {
const authHeader = request.headers.get('Authorization');
if (!authHeader) {
return new Response('Unauthorized', { status: 401 });
}
return new Response(JSON.stringify({
count: this.count,
timestamp: Date.now(),
}), {
headers: {
'content-type': 'application/json',
'cache-control': 'no-cache',
},
});
}
return new Response('Not found', { status: 404 });
}
}
/**
* Worker using HTTP fetch pattern
*/
const fetchWorker = {
async fetch(request: Request, env: Env): Promise<Response> {
// Get stub
const stub = env.FETCH_EXAMPLE.getByName('my-counter');
// Call fetch method (HTTP-style)
const response = await stub.fetch('https://fake-host/increment', {
method: 'POST',
});
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'content-type': 'application/json' },
});
},
};
/**
* Pattern 3: Hybrid (RPC + Fetch)
*
* Use both patterns in the same DO:
* - RPC for simple method calls
* - fetch() for WebSocket upgrades or HTTP-specific logic
*/
export class HybridExample extends DurableObject<Env> {
// RPC method
async getStatus(): Promise<{ active: boolean; connections: number }> {
return {
active: true,
connections: this.ctx.getWebSockets().length,
};
}
// HTTP fetch for WebSocket upgrade
async fetch(request: Request): Promise<Response> {
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
// WebSocket upgrade logic
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
return new Response('Not found', { status: 404 });
}
}
// CRITICAL: Export classes
export default RpcExample;

View File

@@ -0,0 +1,227 @@
/**
* State API Patterns Example
*
* Demonstrates:
* - SQL API (SQLite backend)
* - Key-Value API (available on both SQLite and KV backends)
* - Transactions
* - Combining SQL and KV storage
*/
import { DurableObject, DurableObjectState, SqlStorage } from 'cloudflare:workers';
interface Env {
STORAGE_EXAMPLE: DurableObjectNamespace<StorageExample>;
}
export class StorageExample extends DurableObject<Env> {
sql: SqlStorage;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Access SQL storage
this.sql = ctx.storage.sql;
// Create tables on first run
this.sql.exec(`
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
text TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON messages(user_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
`);
}
/**
* SQL API Example: Insert with RETURNING
*/
async createUser(email: string, username: string): Promise<number> {
const cursor = this.sql.exec(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?) RETURNING user_id',
email,
username,
Date.now()
);
const row = cursor.one<{ user_id: number }>();
return row.user_id;
}
/**
* SQL API Example: Query with parameters
*/
async getUserByEmail(email: string): Promise<any | null> {
const cursor = this.sql.exec(
'SELECT * FROM users WHERE email = ?',
email
);
return cursor.one({ allowNone: true });
}
/**
* SQL API Example: Query multiple rows
*/
async getRecentMessages(limit: number = 50): Promise<any[]> {
const cursor = this.sql.exec(
`SELECT m.*, u.username
FROM messages m
JOIN users u ON m.user_id = u.user_id
ORDER BY m.created_at DESC
LIMIT ?`,
limit
);
return cursor.toArray();
}
/**
* SQL API Example: Transaction (synchronous)
*/
async createUserWithMessage(email: string, username: string, messageText: string): Promise<void> {
this.ctx.storage.transactionSync(() => {
// Insert user
const userCursor = this.sql.exec(
'INSERT INTO users (email, username, created_at) VALUES (?, ?, ?) RETURNING user_id',
email,
username,
Date.now()
);
const { user_id } = userCursor.one<{ user_id: number }>();
// Insert message
this.sql.exec(
'INSERT INTO messages (user_id, text, created_at) VALUES (?, ?, ?)',
user_id,
messageText,
Date.now()
);
// All or nothing - if either fails, both are rolled back
});
}
/**
* SQL API Example: Iterate cursor
*/
async getAllUsers(): Promise<string[]> {
const cursor = this.sql.exec('SELECT username FROM users');
const usernames: string[] = [];
for (const row of cursor) {
usernames.push(row.username as string);
}
return usernames;
}
/**
* Key-Value API Example: Get/Put single value
*/
async setConfig(key: string, value: any): Promise<void> {
await this.ctx.storage.put(`config:${key}`, value);
}
async getConfig(key: string): Promise<any> {
return await this.ctx.storage.get(`config:${key}`);
}
/**
* Key-Value API Example: Get/Put multiple values
*/
async setConfigs(configs: Record<string, any>): Promise<void> {
const entries: Record<string, any> = {};
for (const [key, value] of Object.entries(configs)) {
entries[`config:${key}`] = value;
}
await this.ctx.storage.put(entries);
}
async getConfigs(): Promise<Record<string, any>> {
const map = await this.ctx.storage.list({ prefix: 'config:' });
const configs: Record<string, any> = {};
for (const [key, value] of map.entries()) {
const configKey = key.replace('config:', '');
configs[configKey] = value;
}
return configs;
}
/**
* Key-Value API Example: Delete
*/
async deleteConfig(key: string): Promise<void> {
await this.ctx.storage.delete(`config:${key}`);
}
/**
* Key-Value API Example: List with pagination
*/
async listKeys(prefix: string, limit: number = 100): Promise<string[]> {
const map = await this.ctx.storage.list({ prefix, limit });
return Array.from(map.keys());
}
/**
* Key-Value API Example: Async transaction
*/
async updateMultipleConfigs(updates: Record<string, any>): Promise<void> {
await this.ctx.storage.transaction(async (txn) => {
for (const [key, value] of Object.entries(updates)) {
await txn.put(`config:${key}`, value);
}
// All or nothing
});
}
/**
* Combining SQL and KV: Mixed storage patterns
*/
async recordUserActivity(userId: number, activity: string): Promise<void> {
// Store structured data in SQL
this.sql.exec(
'UPDATE users SET last_activity = ? WHERE user_id = ?',
Date.now(),
userId
);
// Store ephemeral data in KV (faster access)
await this.ctx.storage.put(`activity:${userId}`, {
type: activity,
timestamp: Date.now(),
});
}
/**
* Delete all storage (DO will cease to exist after shutdown)
*/
async deleteAllStorage(): Promise<void> {
// Delete alarm first (if set)
await this.ctx.storage.deleteAlarm();
// Delete all storage (atomic on SQLite backend)
await this.ctx.storage.deleteAll();
// After this, DO will not exist once it shuts down
}
}
// CRITICAL: Export the class
export default StorageExample;

View File

@@ -0,0 +1,225 @@
/**
* WebSocket Hibernation Example: Chat Room
*
* Demonstrates:
* - WebSocket Hibernation API
* - ctx.acceptWebSocket() for cost savings
* - WebSocket handler methods (webSocketMessage, webSocketClose, webSocketError)
* - serializeAttachment / deserializeAttachment for metadata persistence
* - State restoration in constructor after hibernation
*/
import { DurableObject, DurableObjectState } from 'cloudflare:workers';
interface Env {
CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
}
interface SessionMetadata {
userId: string;
username: string;
}
export class ChatRoom extends DurableObject<Env> {
// In-memory state (restored after hibernation)
sessions: Map<WebSocket, SessionMetadata>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
// Restore WebSocket connections after hibernation
this.sessions = new Map();
// Get all active WebSockets and restore their metadata
ctx.getWebSockets().forEach((ws) => {
// Deserialize metadata (persisted via serializeAttachment)
const metadata = ws.deserializeAttachment<SessionMetadata>();
this.sessions.set(ws, metadata);
});
console.log(`ChatRoom constructor: restored ${this.sessions.size} connections`);
}
/**
* Accept WebSocket connections
*/
async fetch(request: Request): Promise<Response> {
// Expect WebSocket upgrade request
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 });
}
if (request.method !== 'GET') {
return new Response('Expected GET method', { status: 400 });
}
// Get user info from URL parameters
const url = new URL(request.url);
const userId = url.searchParams.get('userId') || 'anonymous';
const username = url.searchParams.get('username') || 'Anonymous';
// Create WebSocket pair
const webSocketPair = new WebSocketPair();
const [client, server] = Object.values(webSocketPair);
// CRITICAL: Use ctx.acceptWebSocket (NOT ws.accept())
// This enables hibernation to save costs
this.ctx.acceptWebSocket(server);
// Serialize metadata to persist across hibernation
const metadata: SessionMetadata = { userId, username };
server.serializeAttachment(metadata);
// Track in-memory (will be restored after hibernation)
this.sessions.set(server, metadata);
// Notify others that user joined
this.broadcast({
type: 'system',
text: `${username} joined the room`,
timestamp: Date.now(),
}, server);
// Send welcome message to new user
server.send(JSON.stringify({
type: 'system',
text: `Welcome to the chat room! ${this.sessions.size} user(s) online.`,
timestamp: Date.now(),
}));
// Return client WebSocket to browser
return new Response(null, {
status: 101,
webSocket: client,
});
}
/**
* Called when WebSocket receives a message
* This method is called even if the DO was hibernated
*/
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const session = this.sessions.get(ws);
if (!session) {
console.error('WebSocket not found in sessions');
return;
}
// Handle text messages
if (typeof message === 'string') {
try {
const data = JSON.parse(message);
if (data.type === 'chat') {
// Broadcast chat message to all connections
this.broadcast({
type: 'chat',
userId: session.userId,
username: session.username,
text: data.text,
timestamp: Date.now(),
});
}
if (data.type === 'typing') {
// Broadcast typing indicator to others
this.broadcast({
type: 'typing',
userId: session.userId,
username: session.username,
}, ws);
}
} catch (error) {
console.error('Failed to parse message:', error);
}
}
}
/**
* Called when WebSocket closes
*/
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean): Promise<void> {
const session = this.sessions.get(ws);
// Close the WebSocket
ws.close(code, 'Durable Object closing WebSocket');
// Remove from sessions
this.sessions.delete(ws);
// Notify others
if (session) {
this.broadcast({
type: 'system',
text: `${session.username} left the room`,
timestamp: Date.now(),
});
}
console.log(`WebSocket closed: ${session?.username || 'unknown'}, code: ${code}, clean: ${wasClean}`);
}
/**
* Called on WebSocket errors
*/
async webSocketError(ws: WebSocket, error: any): Promise<void> {
console.error('WebSocket error:', error);
const session = this.sessions.get(ws);
this.sessions.delete(ws);
if (session) {
this.broadcast({
type: 'system',
text: `${session.username} disconnected (error)`,
timestamp: Date.now(),
});
}
}
/**
* Broadcast message to all connections (except sender)
*/
private broadcast(message: any, except?: WebSocket): void {
const messageStr = JSON.stringify(message);
this.sessions.forEach((session, ws) => {
if (ws !== except && ws.readyState === WebSocket.OPEN) {
ws.send(messageStr);
}
});
}
}
// CRITICAL: Export the class
export default ChatRoom;
/**
* Worker that creates and routes to chat rooms
*/
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Extract room ID from path (e.g., /room/abc123)
const match = url.pathname.match(/^\/room\/([^/]+)/);
if (!match) {
return new Response('Usage: /room/{roomId}?userId={userId}&username={username}', {
status: 400,
});
}
const roomId = match[1];
// Get or create chat room DO
const id = env.CHAT_ROOM.idFromName(roomId);
const stub = env.CHAT_ROOM.get(id);
// Forward request to DO
return stub.fetch(request);
},
};

View File

@@ -0,0 +1,98 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-durable-objects-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-22",
"compatibility_flags": ["nodejs_compat"],
// Durable Objects Configuration
"durable_objects": {
"bindings": [
{
// Simple counter example
"name": "COUNTER",
"class_name": "Counter"
},
{
// WebSocket chat room example
"name": "CHAT_ROOM",
"class_name": "ChatRoom"
},
{
// Rate limiter example
"name": "RATE_LIMITER",
"class_name": "RateLimiter"
},
{
// Session management example
"name": "USER_SESSION",
"class_name": "UserSession"
}
]
},
// Migrations (REQUIRED for all DO changes)
"migrations": [
{
// Initial migration: Create new DO classes
"tag": "v1",
"new_sqlite_classes": [
"Counter",
"ChatRoom",
"RateLimiter",
"UserSession"
]
}
// Example: Rename a DO class
// {
// "tag": "v2",
// "renamed_classes": [
// {
// "from": "Counter",
// "to": "CounterV2"
// }
// ]
// }
// Example: Delete a DO class
// {
// "tag": "v3",
// "deleted_classes": ["OldClass"]
// }
// Example: Transfer a DO class from another Worker
// {
// "tag": "v4",
// "transferred_classes": [
// {
// "from": "OriginalClass",
// "from_script": "original-worker-name",
// "to": "TransferredClass"
// }
// ]
// }
],
// Optional: Integrations with other Cloudflare services
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database",
"database_id": "your-database-id"
}
],
"kv_namespaces": [
{
"binding": "KV",
"id": "your-kv-namespace-id"
}
],
"r2_buckets": [
{
"binding": "BUCKET",
"bucket_name": "my-bucket"
}
]
}