Durable Objects | Koze | Primitives Docs

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() and alarm() 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 schemas keys 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"]
    }
  ]
}