@firtoz/websocket-do 2.0.0 → 4.0.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 +432 -13
- package/package.json +10 -5
- package/src/ZodSession.ts +157 -0
- package/src/ZodWebSocketClient.ts +201 -0
- package/src/ZodWebSocketDO.ts +24 -0
- package/src/index.ts +7 -0
- package/src/zodMsgpack.ts +13 -0
package/README.md
CHANGED
|
@@ -9,11 +9,14 @@ Type-safe WebSocket session management for Cloudflare Durable Objects with Hono
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
11
|
- 🔒 **Type-safe** - Full TypeScript support with generic types for messages and session data
|
|
12
|
+
- ✨ **Zod Validation** - Runtime message validation with `ZodWebSocketClient` and `ZodSession`
|
|
12
13
|
- 🌐 **WebSocket Management** - Built on Cloudflare Durable Objects for stateful WebSocket connections
|
|
13
14
|
- 🎯 **Session-based** - Abstract session class for easy implementation of custom WebSocket logic
|
|
14
15
|
- 🔄 **State Persistence** - Automatic serialization/deserialization of session data
|
|
15
16
|
- 📡 **Broadcasting** - Built-in support for broadcasting messages to all connected clients
|
|
17
|
+
- 📦 **Buffer Mode** - Efficient binary messaging with msgpack serialization
|
|
16
18
|
- 🚀 **Hono Integration** - Seamless integration with Hono framework for routing
|
|
19
|
+
- 🔗 **DO Client Integration** - Works seamlessly with `@firtoz/hono-fetcher` for type-safe DO communication
|
|
17
20
|
|
|
18
21
|
## Installation
|
|
19
22
|
|
|
@@ -26,9 +29,22 @@ bun add @firtoz/websocket-do
|
|
|
26
29
|
This package requires the following peer dependencies:
|
|
27
30
|
|
|
28
31
|
```bash
|
|
29
|
-
bun add hono @
|
|
32
|
+
bun add hono @firtoz/hono-fetcher
|
|
30
33
|
```
|
|
31
34
|
|
|
35
|
+
**For Zod validation features** (ZodWebSocketClient, ZodSession):
|
|
36
|
+
```bash
|
|
37
|
+
bun add zod msgpackr
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For TypeScript support, use `wrangler types` to generate accurate types from your `wrangler.jsonc`:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
wrangler types
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This generates `worker-configuration.d.ts` with types for your specific environment bindings, replacing the need for `@cloudflare/workers-types`.
|
|
47
|
+
|
|
32
48
|
## Quick Start
|
|
33
49
|
|
|
34
50
|
### 1. Define Your Message Types
|
|
@@ -115,16 +131,24 @@ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
|
|
|
115
131
|
|
|
116
132
|
### 4. Configure Your Worker
|
|
117
133
|
|
|
118
|
-
```
|
|
119
|
-
// wrangler.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
```jsonc
|
|
135
|
+
// wrangler.jsonc
|
|
136
|
+
{
|
|
137
|
+
"durable_objects": {
|
|
138
|
+
"bindings": [
|
|
139
|
+
{
|
|
140
|
+
"name": "CHAT_ROOM",
|
|
141
|
+
"class_name": "ChatRoomDO"
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
"migrations": [
|
|
146
|
+
{
|
|
147
|
+
"tag": "v1",
|
|
148
|
+
"new_classes": ["ChatRoomDO"]
|
|
149
|
+
}
|
|
150
|
+
]
|
|
151
|
+
}
|
|
128
152
|
```
|
|
129
153
|
|
|
130
154
|
### 5. Access from Your Worker
|
|
@@ -135,8 +159,8 @@ export default {
|
|
|
135
159
|
const url = new URL(request.url);
|
|
136
160
|
|
|
137
161
|
if (url.pathname === '/chat') {
|
|
138
|
-
|
|
139
|
-
const stub = env.CHAT_ROOM.
|
|
162
|
+
// Use getByName() for deterministic DO routing (2025+ compatibility)
|
|
163
|
+
const stub = env.CHAT_ROOM.getByName('global-chat');
|
|
140
164
|
|
|
141
165
|
// Proxy to the Durable Object
|
|
142
166
|
return stub.fetch(request);
|
|
@@ -147,6 +171,231 @@ export default {
|
|
|
147
171
|
};
|
|
148
172
|
```
|
|
149
173
|
|
|
174
|
+
## ZodWebSocketClient (Type-Safe Client)
|
|
175
|
+
|
|
176
|
+
`ZodWebSocketClient` provides a type-safe WebSocket client with automatic Zod validation for both incoming and outgoing messages.
|
|
177
|
+
|
|
178
|
+
### Features
|
|
179
|
+
|
|
180
|
+
- ✅ **Automatic validation** - All messages validated with Zod schemas
|
|
181
|
+
- 🎯 **Full type inference** - TypeScript types automatically inferred from schemas
|
|
182
|
+
- 📦 **Dual mode** - Supports both JSON and msgpack (buffer) serialization
|
|
183
|
+
- 🔗 **DO Integration** - Works seamlessly with `honoDoFetcher` WebSocket connections
|
|
184
|
+
- 🛡️ **Error handling** - Validation errors caught and reported via callbacks
|
|
185
|
+
|
|
186
|
+
### Basic Usage
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import { ZodWebSocketClient } from '@firtoz/websocket-do';
|
|
190
|
+
import { z } from 'zod';
|
|
191
|
+
|
|
192
|
+
// Define your message schemas
|
|
193
|
+
const ClientMessageSchema = z.discriminatedUnion('type', [
|
|
194
|
+
z.object({ type: z.literal('chat'), text: z.string() }),
|
|
195
|
+
z.object({ type: z.literal('ping') }),
|
|
196
|
+
]);
|
|
197
|
+
|
|
198
|
+
const ServerMessageSchema = z.discriminatedUnion('type', [
|
|
199
|
+
z.object({ type: z.literal('chat'), text: z.string(), from: z.string() }),
|
|
200
|
+
z.object({ type: z.literal('pong') }),
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
204
|
+
type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
205
|
+
|
|
206
|
+
// Create WebSocket connection (regular or via honoDoFetcher)
|
|
207
|
+
const ws = new WebSocket('wss://example.com/chat');
|
|
208
|
+
|
|
209
|
+
// Wrap with ZodWebSocketClient
|
|
210
|
+
const client = new ZodWebSocketClient({
|
|
211
|
+
webSocket: ws, // Can also use 'url' instead
|
|
212
|
+
clientSchema: ClientMessageSchema,
|
|
213
|
+
serverSchema: ServerMessageSchema,
|
|
214
|
+
onMessage: (message) => {
|
|
215
|
+
// Fully typed and validated!
|
|
216
|
+
if (message.type === 'chat') {
|
|
217
|
+
console.log(`${message.from}: ${message.text}`);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Send type-safe messages (automatically validated!)
|
|
223
|
+
client.send({ type: 'chat', text: 'Hello!' });
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Integration with honoDoFetcher
|
|
227
|
+
|
|
228
|
+
Perfect for connecting to Durable Objects:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
import { honoDoFetcherWithName } from '@firtoz/hono-fetcher';
|
|
232
|
+
import { ZodWebSocketClient } from '@firtoz/websocket-do';
|
|
233
|
+
|
|
234
|
+
// 1. Connect to DO via honoDoFetcher
|
|
235
|
+
const api = honoDoFetcherWithName(env.CHAT_ROOM, 'room-1');
|
|
236
|
+
const wsResp = await api.websocket({
|
|
237
|
+
url: '/websocket',
|
|
238
|
+
config: { autoAccept: false }, // Let client control acceptance
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// 2. Wrap with ZodWebSocketClient for type safety!
|
|
242
|
+
const client = new ZodWebSocketClient({
|
|
243
|
+
webSocket: wsResp.webSocket,
|
|
244
|
+
clientSchema: ClientMessageSchema,
|
|
245
|
+
serverSchema: ServerMessageSchema,
|
|
246
|
+
onMessage: (message) => {
|
|
247
|
+
// Fully typed and validated
|
|
248
|
+
console.log('Received:', message);
|
|
249
|
+
},
|
|
250
|
+
onValidationError: (error) => {
|
|
251
|
+
console.error('Invalid message:', error);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// 3. Accept the WebSocket
|
|
256
|
+
wsResp.webSocket?.accept();
|
|
257
|
+
|
|
258
|
+
// 4. Send validated messages
|
|
259
|
+
client.send({ type: 'chat', text: 'Hello from typed client!' });
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Buffer Mode (msgpack)
|
|
263
|
+
|
|
264
|
+
For better performance and smaller payloads, use buffer mode with msgpack:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
const client = new ZodWebSocketClient({
|
|
268
|
+
webSocket: ws,
|
|
269
|
+
clientSchema: ClientMessageSchema,
|
|
270
|
+
serverSchema: ServerMessageSchema,
|
|
271
|
+
enableBufferMessages: true, // Enable msgpack serialization
|
|
272
|
+
onMessage: (message) => {
|
|
273
|
+
// Still fully typed!
|
|
274
|
+
console.log('Received via msgpack:', message);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Messages automatically serialized with msgpack
|
|
279
|
+
client.send({ type: 'chat', text: 'Efficient binary message!' });
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### API Reference
|
|
283
|
+
|
|
284
|
+
#### Constructor Options
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
interface ZodWebSocketClientOptions<TClientMessage, TServerMessage> {
|
|
288
|
+
// Connection (provide one)
|
|
289
|
+
url?: string; // Create new WebSocket
|
|
290
|
+
webSocket?: WebSocket; // Use existing WebSocket (e.g., from honoDoFetcher)
|
|
291
|
+
|
|
292
|
+
// Schemas (required)
|
|
293
|
+
clientSchema: z.ZodType<TClientMessage>;
|
|
294
|
+
serverSchema: z.ZodType<TServerMessage>;
|
|
295
|
+
|
|
296
|
+
// Serialization
|
|
297
|
+
enableBufferMessages?: boolean; // Use msgpack instead of JSON (default: false)
|
|
298
|
+
|
|
299
|
+
// Callbacks
|
|
300
|
+
onMessage: (message: TServerMessage) => void;
|
|
301
|
+
onOpen?: () => void;
|
|
302
|
+
onClose?: (event: CloseEvent) => void;
|
|
303
|
+
onError?: (event: Event) => void;
|
|
304
|
+
onValidationError?: (error: unknown) => void;
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
#### Methods
|
|
309
|
+
|
|
310
|
+
- `send(message: TClientMessage): void` - Send a validated message
|
|
311
|
+
- `close(code?: number, reason?: string): void` - Close the connection
|
|
312
|
+
- `waitForOpen(): Promise<void>` - Wait for connection to open
|
|
313
|
+
|
|
314
|
+
## ZodSession (Validated Sessions)
|
|
315
|
+
|
|
316
|
+
`ZodSession` extends `BaseSession` with automatic Zod validation for incoming messages.
|
|
317
|
+
|
|
318
|
+
### Basic Usage
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import { ZodSession } from '@firtoz/websocket-do';
|
|
322
|
+
import { z } from 'zod';
|
|
323
|
+
|
|
324
|
+
// Define schemas
|
|
325
|
+
const ClientMessageSchema = z.discriminatedUnion('type', [
|
|
326
|
+
z.object({ type: z.literal('setName'), name: z.string().min(1).max(50) }),
|
|
327
|
+
z.object({ type: z.literal('message'), text: z.string().max(1000) }),
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
const ServerMessageSchema = z.discriminatedUnion('type', [
|
|
331
|
+
z.object({ type: z.literal('nameChanged'), newName: z.string() }),
|
|
332
|
+
z.object({ type: z.literal('message'), text: z.string(), from: z.string() }),
|
|
333
|
+
z.object({ type: z.literal('error'), message: z.string() }),
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
337
|
+
type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
338
|
+
|
|
339
|
+
interface SessionData {
|
|
340
|
+
name: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Implement validated session
|
|
344
|
+
class ChatSession extends ZodSession<
|
|
345
|
+
Env,
|
|
346
|
+
SessionData,
|
|
347
|
+
ServerMessage,
|
|
348
|
+
ClientMessage
|
|
349
|
+
> {
|
|
350
|
+
protected clientSchema = ClientMessageSchema;
|
|
351
|
+
protected serverSchema = ServerMessageSchema;
|
|
352
|
+
|
|
353
|
+
protected createData(ctx: Context<{ Bindings: Env }>): SessionData {
|
|
354
|
+
return { name: 'Anonymous' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async handleMessage(message: ClientMessage): Promise<void> {
|
|
358
|
+
// Message is already validated!
|
|
359
|
+
switch (message.type) {
|
|
360
|
+
case 'setName':
|
|
361
|
+
this.data.name = message.name;
|
|
362
|
+
this.update();
|
|
363
|
+
this.send({ type: 'nameChanged', newName: message.name });
|
|
364
|
+
break;
|
|
365
|
+
|
|
366
|
+
case 'message':
|
|
367
|
+
this.broadcast({
|
|
368
|
+
type: 'message',
|
|
369
|
+
text: message.text,
|
|
370
|
+
from: this.data.name,
|
|
371
|
+
});
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async handleClose(): Promise<void> {
|
|
377
|
+
console.log(`${this.data.name} disconnected`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Buffer Mode with ZodSession
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
class ChatSession extends ZodSession<...> {
|
|
386
|
+
// Enable buffer mode for msgpack
|
|
387
|
+
protected enableBufferMessages = true;
|
|
388
|
+
|
|
389
|
+
protected clientSchema = ClientMessageSchema;
|
|
390
|
+
protected serverSchema = ServerMessageSchema;
|
|
391
|
+
|
|
392
|
+
// Messages automatically decoded from msgpack
|
|
393
|
+
async handleMessage(message: ClientMessage): Promise<void> {
|
|
394
|
+
// Handle validated message
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
150
399
|
## API Reference
|
|
151
400
|
|
|
152
401
|
### `BaseWebSocketDO<TEnv, TSession>`
|
|
@@ -315,6 +564,176 @@ async handleMessage(message: ClientMessage): Promise<void> {
|
|
|
315
564
|
}
|
|
316
565
|
```
|
|
317
566
|
|
|
567
|
+
## Exports
|
|
568
|
+
|
|
569
|
+
This package exports the following:
|
|
570
|
+
|
|
571
|
+
### Classes
|
|
572
|
+
- `BaseWebSocketDO` - Abstract base class for WebSocket Durable Objects
|
|
573
|
+
- `BaseSession` - Abstract base class for WebSocket sessions
|
|
574
|
+
- `ZodWebSocketClient` - Type-safe WebSocket client with Zod validation
|
|
575
|
+
- `ZodSession` - Session base class with Zod validation built-in
|
|
576
|
+
- `WebsocketWrapper` - Low-level WebSocket wrapper with typed attachments
|
|
577
|
+
|
|
578
|
+
### Utilities
|
|
579
|
+
- `zodMsgpack` - Msgpack encode/decode with Zod validation
|
|
580
|
+
|
|
581
|
+
### Types
|
|
582
|
+
All classes export their type parameters and interfaces for custom implementations.
|
|
583
|
+
|
|
584
|
+
## Complete Example
|
|
585
|
+
|
|
586
|
+
Here's a full example combining all features:
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
// schemas.ts
|
|
590
|
+
import { z } from 'zod';
|
|
591
|
+
|
|
592
|
+
export const ClientMessageSchema = z.discriminatedUnion('type', [
|
|
593
|
+
z.object({ type: z.literal('setName'), name: z.string().min(1).max(50) }),
|
|
594
|
+
z.object({ type: z.literal('message'), text: z.string().max(1000) }),
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
export const ServerMessageSchema = z.discriminatedUnion('type', [
|
|
598
|
+
z.object({ type: z.literal('nameChanged'), newName: z.string() }),
|
|
599
|
+
z.object({ type: z.literal('message'), text: z.string(), from: z.string() }),
|
|
600
|
+
z.object({ type: z.literal('userJoined'), name: z.string() }),
|
|
601
|
+
z.object({ type: z.literal('error'), message: z.string() }),
|
|
602
|
+
]);
|
|
603
|
+
|
|
604
|
+
export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
605
|
+
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
606
|
+
|
|
607
|
+
// do.ts - Server-side (Durable Object)
|
|
608
|
+
import { BaseWebSocketDO, ZodSession } from '@firtoz/websocket-do';
|
|
609
|
+
import { ClientMessageSchema, ServerMessageSchema } from './schemas';
|
|
610
|
+
|
|
611
|
+
interface SessionData {
|
|
612
|
+
name: string;
|
|
613
|
+
joinedAt: number;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
class ChatSession extends ZodSession<Env, SessionData, ServerMessage, ClientMessage> {
|
|
617
|
+
protected clientSchema = ClientMessageSchema;
|
|
618
|
+
protected serverSchema = ServerMessageSchema;
|
|
619
|
+
protected enableBufferMessages = true; // Use msgpack for efficiency
|
|
620
|
+
|
|
621
|
+
protected createData(): SessionData {
|
|
622
|
+
return {
|
|
623
|
+
name: 'Anonymous',
|
|
624
|
+
joinedAt: Date.now(),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async handleMessage(message: ClientMessage): Promise<void> {
|
|
629
|
+
switch (message.type) {
|
|
630
|
+
case 'setName':
|
|
631
|
+
const oldName = this.data.name;
|
|
632
|
+
this.data.name = message.name;
|
|
633
|
+
this.update();
|
|
634
|
+
|
|
635
|
+
this.send({ type: 'nameChanged', newName: message.name });
|
|
636
|
+
this.broadcast({ type: 'userJoined', name: message.name }, true);
|
|
637
|
+
break;
|
|
638
|
+
|
|
639
|
+
case 'message':
|
|
640
|
+
this.broadcast({
|
|
641
|
+
type: 'message',
|
|
642
|
+
text: message.text,
|
|
643
|
+
from: this.data.name,
|
|
644
|
+
});
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async handleClose(): Promise<void> {
|
|
650
|
+
console.log(`${this.data.name} disconnected`);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
|
|
655
|
+
app = this.getBaseApp()
|
|
656
|
+
.get('/info', (ctx) => {
|
|
657
|
+
const users = Array.from(this.sessions.values()).map(s => ({
|
|
658
|
+
name: s.data.name,
|
|
659
|
+
joinedAt: s.data.joinedAt,
|
|
660
|
+
}));
|
|
661
|
+
return ctx.json({ users, count: users.length });
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
protected createSession(websocket: WebSocket): ChatSession {
|
|
665
|
+
return new ChatSession(websocket, this.sessions);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// client.ts - Client-side
|
|
670
|
+
import { ZodWebSocketClient } from '@firtoz/websocket-do';
|
|
671
|
+
import { honoDoFetcherWithName } from '@firtoz/hono-fetcher';
|
|
672
|
+
import { ClientMessageSchema, ServerMessageSchema } from './schemas';
|
|
673
|
+
|
|
674
|
+
async function connectToChat(env: Env, roomName: string) {
|
|
675
|
+
// 1. Connect via honoDoFetcher
|
|
676
|
+
const api = honoDoFetcherWithName(env.CHAT_ROOM, roomName);
|
|
677
|
+
const wsResp = await api.websocket({
|
|
678
|
+
url: '/websocket',
|
|
679
|
+
config: { autoAccept: false },
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// 2. Wrap with ZodWebSocketClient
|
|
683
|
+
const client = new ZodWebSocketClient({
|
|
684
|
+
webSocket: wsResp.webSocket,
|
|
685
|
+
clientSchema: ClientMessageSchema,
|
|
686
|
+
serverSchema: ServerMessageSchema,
|
|
687
|
+
enableBufferMessages: true, // Match server setting
|
|
688
|
+
onMessage: (message) => {
|
|
689
|
+
switch (message.type) {
|
|
690
|
+
case 'message':
|
|
691
|
+
console.log(`${message.from}: ${message.text}`);
|
|
692
|
+
break;
|
|
693
|
+
case 'userJoined':
|
|
694
|
+
console.log(`${message.name} joined!`);
|
|
695
|
+
break;
|
|
696
|
+
case 'nameChanged':
|
|
697
|
+
console.log(`Name changed to ${message.newName}`);
|
|
698
|
+
break;
|
|
699
|
+
case 'error':
|
|
700
|
+
console.error('Error:', message.message);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
onValidationError: (error) => {
|
|
705
|
+
console.error('Validation error:', error);
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
// 3. Accept connection
|
|
710
|
+
wsResp.webSocket?.accept();
|
|
711
|
+
|
|
712
|
+
// 4. Use type-safe client
|
|
713
|
+
client.send({ type: 'setName', name: 'Alice' });
|
|
714
|
+
client.send({ type: 'message', text: 'Hello everyone!' });
|
|
715
|
+
|
|
716
|
+
return client;
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
## Testing
|
|
721
|
+
|
|
722
|
+
This package includes comprehensive integration tests in a separate test package using `@cloudflare/vitest-pool-workers`, which provides full WebSocket testing capabilities in a Miniflare-based environment that closely mirrors production.
|
|
723
|
+
|
|
724
|
+
**What can be tested:**
|
|
725
|
+
- ✅ Worker routing to Durable Objects
|
|
726
|
+
- ✅ HTTP endpoints on DOs
|
|
727
|
+
- ✅ DO state management and isolation
|
|
728
|
+
- ✅ Full WebSocket connection lifecycle
|
|
729
|
+
- ✅ Real-time WebSocket message exchange
|
|
730
|
+
- ✅ WebSocket session management
|
|
731
|
+
- ✅ Type-safe DO client integration
|
|
732
|
+
- ✅ Zod validation in both JSON and msgpack modes
|
|
733
|
+
- ✅ Integration between honoDoFetcher and ZodWebSocketClient
|
|
734
|
+
|
|
735
|
+
For detailed information about testing capabilities, example implementations, comprehensive test coverage, and setup instructions, see the [websocket-do-test](../../tests/websocket-do-test/) package.
|
|
736
|
+
|
|
318
737
|
## License
|
|
319
738
|
|
|
320
739
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/websocket-do",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Type-safe WebSocket session management for Cloudflare Durable Objects with Hono integration",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"module": "./src/index.ts",
|
|
@@ -21,9 +21,7 @@
|
|
|
21
21
|
"typecheck": "tsc --noEmit",
|
|
22
22
|
"lint": "biome check --write src",
|
|
23
23
|
"lint:ci": "biome ci src",
|
|
24
|
-
"format": "biome format src --write"
|
|
25
|
-
"test": "bun test",
|
|
26
|
-
"test:watch": "bun test --watch"
|
|
24
|
+
"format": "biome format src --write"
|
|
27
25
|
},
|
|
28
26
|
"keywords": [
|
|
29
27
|
"typescript",
|
|
@@ -51,6 +49,11 @@
|
|
|
51
49
|
"@firtoz/hono-fetcher": "workspace:*",
|
|
52
50
|
"hono": "^4.9.10"
|
|
53
51
|
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"msgpackr": "^1.11.5",
|
|
54
|
+
"react": "^19.2.0",
|
|
55
|
+
"zod": "^4.1.12"
|
|
56
|
+
},
|
|
54
57
|
"engines": {
|
|
55
58
|
"node": ">=18.0.0"
|
|
56
59
|
},
|
|
@@ -58,6 +61,8 @@
|
|
|
58
61
|
"access": "public"
|
|
59
62
|
},
|
|
60
63
|
"devDependencies": {
|
|
61
|
-
"
|
|
64
|
+
"@types/react": "^19.2.2",
|
|
65
|
+
"bun-types": "^1.3.0",
|
|
66
|
+
"typescript": "^5.9.3"
|
|
62
67
|
}
|
|
63
68
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
import { BaseSession } from "./BaseSession";
|
|
3
|
+
import { zodMsgpack } from "./zodMsgpack";
|
|
4
|
+
|
|
5
|
+
export interface ZodSessionOptions<TClientMessage, TServerMessage> {
|
|
6
|
+
clientSchema: ZodType<TClientMessage>;
|
|
7
|
+
serverSchema: ZodType<TServerMessage>;
|
|
8
|
+
enableBufferMessages?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export abstract class ZodSession<
|
|
12
|
+
TEnv extends object = any,
|
|
13
|
+
TData = any,
|
|
14
|
+
TServerMessage = any,
|
|
15
|
+
TClientMessage = any,
|
|
16
|
+
> extends BaseSession<TEnv, TData, TServerMessage, TClientMessage> {
|
|
17
|
+
protected readonly clientCodec: ReturnType<typeof zodMsgpack<TClientMessage>>;
|
|
18
|
+
protected readonly serverCodec: ReturnType<typeof zodMsgpack<TServerMessage>>;
|
|
19
|
+
protected readonly enableBufferMessages: boolean;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
websocket: WebSocket,
|
|
23
|
+
sessions: Map<
|
|
24
|
+
WebSocket,
|
|
25
|
+
ZodSession<TEnv, TData, TServerMessage, TClientMessage>
|
|
26
|
+
>,
|
|
27
|
+
protected options: ZodSessionOptions<TClientMessage, TServerMessage>,
|
|
28
|
+
) {
|
|
29
|
+
super(websocket, sessions);
|
|
30
|
+
|
|
31
|
+
this.clientCodec = zodMsgpack(options.clientSchema);
|
|
32
|
+
this.serverCodec = zodMsgpack(options.serverSchema);
|
|
33
|
+
this.enableBufferMessages = options.enableBufferMessages ?? false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Override the base handleMessage to add validation
|
|
37
|
+
async handleMessage(message: TClientMessage): Promise<void> {
|
|
38
|
+
// If buffer messages are enabled, reject JSON messages
|
|
39
|
+
if (this.enableBufferMessages) {
|
|
40
|
+
console.error(
|
|
41
|
+
"String messages not allowed when buffer messages are enabled",
|
|
42
|
+
);
|
|
43
|
+
await this.sendProtocolError(
|
|
44
|
+
"String messages are not allowed. Please use buffer messages.",
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Validate the message using the client schema
|
|
51
|
+
const validatedMessage = this.options.clientSchema.parse(message);
|
|
52
|
+
await this.handleValidatedMessage(validatedMessage);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error("Invalid client message received:", error);
|
|
55
|
+
await this.handleValidationError(error, message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Override buffer message handling to support msgpack decoding
|
|
60
|
+
async handleBufferMessage(buffer: ArrayBuffer): Promise<void> {
|
|
61
|
+
// If buffer messages are disabled, reject buffer messages
|
|
62
|
+
if (!this.enableBufferMessages) {
|
|
63
|
+
console.error(
|
|
64
|
+
"Buffer messages not allowed when buffer messages are disabled",
|
|
65
|
+
);
|
|
66
|
+
// We can't use sendProtocolError here because it would send JSON
|
|
67
|
+
// Just close the connection or ignore
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const bytes = new Uint8Array(buffer);
|
|
73
|
+
const decodedMessage = this.clientCodec.decode(bytes);
|
|
74
|
+
await this.handleValidatedMessage(decodedMessage);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error("Failed to decode buffer message:", error);
|
|
77
|
+
await this.handleValidationError(error, buffer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Type-safe send method that automatically uses the correct format
|
|
82
|
+
protected send(message: TServerMessage): void {
|
|
83
|
+
if (this.enableBufferMessages) {
|
|
84
|
+
this.sendBuffer(message);
|
|
85
|
+
} else {
|
|
86
|
+
this.sendJson(message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Explicitly send as JSON
|
|
91
|
+
private sendJson(message: TServerMessage): void {
|
|
92
|
+
try {
|
|
93
|
+
// Validate the message using the server schema
|
|
94
|
+
const validatedMessage = this.options.serverSchema.parse(message);
|
|
95
|
+
|
|
96
|
+
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
97
|
+
|
|
98
|
+
this.websocket.send(JSON.stringify(validatedMessage));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error("Invalid server message to send:", error);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Explicitly send as buffer using msgpack
|
|
105
|
+
private sendBuffer(message: TServerMessage): void {
|
|
106
|
+
try {
|
|
107
|
+
const encodedMessage = this.serverCodec.encode(message);
|
|
108
|
+
|
|
109
|
+
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
110
|
+
|
|
111
|
+
this.websocket.send(encodedMessage);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("Failed to encode buffer message:", error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Send a protocol error message (always as JSON for compatibility)
|
|
118
|
+
private async sendProtocolError(errorMessage: string): Promise<void> {
|
|
119
|
+
try {
|
|
120
|
+
// Send a simple error object - no schema validation needed
|
|
121
|
+
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
122
|
+
this.websocket.send(JSON.stringify({ error: errorMessage }));
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error("Failed to send protocol error:", error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Type-safe broadcast that validates server messages
|
|
129
|
+
// Automatically uses the correct format based on session configuration
|
|
130
|
+
protected broadcast(message: TServerMessage, excludeSelf = false): void {
|
|
131
|
+
for (const session of this.sessions.values()) {
|
|
132
|
+
if (excludeSelf && session === this) continue;
|
|
133
|
+
if (session instanceof ZodSession) {
|
|
134
|
+
session.send(message); // send() automatically uses correct format
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Abstract methods for implementers
|
|
140
|
+
protected abstract handleValidatedMessage(
|
|
141
|
+
message: TClientMessage,
|
|
142
|
+
): Promise<void>;
|
|
143
|
+
|
|
144
|
+
// Optional error handling - default implementation logs and continues
|
|
145
|
+
protected async handleValidationError(
|
|
146
|
+
error: unknown,
|
|
147
|
+
originalMessage: unknown,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
console.error(
|
|
150
|
+
"Validation error:",
|
|
151
|
+
error,
|
|
152
|
+
"Original message:",
|
|
153
|
+
originalMessage,
|
|
154
|
+
);
|
|
155
|
+
// Implementers can override this to send error responses to clients
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { pack, unpack } from "msgpackr";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
|
|
4
|
+
export interface ZodWebSocketClientOptions<TClientMessage, TServerMessage> {
|
|
5
|
+
/**
|
|
6
|
+
* URL to connect to (required if webSocket not provided)
|
|
7
|
+
*/
|
|
8
|
+
url?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Existing WebSocket to wrap (alternative to url)
|
|
11
|
+
* Useful when getting a WebSocket from honoDoFetcher
|
|
12
|
+
*/
|
|
13
|
+
webSocket?: WebSocket;
|
|
14
|
+
clientSchema: ZodType<TClientMessage>;
|
|
15
|
+
serverSchema: ZodType<TServerMessage>;
|
|
16
|
+
enableBufferMessages?: boolean;
|
|
17
|
+
onMessage?: (message: TServerMessage) => void;
|
|
18
|
+
onOpen?: (event: Event) => void;
|
|
19
|
+
onClose?: (event: CloseEvent) => void;
|
|
20
|
+
onError?: (event: Event) => void;
|
|
21
|
+
onValidationError?: (error: unknown, rawMessage: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ZodWebSocketClient<TClientMessage, TServerMessage> {
|
|
25
|
+
private ws: WebSocket;
|
|
26
|
+
private readonly clientSchema: ZodType<TClientMessage>;
|
|
27
|
+
private readonly serverSchema: ZodType<TServerMessage>;
|
|
28
|
+
private readonly enableBufferMessages: boolean;
|
|
29
|
+
private readonly onMessageCallback?: (message: TServerMessage) => void;
|
|
30
|
+
private readonly onValidationError?: (
|
|
31
|
+
error: unknown,
|
|
32
|
+
rawMessage: unknown,
|
|
33
|
+
) => void;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
options: ZodWebSocketClientOptions<TClientMessage, TServerMessage>,
|
|
37
|
+
) {
|
|
38
|
+
this.clientSchema = options.clientSchema;
|
|
39
|
+
this.serverSchema = options.serverSchema;
|
|
40
|
+
this.enableBufferMessages = options.enableBufferMessages ?? false;
|
|
41
|
+
this.onMessageCallback = options.onMessage;
|
|
42
|
+
this.onValidationError = options.onValidationError;
|
|
43
|
+
|
|
44
|
+
// Use provided WebSocket or create new one from URL
|
|
45
|
+
if (options.webSocket) {
|
|
46
|
+
// Use existing WebSocket (e.g., from honoDoFetcher)
|
|
47
|
+
this.ws = options.webSocket;
|
|
48
|
+
} else if (options.url) {
|
|
49
|
+
// Create new WebSocket from URL
|
|
50
|
+
this.ws = new WebSocket(options.url);
|
|
51
|
+
} else {
|
|
52
|
+
throw new Error("Either 'url' or 'webSocket' must be provided");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Set binary type for buffer messages
|
|
56
|
+
if (this.enableBufferMessages) {
|
|
57
|
+
this.ws.binaryType = "arraybuffer";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Setup event handlers
|
|
61
|
+
this.ws.addEventListener("open", (event) => {
|
|
62
|
+
options.onOpen?.(event);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
this.ws.addEventListener("message", (event) => {
|
|
66
|
+
this.handleMessage(event);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
this.ws.addEventListener("close", (event) => {
|
|
70
|
+
options.onClose?.(event);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
this.ws.addEventListener("error", (event) => {
|
|
74
|
+
options.onError?.(event);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private handleMessage(event: MessageEvent): void {
|
|
79
|
+
try {
|
|
80
|
+
let parsedMessage: TServerMessage;
|
|
81
|
+
|
|
82
|
+
if (this.enableBufferMessages) {
|
|
83
|
+
// Buffer mode: expect ArrayBuffer
|
|
84
|
+
if (!(event.data instanceof ArrayBuffer)) {
|
|
85
|
+
console.error(
|
|
86
|
+
"Expected ArrayBuffer but received:",
|
|
87
|
+
typeof event.data,
|
|
88
|
+
);
|
|
89
|
+
this.onValidationError?.(
|
|
90
|
+
new Error("Expected ArrayBuffer in buffer mode"),
|
|
91
|
+
event.data,
|
|
92
|
+
);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unpack and validate
|
|
97
|
+
const unpacked = unpack(new Uint8Array(event.data));
|
|
98
|
+
parsedMessage = this.serverSchema.parse(unpacked);
|
|
99
|
+
} else {
|
|
100
|
+
// JSON mode: expect string
|
|
101
|
+
if (typeof event.data !== "string") {
|
|
102
|
+
console.error("Expected string but received:", typeof event.data);
|
|
103
|
+
this.onValidationError?.(
|
|
104
|
+
new Error("Expected string in JSON mode"),
|
|
105
|
+
event.data,
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Parse and validate
|
|
111
|
+
const parsed = JSON.parse(event.data);
|
|
112
|
+
parsedMessage = this.serverSchema.parse(parsed);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Call message handler
|
|
116
|
+
this.onMessageCallback?.(parsedMessage);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error("Failed to process message:", error);
|
|
119
|
+
this.onValidationError?.(error, event.data);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Send a message (automatically encodes based on mode)
|
|
125
|
+
*/
|
|
126
|
+
send(message: TClientMessage): void {
|
|
127
|
+
try {
|
|
128
|
+
// Validate message
|
|
129
|
+
const validatedMessage = this.clientSchema.parse(message);
|
|
130
|
+
|
|
131
|
+
if (this.enableBufferMessages) {
|
|
132
|
+
// Encode as msgpack
|
|
133
|
+
const packed = pack(validatedMessage);
|
|
134
|
+
this.ws.send(packed);
|
|
135
|
+
} else {
|
|
136
|
+
// Encode as JSON
|
|
137
|
+
this.ws.send(JSON.stringify(validatedMessage));
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error("Failed to send message:", error);
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Close the WebSocket connection
|
|
147
|
+
*/
|
|
148
|
+
close(code?: number, reason?: string): void {
|
|
149
|
+
this.ws.close(code, reason);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get the current WebSocket ready state
|
|
154
|
+
*/
|
|
155
|
+
get readyState(): number {
|
|
156
|
+
return this.ws.readyState;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get the underlying WebSocket instance (use with caution)
|
|
161
|
+
*/
|
|
162
|
+
get socket(): WebSocket {
|
|
163
|
+
return this.ws;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Wait for the connection to open
|
|
168
|
+
*/
|
|
169
|
+
async waitForOpen(): Promise<void> {
|
|
170
|
+
if (this.ws.readyState === WebSocket.OPEN) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const abortController = new AbortController();
|
|
176
|
+
const { signal } = abortController;
|
|
177
|
+
|
|
178
|
+
const cleanup = () => {
|
|
179
|
+
abortController.abort();
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
this.ws.addEventListener(
|
|
183
|
+
"open",
|
|
184
|
+
() => {
|
|
185
|
+
cleanup();
|
|
186
|
+
resolve();
|
|
187
|
+
},
|
|
188
|
+
{ signal },
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
this.ws.addEventListener(
|
|
192
|
+
"error",
|
|
193
|
+
() => {
|
|
194
|
+
cleanup();
|
|
195
|
+
reject(new Error("WebSocket connection failed"));
|
|
196
|
+
},
|
|
197
|
+
{ signal },
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { BaseWebSocketDO } from "./BaseWebSocketDO";
|
|
2
|
+
import type { ZodSession, ZodSessionOptions } from "./ZodSession";
|
|
3
|
+
|
|
4
|
+
export abstract class ZodWebSocketDO<
|
|
5
|
+
TEnv extends object,
|
|
6
|
+
TSession extends ZodSession<TEnv, any, any, any>,
|
|
7
|
+
TClientMessage = any,
|
|
8
|
+
TServerMessage = any,
|
|
9
|
+
> extends BaseWebSocketDO<TEnv, TSession> {
|
|
10
|
+
protected abstract getZodOptions(): ZodSessionOptions<
|
|
11
|
+
TClientMessage,
|
|
12
|
+
TServerMessage
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
protected abstract createZodSession(
|
|
16
|
+
websocket: WebSocket,
|
|
17
|
+
options: ZodSessionOptions<TClientMessage, TServerMessage>,
|
|
18
|
+
): TSession | Promise<TSession>;
|
|
19
|
+
|
|
20
|
+
protected createSession(websocket: WebSocket): TSession | Promise<TSession> {
|
|
21
|
+
const options = this.getZodOptions();
|
|
22
|
+
return this.createZodSession(websocket, options);
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
1
|
export { BaseSession, type SessionClientMessage } from "./BaseSession";
|
|
2
2
|
export { BaseWebSocketDO } from "./BaseWebSocketDO";
|
|
3
3
|
export { WebsocketWrapper } from "./WebsocketWrapper";
|
|
4
|
+
export { ZodSession, type ZodSessionOptions } from "./ZodSession";
|
|
5
|
+
export {
|
|
6
|
+
ZodWebSocketClient,
|
|
7
|
+
type ZodWebSocketClientOptions,
|
|
8
|
+
} from "./ZodWebSocketClient";
|
|
9
|
+
export { ZodWebSocketDO } from "./ZodWebSocketDO";
|
|
10
|
+
export { zodMsgpack } from "./zodMsgpack";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { pack, unpack } from "msgpackr";
|
|
2
|
+
import type { ZodType } from "zod";
|
|
3
|
+
|
|
4
|
+
export const zodMsgpack = <T>(schema: ZodType<T>) => ({
|
|
5
|
+
encode(value: T): Uint8Array {
|
|
6
|
+
const validated = schema.parse(value);
|
|
7
|
+
const packed = pack(validated);
|
|
8
|
+
return new Uint8Array(packed);
|
|
9
|
+
},
|
|
10
|
+
decode(bytes: Uint8Array): T {
|
|
11
|
+
return schema.parse(unpack(bytes));
|
|
12
|
+
},
|
|
13
|
+
});
|