Durable Objects
Build RPC-style Durable Objects with auto-discovery
Durable Objects
Koze auto-discovers .do.ts files in src/server/. Public methods become RPC-accessible automatically.
// src/server/user.do.ts
import { DurableObject } from 'cloudflare:workers';
export default class UserDO extends DurableObject {
async getName() {
return await this.ctx.storage.get('name');
}
async setName(name: string) {
if (!name) throw new Error('Name required');
await this.ctx.storage.put('name', name);
}
// Underscore-prefixed methods are not exposed
_validate() {}
}
Naming Convention
Binding names are derived from the filename:
| File | Binding |
|---|---|
user.do.ts |
USER_DO |
org-settings.do.ts |
ORG_SETTINGS_DO |
chat-room.do.ts |
CHAT_ROOM_DO |
RPC Rules
- Public methods are callable through generated proxies
- Underscore-prefixed methods (
_validate,_internal) are not exposed - Lifecycle methods like
fetch()andalarm()are not treated as RPC methods
Route Usage
Import directly from the .do.ts file using the $server/ alias:
<script>
import { getName, setName } from '$server/user.do';
const name = await getName();
</script>
<h1>Hello, {name}</h1>
<form action={setName} method="POST">
<input type="text" name="name" required />
<button type="submit">Update</button>
</form>
Validating RPC Input
Attach schemas by method name with static schemas. Koze validates input before invoking the method.
import { DurableObject } from 'cloudflare:workers';
import { schema, type InferSchema } from '@kuratchi/koze';
export default class UserDO extends DurableObject {
static schemas = {
setProfile: schema({
name: schema.string().min(1),
likesDogs: schema.boolean().optional(false),
}),
};
async setProfile(data: InferSchema<(typeof UserDO.schemas).setProfile>) {
await this.ctx.storage.put('profile', data);
}
}
Rules:
- Schema-backed RPC methods accept one object argument
static schemaskeys must match the public method names exactly- Invalid payloads return
400 - Use
InferSchema<(typeof MyDO.schemas).methodName>for typed parameters
Schema Examples
With defaults and arrays:
import { DurableObject } from 'cloudflare:workers';
import { schema, type InferSchema } from '@kuratchi/koze';
export default class UserDO extends DurableObject {
static schemas = {
savePreferences: schema({
tags: schema.string().list(),
marketingEmails: schema.boolean().optional(false),
}),
};
async savePreferences(data: InferSchema<(typeof UserDO.schemas).savePreferences>) {
await this.ctx.storage.put('preferences', data);
return { ok: true };
}
}
Stub Resolution
By default, the framework's auto-discovered DOs resolve via idFromName('global') — every binding points to a singleton instance per worker. Apps that need per-user / per-tenant routing register a custom resolver at runtime:
// src/server/do-routing.ts
import { __registerDoResolver } from '@kuratchi/koze/runtime/do.js';
import { getCurrentUser } from '@kuratchi/kyzen';
import { env } from 'cloudflare:workers';
__registerDoResolver('USER_DO', async () => {
const user = await getCurrentUser();
if (!user?.organizationId) return null;
const ns = (env as any).USER_DO;
return ns.get(ns.idFromName(user.organizationId));
});
Import the routing module from src/middleware.ts (any side-effect import works) so the resolver registers before the first request:
import './server/do-routing';
For multi-tenant org databases specifically, kyzen ships getOrgClient(organizationId) (resolves the routing key from the admin DB then returns a stub) and getOrgStubByName(doName) (sync; pass the routing key directly). The auth package uses getOrgStubByName internally during signin/signup, so apps usually just call getCurrentUser() and let the package handle routing — see Organizations and Schema for the full pattern.
Storage API
Use the Durable Object storage API for persistence:
export default class CounterDO extends DurableObject {
async increment() {
const current = (await this.ctx.storage.get('count')) || 0;
await this.ctx.storage.put('count', current + 1);
return current + 1;
}
async getCount() {
return (await this.ctx.storage.get('count')) || 0;
}
async reset() {
await this.ctx.storage.delete('count');
}
}
Alarms
Schedule future work with alarms:
export default class ReminderDO extends DurableObject {
async scheduleReminder(delayMs: number) {
const alarmTime = Date.now() + delayMs;
await this.ctx.storage.setAlarm(alarmTime);
return { scheduled: new Date(alarmTime).toISOString() };
}
async alarm() {
// Called when the alarm fires
await this.sendReminder();
}
}
WebSockets
Handle WebSocket connections in Durable Objects:
export default class ChatRoomDO extends DurableObject {
async fetch(request: Request) {
const upgradeHeader = request.headers.get('Upgrade');
if (upgradeHeader === 'websocket') {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
return new Response('Expected WebSocket', { status: 400 });
}
async webSocketMessage(ws: WebSocket, message: string) {
// Broadcast to all connected clients
for (const client of this.ctx.getWebSockets()) {
client.send(message);
}
}
}
Wrangler Configuration
The framework auto-syncs Durable Object bindings to wrangler.jsonc:
{
"durable_objects": {
"bindings": [
{
"name": "USER_DO",
"class_name": "UserDO"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": ["UserDO"]
}
]
}