@prmichaelsen/acp-visualizer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/agent/commands/acp.clarification-address.md +417 -0
- package/agent/commands/acp.clarification-capture.md +386 -0
- package/agent/commands/acp.clarification-create.md +437 -0
- package/agent/commands/acp.clarifications-research.md +326 -0
- package/agent/commands/acp.command-create.md +432 -0
- package/agent/commands/acp.design-create.md +286 -0
- package/agent/commands/acp.design-reference.md +355 -0
- package/agent/commands/acp.handoff.md +270 -0
- package/agent/commands/acp.index.md +423 -0
- package/agent/commands/acp.init.md +546 -0
- package/agent/commands/acp.package-create.md +895 -0
- package/agent/commands/acp.package-info.md +212 -0
- package/agent/commands/acp.package-install.md +539 -0
- package/agent/commands/acp.package-list.md +280 -0
- package/agent/commands/acp.package-publish.md +541 -0
- package/agent/commands/acp.package-remove.md +293 -0
- package/agent/commands/acp.package-search.md +307 -0
- package/agent/commands/acp.package-update.md +361 -0
- package/agent/commands/acp.package-validate.md +540 -0
- package/agent/commands/acp.pattern-create.md +386 -0
- package/agent/commands/acp.plan.md +587 -0
- package/agent/commands/acp.proceed.md +882 -0
- package/agent/commands/acp.project-create.md +675 -0
- package/agent/commands/acp.project-info.md +312 -0
- package/agent/commands/acp.project-list.md +226 -0
- package/agent/commands/acp.project-remove.md +379 -0
- package/agent/commands/acp.project-set.md +227 -0
- package/agent/commands/acp.project-update.md +307 -0
- package/agent/commands/acp.projects-restore.md +228 -0
- package/agent/commands/acp.projects-sync.md +347 -0
- package/agent/commands/acp.report.md +407 -0
- package/agent/commands/acp.resume.md +239 -0
- package/agent/commands/acp.sessions.md +301 -0
- package/agent/commands/acp.status.md +293 -0
- package/agent/commands/acp.sync.md +364 -0
- package/agent/commands/acp.task-create.md +500 -0
- package/agent/commands/acp.update.md +302 -0
- package/agent/commands/acp.validate.md +466 -0
- package/agent/commands/acp.version-check-for-updates.md +276 -0
- package/agent/commands/acp.version-check.md +191 -0
- package/agent/commands/acp.version-update.md +289 -0
- package/agent/commands/command.template.md +339 -0
- package/agent/commands/git.commit.md +526 -0
- package/agent/commands/git.init.md +514 -0
- package/agent/commands/tanstack-cloudflare.deploy.md +272 -0
- package/agent/commands/tanstack-cloudflare.tail.md +275 -0
- package/agent/design/.gitkeep +0 -0
- package/agent/design/design.template.md +154 -0
- package/agent/design/local.dashboard-layout-routing.md +288 -0
- package/agent/design/local.data-model-yaml-parsing.md +310 -0
- package/agent/design/local.search-filtering.md +331 -0
- package/agent/design/local.server-api-auto-refresh.md +235 -0
- package/agent/design/local.table-tree-views.md +299 -0
- package/agent/design/local.visualizer-requirements.md +349 -0
- package/agent/design/requirements.template.md +387 -0
- package/agent/index/.gitkeep +0 -0
- package/agent/index/acp.core.yaml +137 -0
- package/agent/index/local.main.template.yaml +37 -0
- package/agent/manifest.template.yaml +13 -0
- package/agent/manifest.yaml +302 -0
- package/agent/milestones/.gitkeep +0 -0
- package/agent/milestones/milestone-1-project-scaffold-data-pipeline.md +67 -0
- package/agent/milestones/milestone-1-{title}.template.md +206 -0
- package/agent/milestones/milestone-2-dashboard-views-interaction.md +79 -0
- package/agent/package.template.yaml +86 -0
- package/agent/patterns/.gitkeep +0 -0
- package/agent/patterns/bootstrap.template.md +1237 -0
- package/agent/patterns/pattern.template.md +382 -0
- package/agent/patterns/tanstack-cloudflare.acl-permissions.md +332 -0
- package/agent/patterns/tanstack-cloudflare.action-bar-item.md +416 -0
- package/agent/patterns/tanstack-cloudflare.api-route-handlers.md +401 -0
- package/agent/patterns/tanstack-cloudflare.auth-session-management.md +387 -0
- package/agent/patterns/tanstack-cloudflare.card-and-list.md +271 -0
- package/agent/patterns/tanstack-cloudflare.chat-engine.md +353 -0
- package/agent/patterns/tanstack-cloudflare.confirmation-tokens.md +346 -0
- package/agent/patterns/tanstack-cloudflare.durable-objects-websocket.md +516 -0
- package/agent/patterns/tanstack-cloudflare.email-service.md +431 -0
- package/agent/patterns/tanstack-cloudflare.expander.md +98 -0
- package/agent/patterns/tanstack-cloudflare.fcm-push.md +115 -0
- package/agent/patterns/tanstack-cloudflare.firebase-anonymous-sessions.md +441 -0
- package/agent/patterns/tanstack-cloudflare.firebase-auth.md +348 -0
- package/agent/patterns/tanstack-cloudflare.firebase-firestore.md +550 -0
- package/agent/patterns/tanstack-cloudflare.firebase-storage.md +369 -0
- package/agent/patterns/tanstack-cloudflare.form-controls.md +145 -0
- package/agent/patterns/tanstack-cloudflare.global-search-context.md +93 -0
- package/agent/patterns/tanstack-cloudflare.image-carousel.md +126 -0
- package/agent/patterns/tanstack-cloudflare.library-services.md +553 -0
- package/agent/patterns/tanstack-cloudflare.lightbox.md +169 -0
- package/agent/patterns/tanstack-cloudflare.markdown-content.md +115 -0
- package/agent/patterns/tanstack-cloudflare.mention-suggestions.md +98 -0
- package/agent/patterns/tanstack-cloudflare.modal.md +156 -0
- package/agent/patterns/tanstack-cloudflare.nextjs-to-tanstack-routing.md +461 -0
- package/agent/patterns/tanstack-cloudflare.notifications-engine.md +151 -0
- package/agent/patterns/tanstack-cloudflare.oauth-token-refresh.md +90 -0
- package/agent/patterns/tanstack-cloudflare.og-metadata.md +296 -0
- package/agent/patterns/tanstack-cloudflare.pagination.md +442 -0
- package/agent/patterns/tanstack-cloudflare.pill-input.md +220 -0
- package/agent/patterns/tanstack-cloudflare.provider-adapter.md +401 -0
- package/agent/patterns/tanstack-cloudflare.rate-limiting.md +323 -0
- package/agent/patterns/tanstack-cloudflare.scheduled-tasks.md +338 -0
- package/agent/patterns/tanstack-cloudflare.searchable-settings.md +375 -0
- package/agent/patterns/tanstack-cloudflare.slide-over.md +129 -0
- package/agent/patterns/tanstack-cloudflare.ssr-preload.md +571 -0
- package/agent/patterns/tanstack-cloudflare.third-party-api-integration.md +508 -0
- package/agent/patterns/tanstack-cloudflare.toast-system.md +142 -0
- package/agent/patterns/tanstack-cloudflare.unified-header.md +280 -0
- package/agent/patterns/tanstack-cloudflare.user-scoped-collections.md +628 -0
- package/agent/patterns/tanstack-cloudflare.websocket-manager.md +237 -0
- package/agent/patterns/tanstack-cloudflare.wrangler-configuration.md +358 -0
- package/agent/patterns/tanstack-cloudflare.zod-schema-validation.md +336 -0
- package/agent/progress.template.yaml +161 -0
- package/agent/progress.yaml +145 -0
- package/agent/schemas/package.schema.yaml +276 -0
- package/agent/scripts/acp.common.sh +1781 -0
- package/agent/scripts/acp.install.sh +333 -0
- package/agent/scripts/acp.package-create.sh +924 -0
- package/agent/scripts/acp.package-info.sh +288 -0
- package/agent/scripts/acp.package-install.sh +893 -0
- package/agent/scripts/acp.package-list.sh +311 -0
- package/agent/scripts/acp.package-publish.sh +420 -0
- package/agent/scripts/acp.package-remove.sh +348 -0
- package/agent/scripts/acp.package-search.sh +156 -0
- package/agent/scripts/acp.package-update.sh +517 -0
- package/agent/scripts/acp.package-validate.sh +1018 -0
- package/agent/scripts/acp.uninstall.sh +85 -0
- package/agent/scripts/acp.version-check-for-updates.sh +98 -0
- package/agent/scripts/acp.version-check.sh +47 -0
- package/agent/scripts/acp.version-update.sh +176 -0
- package/agent/scripts/acp.yaml-parser.sh +985 -0
- package/agent/scripts/acp.yaml-validate.sh +205 -0
- package/agent/tasks/.gitkeep +0 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-1-initialize-tanstack-start-project.md +210 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-2-implement-data-model-yaml-parser.md +294 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-3-build-server-api-data-loading.md +193 -0
- package/agent/tasks/milestone-1-project-scaffold-data-pipeline/task-4-add-auto-refresh-sse.md +262 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-10-polish-integration-testing.md +156 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-5-build-dashboard-layout-routing.md +178 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-6-build-overview-page.md +141 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-7-implement-milestone-table-view.md +153 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-8-implement-milestone-tree-view.md +174 -0
- package/agent/tasks/milestone-2-dashboard-views-interaction/task-9-implement-search-filtering.md +233 -0
- package/agent/tasks/task-1-{title}.template.md +244 -0
- package/bin/visualize.mjs +84 -0
- package/package.json +48 -0
- package/src/components/ExtraFieldsBadge.tsx +15 -0
- package/src/components/FilterBar.tsx +33 -0
- package/src/components/Header.tsx +23 -0
- package/src/components/MilestoneTable.tsx +167 -0
- package/src/components/MilestoneTree.tsx +84 -0
- package/src/components/ProgressBar.tsx +20 -0
- package/src/components/SearchInput.tsx +22 -0
- package/src/components/Sidebar.tsx +54 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/StatusDot.tsx +12 -0
- package/src/components/TaskList.tsx +36 -0
- package/src/components/ViewToggle.tsx +31 -0
- package/src/lib/config.ts +8 -0
- package/src/lib/file-watcher.ts +43 -0
- package/src/lib/search.ts +48 -0
- package/src/lib/types.ts +73 -0
- package/src/lib/useAutoRefresh.ts +31 -0
- package/src/lib/useCollapse.ts +31 -0
- package/src/lib/useFilteredData.ts +55 -0
- package/src/lib/yaml-loader-real.spec.ts +47 -0
- package/src/lib/yaml-loader.spec.ts +201 -0
- package/src/lib/yaml-loader.ts +265 -0
- package/src/routeTree.gen.ts +140 -0
- package/src/router.tsx +10 -0
- package/src/routes/__root.tsx +75 -0
- package/src/routes/api/watch.ts +29 -0
- package/src/routes/index.tsx +115 -0
- package/src/routes/milestones.tsx +50 -0
- package/src/routes/search.tsx +84 -0
- package/src/routes/tasks.tsx +63 -0
- package/src/services/progress-database.service.ts +46 -0
- package/src/styles.css +25 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +16 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# Durable Objects WebSocket Pattern
|
|
2
|
+
|
|
3
|
+
**Category**: Architecture
|
|
4
|
+
**Applicable To**: TanStack Start + Cloudflare Workers applications requiring real-time communication
|
|
5
|
+
**Status**: Stable
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Overview
|
|
10
|
+
|
|
11
|
+
Cloudflare Durable Objects provide stateful, long-lived server instances that are ideal for WebSocket-based real-time features. This pattern demonstrates how to build a Durable Object that accepts WebSocket connections, processes typed messages, and delegates business logic to an injected engine using dependency injection.
|
|
12
|
+
|
|
13
|
+
The Durable Object acts as a thin coordination layer — it manages WebSocket lifecycle (accept, message routing, close) while delegating all domain logic to portable engine classes. This separation enables the business logic to be tested independently and potentially extracted into reusable packages.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## When to Use This Pattern
|
|
18
|
+
|
|
19
|
+
✅ **Use this pattern when:**
|
|
20
|
+
- Building real-time features (chat, collaboration, live updates)
|
|
21
|
+
- Need persistent server-side state across requests
|
|
22
|
+
- Want WebSocket connections managed at the edge
|
|
23
|
+
- Need to coordinate between multiple connected clients
|
|
24
|
+
- Building features that require streaming responses (AI chat, file upload progress)
|
|
25
|
+
|
|
26
|
+
❌ **Don't use this pattern when:**
|
|
27
|
+
- Simple request/response patterns suffice (use API routes instead)
|
|
28
|
+
- No real-time requirement (polling or SSR preloading is simpler)
|
|
29
|
+
- Stateless operations (use regular Workers)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Core Principles
|
|
34
|
+
|
|
35
|
+
1. **Thin Wrapper**: The Durable Object is a thin coordination layer — it manages WebSocket lifecycle and delegates business logic to injected engines
|
|
36
|
+
2. **Dependency Injection**: All providers (AI, storage, etc.) are injected into the engine at construction time
|
|
37
|
+
3. **Typed Messages**: Both client and server messages use TypeScript interfaces with discriminated `type` fields
|
|
38
|
+
4. **Persistent Provider Instances**: Provider instances are created once in the constructor and reused across messages for caching benefits
|
|
39
|
+
5. **Graceful Error Handling**: Errors are caught and sent back to the client as error messages, never crashing the Durable Object
|
|
40
|
+
6. **WebSocket Pair Pattern**: Use Cloudflare's `WebSocketPair` API for server-accepted WebSocket connections
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Implementation
|
|
45
|
+
|
|
46
|
+
### Structure
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/
|
|
50
|
+
├── durable-objects/
|
|
51
|
+
│ ├── ChatRoom.ts # Durable Object (thin wrapper)
|
|
52
|
+
│ └── UploadManager.ts # Another Durable Object example
|
|
53
|
+
├── lib/
|
|
54
|
+
│ ├── chat/
|
|
55
|
+
│ │ ├── chat-engine.ts # Core business logic (portable)
|
|
56
|
+
│ │ └── interfaces/ # Provider interfaces
|
|
57
|
+
│ └── chat-providers/
|
|
58
|
+
│ ├── ai-provider.ts # AI provider implementation
|
|
59
|
+
│ └── storage-provider.ts # Storage provider implementation
|
|
60
|
+
└── routes/
|
|
61
|
+
└── api/
|
|
62
|
+
└── chat-ws.tsx # WebSocket upgrade endpoint
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Code Example
|
|
66
|
+
|
|
67
|
+
#### Step 1: Define Typed Messages
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// src/durable-objects/types.ts
|
|
71
|
+
|
|
72
|
+
/** Messages sent from client → server */
|
|
73
|
+
interface ClientMessage {
|
|
74
|
+
type: 'message' | 'load_messages' | 'init'
|
|
75
|
+
userId: string
|
|
76
|
+
conversationId?: string
|
|
77
|
+
message?: MessageContent
|
|
78
|
+
limit?: number
|
|
79
|
+
startAfter?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Messages sent from server → client */
|
|
83
|
+
interface ServerMessage {
|
|
84
|
+
type: 'chunk' | 'complete' | 'error' | 'message' | 'messages_loaded' | 'ready'
|
|
85
|
+
content?: string
|
|
86
|
+
error?: string
|
|
87
|
+
message?: any
|
|
88
|
+
messages?: any[]
|
|
89
|
+
hasMore?: boolean
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
#### Step 2: Create the Durable Object
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
// src/durable-objects/ChatRoom.ts
|
|
97
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
98
|
+
import { ChatEngine } from '@/lib/chat'
|
|
99
|
+
import { BedrockAIProvider, FirebaseStorageProvider } from '@/lib/chat-providers'
|
|
100
|
+
import { chatLogger } from '@/lib/logger'
|
|
101
|
+
|
|
102
|
+
export class ChatRoom extends DurableObject {
|
|
103
|
+
private sessions: Set<WebSocket>
|
|
104
|
+
private chatEngine: ChatEngine
|
|
105
|
+
|
|
106
|
+
constructor(state: DurableObjectState, env: Env) {
|
|
107
|
+
super(state, env)
|
|
108
|
+
this.sessions = new Set()
|
|
109
|
+
|
|
110
|
+
// Inject dependencies into engine (created once, reused across messages)
|
|
111
|
+
this.chatEngine = new ChatEngine({
|
|
112
|
+
aiProvider: new BedrockAIProvider(),
|
|
113
|
+
storageProvider: new FirebaseStorageProvider(),
|
|
114
|
+
logger: chatLogger
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async fetch(request: Request): Promise<Response> {
|
|
119
|
+
// Verify WebSocket upgrade
|
|
120
|
+
const upgradeHeader = request.headers.get('Upgrade')
|
|
121
|
+
if (upgradeHeader !== 'websocket') {
|
|
122
|
+
return new Response('Expected WebSocket', { status: 426 })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create WebSocket pair
|
|
126
|
+
const pair = new WebSocketPair()
|
|
127
|
+
const [client, server] = Object.values(pair)
|
|
128
|
+
|
|
129
|
+
// Accept and track connection
|
|
130
|
+
server.accept()
|
|
131
|
+
this.sessions.add(server)
|
|
132
|
+
|
|
133
|
+
// Handle messages
|
|
134
|
+
server.addEventListener('message', async (event) => {
|
|
135
|
+
try {
|
|
136
|
+
const data = JSON.parse(event.data as string) as ClientMessage
|
|
137
|
+
|
|
138
|
+
switch (data.type) {
|
|
139
|
+
case 'init':
|
|
140
|
+
this.sendMessage(server, { type: 'ready' })
|
|
141
|
+
break
|
|
142
|
+
case 'message':
|
|
143
|
+
await this.handleMessage(data, server)
|
|
144
|
+
break
|
|
145
|
+
case 'load_messages':
|
|
146
|
+
await this.handleLoadMessages(data, server)
|
|
147
|
+
break
|
|
148
|
+
default:
|
|
149
|
+
chatLogger.warn('Unknown message type', { type: (data as any).type })
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.sendMessage(server, {
|
|
153
|
+
type: 'error',
|
|
154
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// Handle close
|
|
160
|
+
server.addEventListener('close', () => {
|
|
161
|
+
this.sessions.delete(server)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private async handleMessage(data: ClientMessage, socket: WebSocket): Promise<void> {
|
|
168
|
+
const { userId, conversationId = 'main', message } = data
|
|
169
|
+
|
|
170
|
+
if (!message) {
|
|
171
|
+
this.sendMessage(socket, { type: 'error', error: 'No message provided' })
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Delegate to engine with streaming callback
|
|
176
|
+
await this.chatEngine.processMessage({
|
|
177
|
+
userId,
|
|
178
|
+
conversationId,
|
|
179
|
+
message,
|
|
180
|
+
onMessage: (msg) => {
|
|
181
|
+
this.sendMessage(socket, msg)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async handleLoadMessages(data: ClientMessage, socket: WebSocket): Promise<void> {
|
|
187
|
+
const { userId, conversationId = 'main', limit = 50, startAfter } = data
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const messages = await this.chatEngine.loadMessages({
|
|
191
|
+
userId, conversationId, limit, startAfter
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
this.sendMessage(socket, {
|
|
195
|
+
type: 'messages_loaded',
|
|
196
|
+
messages,
|
|
197
|
+
hasMore: messages.length === limit
|
|
198
|
+
})
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.sendMessage(socket, {
|
|
201
|
+
type: 'error',
|
|
202
|
+
error: error instanceof Error ? error.message : 'Failed to load messages'
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private sendMessage(socket: WebSocket, message: ServerMessage): void {
|
|
208
|
+
try {
|
|
209
|
+
socket.send(JSON.stringify(message))
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Failed to send WebSocket message', error)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Step 3: Configure Wrangler
|
|
218
|
+
|
|
219
|
+
```toml
|
|
220
|
+
# wrangler.toml
|
|
221
|
+
|
|
222
|
+
# Durable Object bindings
|
|
223
|
+
[[durable_objects.bindings]]
|
|
224
|
+
name = "CHAT_ROOM"
|
|
225
|
+
class_name = "ChatRoom"
|
|
226
|
+
|
|
227
|
+
# Migrations
|
|
228
|
+
[[migrations]]
|
|
229
|
+
tag = "v1"
|
|
230
|
+
new_sqlite_classes = ["ChatRoom"]
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
#### Step 4: Create WebSocket Upgrade Route
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
// src/routes/api/chat-ws.tsx
|
|
237
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
238
|
+
import { getAuthSession } from '@/lib/auth/server-fn'
|
|
239
|
+
|
|
240
|
+
export const Route = createFileRoute('/api/chat-ws')({
|
|
241
|
+
server: {
|
|
242
|
+
handlers: {
|
|
243
|
+
GET: async ({ request, context }) => {
|
|
244
|
+
const user = await getAuthSession()
|
|
245
|
+
if (!user) {
|
|
246
|
+
return new Response('Unauthorized', { status: 401 })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Get Durable Object stub
|
|
250
|
+
const env = context.cloudflare.env as Env
|
|
251
|
+
const id = env.CHAT_ROOM.idFromName(user.uid)
|
|
252
|
+
const stub = env.CHAT_ROOM.get(id)
|
|
253
|
+
|
|
254
|
+
// Forward WebSocket upgrade to Durable Object
|
|
255
|
+
return stub.fetch(request)
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### Step 5: Client-Side WebSocket Hook
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// src/hooks/use-websocket.ts
|
|
266
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
267
|
+
|
|
268
|
+
interface UseWebSocketOptions {
|
|
269
|
+
userId: string
|
|
270
|
+
onMessage: (message: ServerMessage) => void
|
|
271
|
+
onConnectionChange?: (connected: boolean) => void
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function useWebSocket({ userId, onMessage, onConnectionChange }: UseWebSocketOptions) {
|
|
275
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
276
|
+
const [connected, setConnected] = useState(false)
|
|
277
|
+
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
280
|
+
const ws = new WebSocket(`${protocol}//${window.location.host}/api/chat-ws`)
|
|
281
|
+
wsRef.current = ws
|
|
282
|
+
|
|
283
|
+
ws.onopen = () => {
|
|
284
|
+
setConnected(true)
|
|
285
|
+
onConnectionChange?.(true)
|
|
286
|
+
ws.send(JSON.stringify({ type: 'init', userId }))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
ws.onmessage = (event) => {
|
|
290
|
+
const data = JSON.parse(event.data)
|
|
291
|
+
onMessage(data)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
ws.onclose = () => {
|
|
295
|
+
setConnected(false)
|
|
296
|
+
onConnectionChange?.(false)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return () => ws.close()
|
|
300
|
+
}, [userId])
|
|
301
|
+
|
|
302
|
+
const sendMessage = useCallback((message: ClientMessage) => {
|
|
303
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
304
|
+
wsRef.current.send(JSON.stringify(message))
|
|
305
|
+
}
|
|
306
|
+
}, [])
|
|
307
|
+
|
|
308
|
+
return { connected, sendMessage }
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Examples
|
|
315
|
+
|
|
316
|
+
### Example 1: File Upload Durable Object
|
|
317
|
+
|
|
318
|
+
```typescript
|
|
319
|
+
// src/durable-objects/UploadManager.ts
|
|
320
|
+
import { DurableObject } from 'cloudflare:workers'
|
|
321
|
+
|
|
322
|
+
export class UploadManager extends DurableObject {
|
|
323
|
+
async fetch(request: Request): Promise<Response> {
|
|
324
|
+
if (request.headers.get('Upgrade') !== 'websocket') {
|
|
325
|
+
return new Response('Expected WebSocket', { status: 426 })
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const pair = new WebSocketPair()
|
|
329
|
+
const [client, server] = Object.values(pair)
|
|
330
|
+
server.accept()
|
|
331
|
+
|
|
332
|
+
server.addEventListener('message', async (event) => {
|
|
333
|
+
try {
|
|
334
|
+
const data = JSON.parse(event.data as string)
|
|
335
|
+
|
|
336
|
+
switch (data.type) {
|
|
337
|
+
case 'upload_chunk':
|
|
338
|
+
// Process chunk, report progress
|
|
339
|
+
server.send(JSON.stringify({
|
|
340
|
+
type: 'progress',
|
|
341
|
+
progress: data.chunkIndex / data.totalChunks * 100
|
|
342
|
+
}))
|
|
343
|
+
break
|
|
344
|
+
case 'upload_complete':
|
|
345
|
+
// Finalize upload
|
|
346
|
+
server.send(JSON.stringify({ type: 'complete', url: signedUrl }))
|
|
347
|
+
break
|
|
348
|
+
}
|
|
349
|
+
} catch (error) {
|
|
350
|
+
server.send(JSON.stringify({ type: 'error', error: String(error) }))
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
return new Response(null, { status: 101, webSocket: client })
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### Example 2: Internal HTTP Endpoint on Durable Object
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
// Durable Objects can also handle non-WebSocket HTTP requests
|
|
363
|
+
export class ChatRoom extends DurableObject {
|
|
364
|
+
async fetch(request: Request): Promise<Response> {
|
|
365
|
+
const url = new URL(request.url)
|
|
366
|
+
|
|
367
|
+
// Internal cache invalidation endpoint
|
|
368
|
+
if (url.pathname === '/invalidate-cache' && request.method === 'POST') {
|
|
369
|
+
const body = await request.json()
|
|
370
|
+
this.mcpProvider.clearCache()
|
|
371
|
+
return Response.json({ success: true })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// WebSocket upgrade (normal flow)
|
|
375
|
+
if (request.headers.get('Upgrade') === 'websocket') {
|
|
376
|
+
return this.handleWebSocket(request)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return new Response('Expected WebSocket or known endpoint', { status: 426 })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Benefits
|
|
387
|
+
|
|
388
|
+
### 1. Persistent State Across Messages
|
|
389
|
+
Durable Objects maintain state between requests, enabling caching of provider instances and connection state.
|
|
390
|
+
|
|
391
|
+
### 2. Edge-Based Real-Time
|
|
392
|
+
WebSocket connections run at Cloudflare's edge, providing low-latency real-time communication globally.
|
|
393
|
+
|
|
394
|
+
### 3. Testable Business Logic
|
|
395
|
+
Business logic lives in injected engines, not in the Durable Object itself, making it independently testable.
|
|
396
|
+
|
|
397
|
+
### 4. Automatic Scaling
|
|
398
|
+
Each Durable Object instance is uniquely addressable (by user ID, room ID, etc.) and scales automatically.
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Trade-offs
|
|
403
|
+
|
|
404
|
+
### 1. Durable Object Limitations
|
|
405
|
+
**Downside**: Each Durable Object runs in a single location and has CPU/memory limits.
|
|
406
|
+
**Mitigation**: Keep Durable Objects thin. Delegate heavy work to external services.
|
|
407
|
+
|
|
408
|
+
### 2. Cold Start Latency
|
|
409
|
+
**Downside**: First request to a Durable Object may have cold start delay.
|
|
410
|
+
**Mitigation**: Use `init` messages to pre-warm connections after WebSocket establishes.
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Anti-Patterns
|
|
415
|
+
|
|
416
|
+
### ❌ Anti-Pattern 1: Business Logic in Durable Object
|
|
417
|
+
|
|
418
|
+
```typescript
|
|
419
|
+
// ❌ BAD: Business logic mixed into Durable Object
|
|
420
|
+
export class ChatRoom extends DurableObject {
|
|
421
|
+
async handleMessage(data, socket) {
|
|
422
|
+
const response = await fetch('https://api.anthropic.com/...') // Direct API call
|
|
423
|
+
const messages = await getDocument(...) // Direct DB call
|
|
424
|
+
// ... 200 lines of business logic
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ✅ GOOD: Delegate to engine
|
|
429
|
+
export class ChatRoom extends DurableObject {
|
|
430
|
+
async handleMessage(data, socket) {
|
|
431
|
+
await this.chatEngine.processMessage({
|
|
432
|
+
...data,
|
|
433
|
+
onMessage: (msg) => this.sendMessage(socket, msg)
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### ❌ Anti-Pattern 2: Creating Providers Per Message
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
// ❌ BAD: New provider instances per message (loses caching)
|
|
443
|
+
server.addEventListener('message', async (event) => {
|
|
444
|
+
const aiProvider = new BedrockAIProvider() // Created every message!
|
|
445
|
+
const engine = new ChatEngine({ aiProvider })
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// ✅ GOOD: Create once in constructor
|
|
449
|
+
constructor(state, env) {
|
|
450
|
+
this.chatEngine = new ChatEngine({
|
|
451
|
+
aiProvider: new BedrockAIProvider() // Created once, reused
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
---
|
|
457
|
+
|
|
458
|
+
## Testing Strategy
|
|
459
|
+
|
|
460
|
+
### Unit Testing Engine (Without Durable Object)
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
describe('ChatEngine', () => {
|
|
464
|
+
it('should process messages', async () => {
|
|
465
|
+
const mockAI = { streamChat: jest.fn() }
|
|
466
|
+
const mockStorage = { saveMessage: jest.fn(), loadMessages: jest.fn() }
|
|
467
|
+
|
|
468
|
+
const engine = new ChatEngine({
|
|
469
|
+
aiProvider: mockAI,
|
|
470
|
+
storageProvider: mockStorage
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const messages: any[] = []
|
|
474
|
+
await engine.processMessage({
|
|
475
|
+
userId: 'user1',
|
|
476
|
+
conversationId: 'conv1',
|
|
477
|
+
message: 'Hello',
|
|
478
|
+
onMessage: (msg) => messages.push(msg)
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
expect(mockStorage.saveMessage).toHaveBeenCalled()
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
## Related Patterns
|
|
489
|
+
|
|
490
|
+
- **[Provider Adapter Pattern](./tanstack-cloudflare.provider-adapter.md)**: Interfaces for dependency injection into engines
|
|
491
|
+
- **[Rate Limiting Pattern](./tanstack-cloudflare.rate-limiting.md)**: Rate limit WebSocket connections
|
|
492
|
+
- **[Wrangler Configuration](./tanstack-cloudflare.wrangler-configuration.md)**: Durable Object bindings and migrations
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Checklist for Implementation
|
|
497
|
+
|
|
498
|
+
- [ ] Durable Object extends `DurableObject` from `cloudflare:workers`
|
|
499
|
+
- [ ] Business logic delegated to injected engine
|
|
500
|
+
- [ ] Provider instances created once in constructor
|
|
501
|
+
- [ ] Typed `ClientMessage` and `ServerMessage` interfaces
|
|
502
|
+
- [ ] `switch` statement on `data.type` for message routing
|
|
503
|
+
- [ ] Error handling wraps every message handler
|
|
504
|
+
- [ ] Errors sent to client as `{ type: 'error', error: string }`
|
|
505
|
+
- [ ] WebSocket pair created with `new WebSocketPair()`
|
|
506
|
+
- [ ] Server socket accepted with `server.accept()`
|
|
507
|
+
- [ ] Sessions tracked in `Set<WebSocket>` and cleaned up on close
|
|
508
|
+
- [ ] Wrangler configured with DO bindings and migrations
|
|
509
|
+
- [ ] WebSocket upgrade route validates auth before forwarding
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
**Status**: Stable - Proven pattern for real-time features on Cloudflare Workers
|
|
514
|
+
**Recommendation**: Use for all real-time features requiring persistent server-side state
|
|
515
|
+
**Last Updated**: 2026-02-28
|
|
516
|
+
**Contributors**: Patrick Michaelsen
|