Initial commit
This commit is contained in:
232
templates/alarms-api-do.ts
Normal file
232
templates/alarms-api-do.ts
Normal 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
107
templates/basic-do.ts
Normal 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
238
templates/location-hints.ts
Normal 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
|
||||
}
|
||||
283
templates/multi-do-coordination.ts
Normal file
283
templates/multi-do-coordination.ts
Normal 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
17
templates/package.json
Normal 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
233
templates/rpc-vs-fetch.ts
Normal 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;
|
||||
227
templates/state-api-patterns.ts
Normal file
227
templates/state-api-patterns.ts
Normal 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;
|
||||
225
templates/websocket-hibernation-do.ts
Normal file
225
templates/websocket-hibernation-do.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
98
templates/wrangler-do-config.jsonc
Normal file
98
templates/wrangler-do-config.jsonc
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user