@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 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 @cloudflare/workers-types @firtoz/hono-fetcher
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
- ```typescript
119
- // wrangler.toml
120
- [[durable_objects.bindings]]
121
- name = "CHAT_ROOM"
122
- class_name = "ChatRoomDO"
123
- script_name = "your-worker-name"
124
-
125
- [[migrations]]
126
- tag = "v1"
127
- new_classes = ["ChatRoomDO"]
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
- const id = env.CHAT_ROOM.idFromName('global-chat');
139
- const stub = env.CHAT_ROOM.get(id);
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": "2.0.0",
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
- "bun-types": "^1.2.23"
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
+ });