Durable Objects
Cloudwerk provides a convention-based Durable Objects system through @cloudwerk/durable-object. Define type-safe, stateful objects with native Cloudflare RPC, storage options (KV and SQLite), WebSocket support, and alarms.
Quick Start
Section titled “Quick Start”-
Create a Durable Object:
// app/objects/counter.tsimport { defineDurableObject } from '@cloudwerk/durable-object'interface CounterState {value: number}export default defineDurableObject<CounterState>({init: () => ({ value: 0 }),methods: {async increment(amount = 1) {this.state.value += amountreturn this.state.value},async getValue() {return this.state.value},},}) -
Use it from your routes:
// app/api/counter/[id]/route.tsimport { durableObjects } from '@cloudwerk/core/bindings'import { json } from '@cloudwerk/core'export async function POST(request: Request, { params }) {const id = durableObjects.Counter.idFromName(params.id)const stub = durableObjects.Counter.get(id)// Direct RPC call - no HTTP overheadconst newValue = await stub.increment(5)return json({ value: newValue })} -
Durable Objects are automatically discovered and registered during build.
Convention Structure
Section titled “Convention Structure”Durable Objects are defined in the app/objects/ directory:
Directoryapp/objects/
- counter.ts # Counter object
- chat-room.ts # Chat room with WebSockets
- user-session.ts # User session object
- rate-limiter.ts # Rate limiter
Naming Convention:
- File names are kebab-case to PascalCase class names
counter.tsbecomes classCounter, bindingCOUNTERchat-room.tsbecomes classChatRoom, bindingCHAT_ROOM
Defining Objects
Section titled “Defining Objects”Basic Object with State
Section titled “Basic Object with State”// app/objects/counter.tsimport { defineDurableObject } from '@cloudwerk/durable-object'
interface CounterState { value: number lastUpdated: number}
export default defineDurableObject<CounterState>({ init(ctx) { return { value: 0, lastUpdated: Date.now(), } }, methods: { async increment(amount = 1) { this.state.value += amount this.state.lastUpdated = Date.now() return this.state.value }, async decrement(amount = 1) { this.state.value = Math.max(0, this.state.value - amount) this.state.lastUpdated = Date.now() return this.state.value }, async getValue() { return this.state.value }, async reset() { this.state.value = 0 this.state.lastUpdated = Date.now() return 0 }, },})With Schema Validation
Section titled “With Schema Validation”Use Zod for runtime state validation:
import { defineDurableObject } from '@cloudwerk/durable-object'import { z } from 'zod'
const SessionSchema = z.object({ userId: z.string(), expiresAt: z.number(), data: z.record(z.unknown()),})
type SessionState = z.infer<typeof SessionSchema>
export default defineDurableObject<SessionState>({ schema: SessionSchema, init: () => ({ userId: '', expiresAt: 0, data: {}, }), methods: { async setUser(userId: string, ttl = 3600) { this.state.userId = userId this.state.expiresAt = Date.now() + ttl * 1000 }, async getData() { if (Date.now() > this.state.expiresAt) { throw new Error('Session expired') } return this.state }, },})RPC Methods
Section titled “RPC Methods”Methods defined in the methods object become native Cloudflare RPC methods:
export default defineDurableObject<CartState>({ methods: { // Simple method async getItems() { return this.state.items },
// Method with parameters async addItem(productId: string, quantity: number) { const existing = this.state.items.find(i => i.productId === productId) if (existing) { existing.quantity += quantity } else { this.state.items.push({ productId, quantity }) } return this.state.items },
// Method accessing environment async checkout() { const db = this.ctx.env.DB await db.prepare('INSERT INTO orders...').run() this.state.items = [] return { success: true } }, },})Calling RPC Methods
Section titled “Calling RPC Methods”// From route handlersimport { durableObjects } from '@cloudwerk/core/bindings'
export async function POST(request: Request, { params }) { const id = durableObjects.Cart.idFromName(params.userId) const stub = durableObjects.Cart.get(id)
// Direct RPC calls - type-safe and fast await stub.addItem('prod_123', 2) const items = await stub.getItems() const result = await stub.checkout()
return json({ items, result })}Storage Options
Section titled “Storage Options”Key-Value Storage
Section titled “Key-Value Storage”Access the built-in KV storage via this.ctx.storage:
export default defineDurableObject<UserState>({ methods: { async setPreference(key: string, value: unknown) { await this.ctx.storage.put(`pref:${key}`, value) },
async getPreference(key: string) { return this.ctx.storage.get(`pref:${key}`) },
async getAllPreferences() { const prefs = await this.ctx.storage.list({ prefix: 'pref:' }) return Object.fromEntries(prefs) },
async clearPreferences() { const prefs = await this.ctx.storage.list({ prefix: 'pref:' }) for (const key of prefs.keys()) { await this.ctx.storage.delete(key) } }, },})Storage API
Section titled “Storage API”// Get single valueconst value = await this.ctx.storage.get<MyType>('key')
// Get multiple valuesconst map = await this.ctx.storage.get(['key1', 'key2'])
// List keys with prefixconst entries = await this.ctx.storage.list({ prefix: 'user:', limit: 100, reverse: false,})
// Put single valueawait this.ctx.storage.put('key', value)
// Put multiple valuesawait this.ctx.storage.put({ key1: value1, key2: value2,})
// Deleteawait this.ctx.storage.delete('key')await this.ctx.storage.deleteAll()Transactions
Section titled “Transactions”For atomic operations:
methods: { async transfer(fromKey: string, toKey: string, amount: number) { await this.ctx.storage.transaction(async (txn) => { const from = await txn.get<number>(fromKey) ?? 0 const to = await txn.get<number>(toKey) ?? 0
if (from < amount) { txn.rollback() throw new Error('Insufficient funds') }
await txn.put(fromKey, from - amount) await txn.put(toKey, to + amount) }) },}SQLite Storage
Section titled “SQLite Storage”Enable SQLite for relational data:
// app/objects/analytics.tsexport default defineDurableObject<AnalyticsState>({ sqlite: true, // Enable SQLite
init(ctx) { // Create tables on initialization ctx.sql.run(` CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, properties TEXT, timestamp INTEGER NOT NULL ) `) ctx.sql.run(` CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events (timestamp) `)
return { initialized: true } },
methods: { async trackEvent(name: string, properties?: Record<string, unknown>) { this.ctx.sql.run( 'INSERT INTO events (name, properties, timestamp) VALUES (?, ?, ?)', [name, JSON.stringify(properties), Date.now()] ) },
async getRecentEvents(limit = 100) { const cursor = this.ctx.sql.exec( 'SELECT * FROM events ORDER BY timestamp DESC LIMIT ?', [limit] ) return cursor.toArray() },
async getEventCounts(since: number) { const cursor = this.ctx.sql.exec( 'SELECT name, COUNT(*) as count FROM events WHERE timestamp > ? GROUP BY name', [since] ) return cursor.toArray() }, },})WebSocket Support
Section titled “WebSocket Support”Build real-time applications with WebSocket support:
// app/objects/chat-room.tsimport { defineDurableObject } from '@cloudwerk/durable-object'
interface ChatState { messages: Array<{ user: string; text: string; timestamp: number }>}
export default defineDurableObject<ChatState>({ init: () => ({ messages: [] }),
async fetch(request) { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }) }
const url = new URL(request.url) const username = url.searchParams.get('username') ?? 'Anonymous'
// Create WebSocket pair const pair = new WebSocketPair() const [client, server] = Object.values(pair)
// Accept with tags for filtering this.ctx.acceptWebSocket(server, [username])
// Send recent messages server.send(JSON.stringify({ type: 'history', messages: this.state.messages.slice(-50), }))
// Notify others this.broadcast({ type: 'join', user: username, timestamp: Date.now(), }, server)
return new Response(null, { status: 101, webSocket: client }) },
async webSocketMessage(ws, message) { const data = JSON.parse(message as string) const [username] = this.ctx.getTags(ws)
if (data.type === 'message') { const msg = { user: username, text: data.text, timestamp: Date.now(), }
this.state.messages.push(msg)
// Keep last 1000 messages if (this.state.messages.length > 1000) { this.state.messages = this.state.messages.slice(-1000) }
this.broadcast({ type: 'message', ...msg }) } },
async webSocketClose(ws, code, reason) { const [username] = this.ctx.getTags(ws) this.broadcast({ type: 'leave', user: username, timestamp: Date.now(), }) },
methods: { async getOnlineUsers() { const sockets = this.ctx.getWebSockets() return sockets.map(ws => this.ctx.getTags(ws)[0]) },
async getRecentMessages(limit = 50) { return this.state.messages.slice(-limit) }, },})WebSocket Context API
Section titled “WebSocket Context API”// Accept a WebSocket connectionthis.ctx.acceptWebSocket(ws, ['tag1', 'tag2'])
// Get all connected WebSocketsconst all = this.ctx.getWebSockets()
// Get WebSockets by tagconst admins = this.ctx.getWebSockets('admin')
// Get tags for a WebSocketconst tags = this.ctx.getTags(ws)Alarms
Section titled “Alarms”Schedule periodic tasks with alarms:
// app/objects/session.tsexport default defineDurableObject<SessionState>({ init(ctx) { // Schedule cleanup alarm ctx.storage.setAlarm(Date.now() + 30 * 60 * 1000) // 30 minutes return { data: {}, lastActivity: Date.now() } },
async alarm() { const idleTime = Date.now() - this.state.lastActivity
if (idleTime > 30 * 60 * 1000) { // Inactive for 30 minutes, clean up await this.ctx.storage.deleteAll() } else { // Reschedule alarm await this.ctx.storage.setAlarm(Date.now() + 30 * 60 * 1000) } },
methods: { async touch() { this.state.lastActivity = Date.now() },
async getData() { await this.touch() return this.state.data }, },})Alarm API
Section titled “Alarm API”// Set alarm (replaces existing)await this.ctx.storage.setAlarm(Date.now() + 60000) // 1 minute from nowawait this.ctx.storage.setAlarm(new Date('2024-12-31'))
// Get current alarmconst alarmTime = await this.ctx.storage.getAlarm()
// Cancel alarmawait this.ctx.storage.deleteAlarm()HTTP Fetch Handler
Section titled “HTTP Fetch Handler”Handle HTTP requests in addition to RPC:
export default defineDurableObject<CounterState>({ init: () => ({ value: 0 }),
async fetch(request) { const url = new URL(request.url)
if (request.method === 'GET' && url.pathname === '/') { return Response.json({ value: this.state.value, timestamp: Date.now(), }) }
if (request.method === 'POST' && url.pathname === '/increment') { const body = await request.json() this.state.value += body.amount ?? 1 return Response.json({ value: this.state.value }) }
return new Response('Not Found', { status: 404 }) },
methods: { // RPC methods are preferred over fetch async increment(amount = 1) { this.state.value += amount return this.state.value }, },})Calling via Fetch
Section titled “Calling via Fetch”const id = durableObjects.Counter.idFromName('my-counter')const stub = durableObjects.Counter.get(id)
// HTTP fetch (less efficient than RPC)const response = await stub.fetch(new Request('http://counter/increment', { method: 'POST', body: JSON.stringify({ amount: 5 }),}))const data = await response.json()Accessing Durable Objects
Section titled “Accessing Durable Objects”ID Creation
Section titled “ID Creation”import { durableObjects } from '@cloudwerk/core/bindings'
// From a name (deterministic)const id = durableObjects.Counter.idFromName('user-123-cart')
// From a string IDconst id = durableObjects.Counter.idFromString('some-id-string')
// Generate unique IDconst id = durableObjects.Counter.newUniqueId()
// Get stubconst stub = durableObjects.Counter.get(id)ID Properties
Section titled “ID Properties”const id = durableObjects.Counter.idFromName('my-counter')
id.toString() // String representationid.name // 'my-counter' (if created with idFromName)id.equals(otherId) // Compare IDsCommon Patterns
Section titled “Common Patterns”Rate Limiter
Section titled “Rate Limiter”// app/objects/rate-limiter.tsinterface RateLimitState { requests: number[]}
export default defineDurableObject<RateLimitState>({ init: () => ({ requests: [] }),
methods: { async check(limit: number, windowMs: number) { const now = Date.now() const windowStart = now - windowMs
// Filter to requests in window this.state.requests = this.state.requests.filter(t => t > windowStart)
if (this.state.requests.length >= limit) { return { allowed: false, remaining: 0, resetAt: this.state.requests[0] + windowMs, } }
this.state.requests.push(now)
return { allowed: true, remaining: limit - this.state.requests.length, resetAt: this.state.requests[0] + windowMs, } },
async reset() { this.state.requests = [] }, },})Collaborative Document
Section titled “Collaborative Document”// app/objects/document.tsinterface DocumentState { content: string version: number}
export default defineDurableObject<DocumentState>({ sqlite: true,
init(ctx) { ctx.sql.run(` CREATE TABLE IF NOT EXISTS history ( version INTEGER PRIMARY KEY, content TEXT, author TEXT, timestamp INTEGER ) `) return { content: '', version: 0 } },
async fetch(request) { if (request.headers.get('Upgrade') === 'websocket') { return this.handleWebSocket(request) }
if (request.method === 'GET') { return Response.json({ content: this.state.content, version: this.state.version, }) }
return new Response('Not Found', { status: 404 }) },
async webSocketMessage(ws, message) { const data = JSON.parse(message as string)
if (data.type === 'edit') { this.applyEdit(data.operations, data.author) this.broadcastState() } },
methods: { async getContent() { return { content: this.state.content, version: this.state.version, } },
async getHistory(limit = 10) { const cursor = this.ctx.sql.exec( 'SELECT * FROM history ORDER BY version DESC LIMIT ?', [limit] ) return cursor.toArray() }, },})Best Practices
Section titled “Best Practices”Error Handling
Section titled “Error Handling”import { DurableObjectNotFoundError, DurableObjectRPCError,} from '@cloudwerk/durable-object'
try { const stub = durableObjects.Counter.get(id) await stub.increment(5)} catch (error) { if (error instanceof DurableObjectRPCError) { console.error('RPC failed:', error.methodName, error.message) }}Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Storage per object | 128 KB (KV), 1 GB (SQLite) |
| Concurrent requests | Unlimited (single-threaded) |
| WebSocket connections | 32,768 per object |
| Alarm precision | ~1 second |
Next Steps
Section titled “Next Steps”- Durable Objects API Reference - Complete API documentation
- Real-time Chat Example - Full WebSocket example
- Services Guide - Service extraction patterns