计划执行与检查节点
executing-plans
按既定实施计划完成工作,保持过程检查和反馈。
(UPPER_SNAKE).\n- A key can't collide with a DO class binding (e.g. `chat-room` reserves `CHAT_ROOM`).\n- Read in code as `this.env.KEY_NAME`.\n\n---\n\n## 7. Lifecycle, listing, deletion\n\n```js\nmanage_durable_objects({ app_id, action: \"list\" })\nmanage_durable_objects({ app_id, action: \"get\", name: \"chat-room\" }) // includes full source + status + error_message\nmanage_durable_objects({ app_id, action: \"delete\", name: \"chat-room\" }) // IRREVERSIBLE: purges all instances + storage\nmanage_durable_objects({ app_id, action: \"usage\", name: \"chat-room\" }) // do_requests, do_cpu_ms (refreshed every 15 min)\n```\n\nStatus transitions: `PENDING → BUILDING → READY` or `ERROR` (with `error_message`).\n\n---\n\n## 8. Patterns\n\n### Chat room (broadcast)\n\n```typescript\nexport class ChatRoom {\n constructor(public state: DurableObjectState, public env: any) {}\n\n async fetch(req: Request): Promise\u003cResponse> {\n if (req.headers.get(\"Upgrade\") === \"websocket\") {\n const pair = new WebSocketPair();\n this.state.acceptWebSocket(pair[1]);\n const history = (await this.state.storage.get(\"messages\")) ?? [];\n pair[1].send(JSON.stringify({ type: \"init\", messages: history }));\n return new Response(null, { status: 101, webSocket: pair[0] });\n }\n return Response.json(await this.state.storage.get(\"messages\") ?? []);\n }\n\n async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {\n if (typeof msg !== \"string\") return;\n const history: any[] = (await this.state.storage.get(\"messages\")) ?? [];\n const parsed = JSON.parse(msg);\n history.push(parsed);\n await this.state.storage.put(\"messages\", history.slice(-200));\n for (const peer of this.state.getWebSockets()) {\n try { peer.send(msg); } catch {}\n }\n }\n}\n```\n\n### Sliding-window rate limiter\n\n```typescript\nexport class RateLimiter {\n constructor(public state: DurableObjectState, public env: any) {}\n\n async fetch(req: Request): Promise\u003cResponse> {\n const now = Date.now();\n const window = 60_000;\n const limit = 100;\n const requests: number[] = (await this.state.storage.get(\"requests\")) ?? [];\n const recent = requests.filter(t => now - t \u003c window);\n if (recent.length >= limit) return new Response(\"rate limit\", { status: 429 });\n recent.push(now);\n await this.state.storage.put(\"requests\", recent);\n return Response.json({ ok: true, remaining: limit - recent.length });\n }\n}\n```\n\nAddress one instance per actor: `/_do/rate-limiter/\u003cuser-id>` or `/_do/rate-limiter/\u003capi-key-hash>`.\n\n### Long-running AI agent\n\n```typescript\nexport class Agent {\n constructor(public state: DurableObjectState, public env: any) {}\n async fetch(req: Request): Promise\u003cResponse> {\n const { prompt } = await req.json();\n const r = await fetch(this.env.AI_API_ENDPOINT, {\n method: \"POST\",\n headers: { Authorization: `Bearer ${this.env.AI_API_KEY}` },\n body: JSON.stringify({ prompt })\n });\n const data = await r.json();\n await this.state.storage.put(\"last_response\", data);\n return Response.json(data);\n }\n}\n```\n\nOne DO instance per conversation; storage holds the rolling history.\n\n### Counter / leaderboard\n\nEach instance is its own counter. `/_do/leaderboard/main` and `/_do/leaderboard/season-2` have independent state — no coordination needed in v1.\n\n---\n\n## 9. Errors\n\nBuild-time (rejected on deploy):\n\n| Code | Cause |\n|------|-------|\n| `NO_EXPORTED_CLASS` | Source doesn't export a class |\n| `MULTIPLE_EXPORTS` | More than one export, or export of non-class |\n| `INVALID_IMPORT` | Imported anything other than `cloudflare:workers` |\n| `CLASS_NAME_PARSE_ERROR` | TS AST couldn't extract the class name |\n| `NAME_REGEX_VIOLATION` | `name` doesn't match `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*
durable-objects【效率与协作】Use 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
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
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).
One tool: manage_durable_objects.
Class: ChatRoom (deployed once)
│
├── instance "lobby" ─► in-memory state + state.storage + WebSockets
├── instance "general" ─► separate state, separate sockets
└── instance "user-123" ─► separate again
Each URL https://<app>.butterbase.dev/_do/chat-room/<instance-id>
gets routed to the instance with that id. State is isolated per id.
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.
import { ... } from 'cloudflare:workers' is allowed.export class Foo { ... } — no extra exports, no helpers re-exported.ChatRoom ↔ chat-room).state.storage keys/values capped at 128 KB. Larger blobs → Butterbase Storage.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.export class ChatRoom {
constructor(public state: DurableObjectState, public env: Env) {}
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]);
return new Response(null, { status: 101, webSocket: pair[0] });
}
if (req.method === "POST") {
// handle plain HTTP
}
return Response.json({ ok: true });
}
// Optional WebSocket lifecycle hooks — called by the runtime
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (typeof msg !== "string") return; // guard binary
for (const peer of this.state.getWebSockets()) {
try { peer.send(msg); } catch {}
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string, wasClean: boolean) {}
async webSocketError(ws: WebSocket, err: Error) {}
}
Key APIs:
| API | Purpose |
|---|---|
state.storage.get/put/delete/deleteAll/list | Async transactional KV store |
state.acceptWebSocket(ws) | Hold a WS connection; runtime routes messages to webSocketMessage |
state.getWebSockets() | All active WS connections for this instance |
new WebSocketPair() | Returns [client, server] — return client to browser, accept server |
this.env.KEY | Read DO env vars (set via set_env) |
manage_durable_objects({
app_id: "app_abc123",
action: "deploy",
name: "chat-room", // kebab-case URL name
code: "<single TypeScript file>",
access_mode: "authenticated" // "public" | "authenticated" (default) | "service_key"
})
// → { id, name, class_name, status: "READY", access_mode, last_deployed_at }
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).
| Mode | Auth required |
|---|---|
public | None — validate tokens inside fetch() if you need any |
authenticated (default) | End-user JWT in Authorization: Bearer <token> |
service_key | Butterbase service key — backend-to-backend |
The dispatcher only checks header shape, not validity. For real auth on production DOs, validate the token inside
fetch().
https://<your-subdomain>.butterbase.dev/_do/<name>/<instance-id>
<name> = kebab-case DO name from deploy<instance-id> = anything you choose (/lobby, /user-123, /main)Both HTTP and WebSocket upgrade work on the same URL.
// HTTP
fetch("https://app.butterbase.dev/_do/chat-room/lobby", {
method: "POST",
body: JSON.stringify({ user: "alice", text: "hi" })
});
// WebSocket
const ws = new WebSocket("wss://app.butterbase.dev/_do/chat-room/lobby");
Different instance ids → completely separate state. There is no shared global view; if you need one, build it yourself (e.g. a /registry instance that other instances report into).
Env vars are app-wide across all DO classes. Setting one redeploys the DO Worker — existing in-memory state is evicted, active WS connections drop.
manage_durable_objects({ app_id, action: "list_env" }) // keys only, never values
manage_durable_objects({ app_id, action: "set_env", key: "AI_API_KEY", value: "sk-..." })
manage_durable_objects({ app_id, action: "delete_env", key: "AI_API_KEY" })
^[A-Z_][A-Z0-9_]*$ (UPPER_SNAKE).chat-room reserves CHAT_ROOM).this.env.KEY_NAME.manage_durable_objects({ app_id, action: "list" })
manage_durable_objects({ app_id, action: "get", name: "chat-room" }) // includes full source + status + error_message
manage_durable_objects({ app_id, action: "delete", name: "chat-room" }) // IRREVERSIBLE: purges all instances + storage
manage_durable_objects({ app_id, action: "usage", name: "chat-room" }) // do_requests, do_cpu_ms (refreshed every 15 min)
Status transitions: PENDING → BUILDING → READY or ERROR (with error_message).
export class ChatRoom {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
if (req.headers.get("Upgrade") === "websocket") {
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]);
const history = (await this.state.storage.get("messages")) ?? [];
pair[1].send(JSON.stringify({ type: "init", messages: history }));
return new Response(null, { status: 101, webSocket: pair[0] });
}
return Response.json(await this.state.storage.get("messages") ?? []);
}
async webSocketMessage(ws: WebSocket, msg: string | ArrayBuffer) {
if (typeof msg !== "string") return;
const history: any[] = (await this.state.storage.get("messages")) ?? [];
const parsed = JSON.parse(msg);
history.push(parsed);
await this.state.storage.put("messages", history.slice(-200));
for (const peer of this.state.getWebSockets()) {
try { peer.send(msg); } catch {}
}
}
}
export class RateLimiter {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
const now = Date.now();
const window = 60_000;
const limit = 100;
const requests: number[] = (await this.state.storage.get("requests")) ?? [];
const recent = requests.filter(t => now - t < window);
if (recent.length >= limit) return new Response("rate limit", { status: 429 });
recent.push(now);
await this.state.storage.put("requests", recent);
return Response.json({ ok: true, remaining: limit - recent.length });
}
}
Address one instance per actor: /_do/rate-limiter/<user-id> or /_do/rate-limiter/<api-key-hash>.
export class Agent {
constructor(public state: DurableObjectState, public env: any) {}
async fetch(req: Request): Promise<Response> {
const { prompt } = await req.json();
const r = await fetch(this.env.AI_API_ENDPOINT, {
method: "POST",
headers: { Authorization: `Bearer ${this.env.AI_API_KEY}` },
body: JSON.stringify({ prompt })
});
const data = await r.json();
await this.state.storage.put("last_response", data);
return Response.json(data);
}
}
One DO instance per conversation; storage holds the rolling history.
Each instance is its own counter. /_do/leaderboard/main and /_do/leaderboard/season-2 have independent state — no coordination needed in v1.
Build-time (rejected on deploy):
| Code | Cause |
|---|---|
NO_EXPORTED_CLASS | Source doesn't export a class |
MULTIPLE_EXPORTS | More than one export, or export of non-class |
INVALID_IMPORT | Imported anything other than cloudflare:workers |
CLASS_NAME_PARSE_ERROR | TS AST couldn't extract the class name |
NAME_REGEX_VIOLATION | name doesn't match ^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$ |
QUOTA_DO_LIMIT | Already 5 classes for this app |
BUNDLE_SIZE_EXCEEDED / SOURCE_SIZE_EXCEEDED | Over the 10 MB / 5 MB limits |
Runtime / async-deploy:
ERROR after a deploy → check manage_durable_objects (get) error_message.state.acceptWebSocket(ws) — easy to forget.webSocketMessage receives string | ArrayBuffer. Always guard before JSON.parse.| Don't | Do |
|---|---|
| Try to share state between instances directly | Pick a single "registry" instance and have others fetch into it |
| Use a DO for stateless HTTP work | Use a function — DOs cost more and have stricter constraints |
| Rely on dispatcher access_mode for real auth | Validate JWTs inside fetch() for production |
Use access_mode: "authenticated" for browser WebSockets | Use public + token-in-query-string + manual validation; browsers can't set headers |
Stuff > 128 KB blobs into state.storage | Use Butterbase Storage and store the object_id in DO state |
| Update env vars in tight loops | Each set_env redeploys the Worker — drops connections |
| Forget redeploy semantics | Code change or env change evicts all instances; storage survives but in-memory caches don't |
If a docs/butterbase/00-state.md exists in the working directory, prefer invoking via /butterbase-skills:journey-durable so the journey orchestrator stays in sync.