$npx -y skills add butterbase-ai/butterbase-skills --skill durable-objectsUse when building stateful per-key actors — chat rooms, multiplayer rooms, rate limiters, long-running agents, leaderboards — that need persistent in-memory + storage state across requests
| 1 | # Butterbase Durable Objects |
| 2 | |
| 3 | Durable Objects (DOs) are **stateful per-key actors** running on Cloudflare Workers. Each instance has its own in-memory state and a built-in transactional KV store. Use one when state must survive across requests for a single room/user/agent. For stateless work, use a serverless function instead (`butterbase-skills:function-dev`). |
| 4 | |
| 5 | One tool: **`manage_durable_objects`**. |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## 1. The mental model |
| 10 | |
| 11 | ``` |
| 12 | Class: ChatRoom (deployed once) |
| 13 | │ |
| 14 | ├── instance "lobby" ─► in-memory state + state.storage + WebSockets |
| 15 | ├── instance "general" ─► separate state, separate sockets |
| 16 | └── instance "user-123" ─► separate again |
| 17 | |
| 18 | Each URL https://<app>.butterbase.dev/_do/chat-room/<instance-id> |
| 19 | gets routed to the instance with that id. State is isolated per id. |
| 20 | ``` |
| 21 | |
| 22 | A class is shared code; an **instance** is a unique key (`/lobby`, `/general`, `/user-123`). Different ids = different state. There is no shared cross-instance state. |
| 23 | |
| 24 | --- |
| 25 | |
| 26 | ## 2. Constraints (read these first) |
| 27 | |
| 28 | - **One TypeScript file per class.** No npm imports. Only `import { ... } from 'cloudflare:workers'` is allowed. |
| 29 | - **Exactly one exported class.** `export class Foo { ... }` — no extra exports, no helpers re-exported. |
| 30 | - **PascalCase class name** in source; **kebab-case** for the URL name (e.g. `ChatRoom` ↔ `chat-room`). |
| 31 | - File size: ≤ 5 MB. Total of all DO classes per app: ≤ 10 MB compressed. |
| 32 | - ≤ 5 DO classes per app (v1). |
| 33 | - **No service bindings yet.** Functions reach DOs over HTTP, not via env binding. |
| 34 | - `state.storage` keys/values capped at 128 KB. Larger blobs → Butterbase Storage. |
| 35 | - **Browser WebSockets need `access_mode: "public"`.** Browsers can't set custom headers on WS upgrade, and the `_do/` dispatcher reads auth *only* from `Authorization` — `?token=` and `Sec-WebSocket-Protocol` are silently ignored at the dispatcher (unlike the `/realtime` route, which does accept `?token=`). Set the DO public, then read the token from `?token=` (or `Sec-WebSocket-Protocol`) inside `fetch()` and verify it yourself before accepting the upgrade. Server-to-server callers can still use `authenticated`/`service_key`. |
| 36 | |
| 37 | --- |
| 38 | |
| 39 | ## 3. The class skeleton |
| 40 | |
| 41 | ```typescript |
| 42 | export class ChatRoom { |
| 43 | constructor(public state: DurableObjectState, public env: Env) {} |
| 44 | |
| 45 | async fetch(req: Request): Promise<Response> { |
| 46 | if (req.headers.get("Upgrade") === "websocket") { |
| 47 | const pair = new WebSocketPair(); |
| 48 | this.state.acceptWebSocket(pair[1]); |
| 49 | return new Response(null, { status: 101, webSocket: pair[0] }); |
| 50 | } |
| 51 | |
| 52 | if (req.method === "POST") { |
| 53 | // handle plain HTTP |
| 54 | } |
| 55 | |
| 56 | return Response.json({ ok: true }); |
| 57 | } |
| 58 | |
| 59 | // Optional WebSocket lifecycle hooks — called by the runtime |
| 60 | async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) { |
| 61 | if (typeof msg !== "string") return; // guard binary |
| 62 | for (const peer of this.state.getWebSockets()) { |
| 63 | try { peer.send(msg); } catch {} |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {} |
| 68 | async webSocketError(ws: WebSocket, err: Error) {} |
| 69 | } |
| 70 | ``` |
| 71 | |
| 72 | Key APIs: |
| 73 | |
| 74 | | API | Purpose | |
| 75 | |-----|---------| |
| 76 | | `state.storage.get/put/delete/deleteAll/list` | Async transactional KV store | |
| 77 | | `state.acceptWebSocket(ws)` | Hold a WS connection; runtime routes messages to `webSocketMessage` | |
| 78 | | `state.getWebSockets()` | All active WS connections for this instance | |
| 79 | | `new WebSocketPair()` | Returns `[client, server]` — return `client` to browser, accept `server` | |
| 80 | | `this.env.KEY` | Read DO env vars (set via `set_env`) | |
| 81 | |
| 82 | --- |
| 83 | |
| 84 | ## 4. Deploy |
| 85 | |
| 86 | ```js |
| 87 | manage_durable_objects({ |
| 88 | app_id: "app_abc123", |
| 89 | action: "deploy", |
| 90 | name: "chat-room", // kebab-case URL name |
| 91 | code: "<single TypeScript file>", |
| 92 | access_mode: "authenticated" // "public" | "authenticated" (default) | "service_key" |
| 93 | }) |
| 94 | // → { id, name, class_name, status: "READY", access_mode, last_deployed_at } |
| 95 | ``` |
| 96 | |
| 97 | Re-deploying with the same `name` updates the class; old in-memory state is evicted on next request. Storage persists across redeploys (same instance id = same `state.storage`). |
| 98 | |
| 99 | ### Access modes |
| 100 | |
| 101 | | Mode | Auth required | |
| 102 | |------|---------------| |
| 103 | | `public` | None — validate tokens inside `fetch()` if you need any | |
| 104 | | `authenticated` (default) | End-user JWT in `Authorization: Bearer <token>` | |
| 105 | | `service_key` | Butterbase service key — backend-to-backend | |
| 106 | |
| 107 | > The dispatcher only checks header **shape**, not validity. For real auth on production DOs, validate the token inside `fetch()`. |
| 108 | |
| 109 | --- |
| 110 | |
| 111 | ## 5. Address an instance |
| 112 | |
| 113 | ``` |
| 114 | https://<your-subdomain>.butterbase.dev/_do/<name>/<instance-id> |
| 115 | ``` |
| 116 | |
| 117 | - `<name>` = kebab-case DO name from deploy |
| 118 | - `<instance-id>` = anything you choose (`/lobby`, `/user-123`, `/mai |