Skip to content

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.

  1. Create a Durable Object:

    // app/objects/counter.ts
    import { defineDurableObject } from '@cloudwerk/durable-object'
    interface CounterState {
    value: number
    }
    export default defineDurableObject<CounterState>({
    init: () => ({ value: 0 }),
    methods: {
    async increment(amount = 1) {
    this.state.value += amount
    return this.state.value
    },
    async getValue() {
    return this.state.value
    },
    },
    })
  2. Use it from your routes:

    // app/api/counter/[id]/route.ts
    import { 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 overhead
    const newValue = await stub.increment(5)
    return json({ value: newValue })
    }
  3. Durable Objects are automatically discovered and registered during build.

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.ts becomes class Counter, binding COUNTER
  • chat-room.ts becomes class ChatRoom, binding CHAT_ROOM
// app/objects/counter.ts
import { 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
},
},
})

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
},
},
})

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 }
},
},
})
// From route handlers
import { 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 })
}

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)
}
},
},
})
// Get single value
const value = await this.ctx.storage.get<MyType>('key')
// Get multiple values
const map = await this.ctx.storage.get(['key1', 'key2'])
// List keys with prefix
const entries = await this.ctx.storage.list({
prefix: 'user:',
limit: 100,
reverse: false,
})
// Put single value
await this.ctx.storage.put('key', value)
// Put multiple values
await this.ctx.storage.put({
key1: value1,
key2: value2,
})
// Delete
await this.ctx.storage.delete('key')
await this.ctx.storage.deleteAll()

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)
})
},
}

Enable SQLite for relational data:

// app/objects/analytics.ts
export 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()
},
},
})

Build real-time applications with WebSocket support:

// app/objects/chat-room.ts
import { 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)
},
},
})
// Accept a WebSocket connection
this.ctx.acceptWebSocket(ws, ['tag1', 'tag2'])
// Get all connected WebSockets
const all = this.ctx.getWebSockets()
// Get WebSockets by tag
const admins = this.ctx.getWebSockets('admin')
// Get tags for a WebSocket
const tags = this.ctx.getTags(ws)

Schedule periodic tasks with alarms:

// app/objects/session.ts
export 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
},
},
})
// Set alarm (replaces existing)
await this.ctx.storage.setAlarm(Date.now() + 60000) // 1 minute from now
await this.ctx.storage.setAlarm(new Date('2024-12-31'))
// Get current alarm
const alarmTime = await this.ctx.storage.getAlarm()
// Cancel alarm
await this.ctx.storage.deleteAlarm()

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
},
},
})
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()
import { durableObjects } from '@cloudwerk/core/bindings'
// From a name (deterministic)
const id = durableObjects.Counter.idFromName('user-123-cart')
// From a string ID
const id = durableObjects.Counter.idFromString('some-id-string')
// Generate unique ID
const id = durableObjects.Counter.newUniqueId()
// Get stub
const stub = durableObjects.Counter.get(id)
const id = durableObjects.Counter.idFromName('my-counter')
id.toString() // String representation
id.name // 'my-counter' (if created with idFromName)
id.equals(otherId) // Compare IDs
// app/objects/rate-limiter.ts
interface 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 = []
},
},
})
// app/objects/document.ts
interface 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()
},
},
})
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)
}
}
LimitValue
Storage per object128 KB (KV), 1 GB (SQLite)
Concurrent requestsUnlimited (single-threaded)
WebSocket connections32,768 per object
Alarm precision~1 second