@firtoz/websocket-do 6.0.2 → 7.0.1
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 +304 -165
- package/package.json +9 -9
- package/src/BaseSession.ts +87 -20
- package/src/BaseWebSocketDO.ts +38 -12
- package/src/ZodSession.ts +90 -51
- package/src/ZodWebSocketDO.ts +72 -16
- package/src/index.ts +19 -4
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
Type-safe WebSocket session management for Cloudflare Durable Objects with Hono integration.
|
|
8
8
|
|
|
9
|
+
> **⚠️ Early WIP Notice:** This package is in very early development and is **not production-ready**. It is TypeScript-only and may have breaking changes. While I (the maintainer) have limited time, I'm open to PRs for features, bug fixes, or additional support (like JS builds). Please feel free to try it out and contribute! See [CONTRIBUTING.md](../../CONTRIBUTING.md) for details.
|
|
10
|
+
|
|
9
11
|
## Features
|
|
10
12
|
|
|
11
13
|
- 🔒 **Type-safe** - Full TypeScript support with generic types for messages and session data
|
|
@@ -67,44 +69,81 @@ interface SessionData {
|
|
|
67
69
|
### 2. Implement Your Session
|
|
68
70
|
|
|
69
71
|
```typescript
|
|
70
|
-
import { BaseSession } from '@firtoz/websocket-do';
|
|
72
|
+
import { BaseSession, type BaseSessionHandlers } from '@firtoz/websocket-do';
|
|
71
73
|
import type { Context } from 'hono';
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
// Define handlers for your session
|
|
76
|
+
const chatSessionHandlers: BaseSessionHandlers<
|
|
75
77
|
SessionData,
|
|
76
78
|
ServerMessage,
|
|
77
|
-
ClientMessage
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
ClientMessage,
|
|
80
|
+
Env
|
|
81
|
+
> = {
|
|
82
|
+
createData: (ctx: Context<{ Bindings: Env }>) => ({
|
|
83
|
+
userId: crypto.randomUUID(),
|
|
84
|
+
joinedAt: Date.now(),
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
handleMessage: async (message: ClientMessage) => {
|
|
88
|
+
// 'this' context will be the session instance
|
|
87
89
|
switch (message.type) {
|
|
88
90
|
case 'chat':
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
type: 'chat',
|
|
92
|
-
message: message.message,
|
|
93
|
-
from: this.data.userId,
|
|
94
|
-
});
|
|
91
|
+
// Access session via closure or bind
|
|
92
|
+
// Note: handlers receive session context when called
|
|
95
93
|
break;
|
|
96
94
|
case 'ping':
|
|
97
|
-
|
|
95
|
+
// Send messages
|
|
98
96
|
break;
|
|
99
97
|
}
|
|
100
|
-
}
|
|
98
|
+
},
|
|
101
99
|
|
|
102
|
-
async
|
|
100
|
+
handleBufferMessage: async (message: ArrayBuffer) => {
|
|
103
101
|
// Handle binary messages if needed
|
|
104
|
-
}
|
|
102
|
+
},
|
|
105
103
|
|
|
106
|
-
async
|
|
107
|
-
console.log(
|
|
104
|
+
handleClose: async () => {
|
|
105
|
+
console.log('Session closed');
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Create session class (can be extended if needed)
|
|
110
|
+
class ChatSession extends BaseSession<
|
|
111
|
+
SessionData,
|
|
112
|
+
ServerMessage,
|
|
113
|
+
ClientMessage,
|
|
114
|
+
Env
|
|
115
|
+
> {
|
|
116
|
+
constructor(
|
|
117
|
+
websocket: WebSocket,
|
|
118
|
+
sessions: Map<WebSocket, ChatSession>
|
|
119
|
+
) {
|
|
120
|
+
super(websocket, sessions, {
|
|
121
|
+
createData: (ctx) => ({
|
|
122
|
+
userId: crypto.randomUUID(),
|
|
123
|
+
joinedAt: Date.now(),
|
|
124
|
+
}),
|
|
125
|
+
handleMessage: async (message) => {
|
|
126
|
+
switch (message.type) {
|
|
127
|
+
case 'chat':
|
|
128
|
+
// Broadcast to all sessions
|
|
129
|
+
this.broadcast({
|
|
130
|
+
type: 'chat',
|
|
131
|
+
message: message.message,
|
|
132
|
+
from: this.data.userId,
|
|
133
|
+
});
|
|
134
|
+
break;
|
|
135
|
+
case 'ping':
|
|
136
|
+
this.send({ type: 'welcome', userId: this.data.userId });
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
handleBufferMessage: async (message) => {
|
|
141
|
+
// Handle binary messages if needed
|
|
142
|
+
},
|
|
143
|
+
handleClose: async () => {
|
|
144
|
+
console.log(`Session closed for user ${this.data.userId}`);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
108
147
|
}
|
|
109
148
|
}
|
|
110
149
|
```
|
|
@@ -115,7 +154,7 @@ class ChatSession extends BaseSession<
|
|
|
115
154
|
import { BaseWebSocketDO } from '@firtoz/websocket-do';
|
|
116
155
|
import { Hono } from 'hono';
|
|
117
156
|
|
|
118
|
-
export class ChatRoomDO extends BaseWebSocketDO<
|
|
157
|
+
export class ChatRoomDO extends BaseWebSocketDO<ChatSession, Env> {
|
|
119
158
|
app = this.getBaseApp()
|
|
120
159
|
.get('/info', (ctx) => {
|
|
121
160
|
return ctx.json({
|
|
@@ -123,8 +162,12 @@ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
|
|
|
123
162
|
});
|
|
124
163
|
});
|
|
125
164
|
|
|
126
|
-
|
|
127
|
-
|
|
165
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
166
|
+
super(ctx, env, {
|
|
167
|
+
createSession: (ctx, websocket) => {
|
|
168
|
+
return new ChatSession(websocket, this.sessions);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
128
171
|
}
|
|
129
172
|
}
|
|
130
173
|
```
|
|
@@ -342,39 +385,42 @@ interface SessionData {
|
|
|
342
385
|
|
|
343
386
|
// Implement validated session
|
|
344
387
|
class ChatSession extends ZodSession<
|
|
345
|
-
Env,
|
|
346
388
|
SessionData,
|
|
347
389
|
ServerMessage,
|
|
348
|
-
ClientMessage
|
|
390
|
+
ClientMessage,
|
|
391
|
+
Env
|
|
349
392
|
> {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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;
|
|
393
|
+
constructor(
|
|
394
|
+
websocket: WebSocket,
|
|
395
|
+
sessions: Map<WebSocket, ChatSession>,
|
|
396
|
+
options: ZodSessionOptions<ClientMessage, ServerMessage>
|
|
397
|
+
) {
|
|
398
|
+
super(websocket, sessions, options, {
|
|
399
|
+
createData: (ctx) => ({ name: 'Anonymous' }),
|
|
365
400
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
401
|
+
handleValidatedMessage: async (message) => {
|
|
402
|
+
// Message is already validated!
|
|
403
|
+
switch (message.type) {
|
|
404
|
+
case 'setName':
|
|
405
|
+
this.data.name = message.name;
|
|
406
|
+
this.update();
|
|
407
|
+
this.send({ type: 'nameChanged', newName: message.name });
|
|
408
|
+
break;
|
|
409
|
+
|
|
410
|
+
case 'message':
|
|
411
|
+
this.broadcast({
|
|
412
|
+
type: 'message',
|
|
413
|
+
text: message.text,
|
|
414
|
+
from: this.data.name,
|
|
415
|
+
});
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
handleClose: async () => {
|
|
421
|
+
console.log(`${this.data.name} disconnected`);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
378
424
|
}
|
|
379
425
|
}
|
|
380
426
|
```
|
|
@@ -383,34 +429,61 @@ class ChatSession extends ZodSession<
|
|
|
383
429
|
|
|
384
430
|
```typescript
|
|
385
431
|
class ChatSession extends ZodSession<...> {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
432
|
+
constructor(
|
|
433
|
+
websocket: WebSocket,
|
|
434
|
+
sessions: Map<WebSocket, ChatSession>
|
|
435
|
+
) {
|
|
436
|
+
super(websocket, sessions, {
|
|
437
|
+
clientSchema: ClientMessageSchema,
|
|
438
|
+
serverSchema: ServerMessageSchema,
|
|
439
|
+
enableBufferMessages: true, // Enable buffer mode for msgpack
|
|
440
|
+
}, {
|
|
441
|
+
createData: (ctx) => ({ name: 'Anonymous' }),
|
|
442
|
+
handleValidatedMessage: async (message) => {
|
|
443
|
+
// Messages automatically decoded from msgpack
|
|
444
|
+
// Handle validated message
|
|
445
|
+
},
|
|
446
|
+
handleClose: async () => {
|
|
447
|
+
console.log('Session closed');
|
|
448
|
+
},
|
|
449
|
+
});
|
|
395
450
|
}
|
|
396
451
|
}
|
|
397
452
|
```
|
|
398
453
|
|
|
399
454
|
## API Reference
|
|
400
455
|
|
|
401
|
-
### `BaseWebSocketDO<
|
|
456
|
+
### `BaseWebSocketDO<TSession, TEnv>`
|
|
402
457
|
|
|
403
|
-
|
|
458
|
+
Base class for creating WebSocket-enabled Durable Objects. Uses composition instead of inheritance.
|
|
404
459
|
|
|
405
460
|
#### Type Parameters
|
|
406
461
|
|
|
407
|
-
- `TEnv` - Your Cloudflare Worker environment bindings
|
|
408
462
|
- `TSession` - Your session class extending `BaseSession`
|
|
463
|
+
- `TEnv` - Your Cloudflare Worker environment bindings
|
|
409
464
|
|
|
410
|
-
####
|
|
465
|
+
#### Constructor
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
constructor(
|
|
469
|
+
ctx: DurableObjectState,
|
|
470
|
+
env: TEnv,
|
|
471
|
+
options: BaseWebSocketDOOptions<TSession, TEnv>
|
|
472
|
+
)
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
#### Options Type
|
|
476
|
+
|
|
477
|
+
```typescript
|
|
478
|
+
type BaseWebSocketDOOptions<TSession, TEnv> = {
|
|
479
|
+
createSession: (
|
|
480
|
+
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
481
|
+
websocket: WebSocket
|
|
482
|
+
) => TSession | Promise<TSession>;
|
|
483
|
+
};
|
|
484
|
+
```
|
|
411
485
|
|
|
412
|
-
|
|
413
|
-
- Factory method to create session instances
|
|
486
|
+
#### Methods
|
|
414
487
|
|
|
415
488
|
- `getBaseApp(): Hono`
|
|
416
489
|
- Returns a base Hono app with `/websocket` endpoint configured
|
|
@@ -423,35 +496,44 @@ Abstract class for creating WebSocket-enabled Durable Objects.
|
|
|
423
496
|
- `sessions: Map<WebSocket, TSession>` - Map of all active sessions
|
|
424
497
|
- `app: Hono` - Your Hono application (must be implemented)
|
|
425
498
|
|
|
426
|
-
### `BaseSession<
|
|
499
|
+
### `BaseSession<TData, TServerMessage, TClientMessage, TEnv>`
|
|
427
500
|
|
|
428
|
-
|
|
501
|
+
Concrete class for managing individual WebSocket sessions. Uses composition pattern with handlers.
|
|
429
502
|
|
|
430
503
|
#### Type Parameters
|
|
431
504
|
|
|
432
|
-
- `TEnv` - Your Cloudflare Worker environment bindings
|
|
433
505
|
- `TData` - Type of data stored in the session
|
|
434
506
|
- `TServerMessage` - Union type of messages sent to clients
|
|
435
507
|
- `TClientMessage` - Union type of messages received from clients
|
|
508
|
+
- `TEnv` - Your Cloudflare Worker environment bindings (default: `Cloudflare.Env`)
|
|
436
509
|
|
|
437
|
-
####
|
|
510
|
+
#### Constructor
|
|
438
511
|
|
|
439
|
-
|
|
440
|
-
|
|
512
|
+
```typescript
|
|
513
|
+
constructor(
|
|
514
|
+
websocket: WebSocket,
|
|
515
|
+
sessions: Map<WebSocket, BaseSession<TData, TServerMessage, TClientMessage, TEnv>>,
|
|
516
|
+
handlers: BaseSessionHandlers<TData, TServerMessage, TClientMessage, TEnv>
|
|
517
|
+
)
|
|
518
|
+
```
|
|
441
519
|
|
|
442
|
-
|
|
443
|
-
- Handles text messages from client
|
|
520
|
+
#### Handlers Type
|
|
444
521
|
|
|
445
|
-
|
|
446
|
-
|
|
522
|
+
```typescript
|
|
523
|
+
type BaseSessionHandlers<TData, TServerMessage, TClientMessage, TEnv> = {
|
|
524
|
+
createData: (ctx: Context<{ Bindings: TEnv }>) => TData;
|
|
525
|
+
handleMessage: (message: TClientMessage) => Promise<void>;
|
|
526
|
+
handleBufferMessage: (message: ArrayBuffer) => Promise<void>;
|
|
527
|
+
handleClose: () => Promise<void>;
|
|
528
|
+
};
|
|
529
|
+
```
|
|
447
530
|
|
|
448
|
-
|
|
449
|
-
- Cleanup when session closes
|
|
531
|
+
#### Methods
|
|
450
532
|
|
|
451
|
-
- `
|
|
533
|
+
- `send(message: TServerMessage): void`
|
|
452
534
|
- Send message to this session's client
|
|
453
535
|
|
|
454
|
-
- `
|
|
536
|
+
- `broadcast(message: TServerMessage, excludeSelf?: boolean): void`
|
|
455
537
|
- Send message to all connected sessions
|
|
456
538
|
|
|
457
539
|
- `startFresh(ctx: Context): void`
|
|
@@ -490,7 +572,7 @@ Low-level wrapper for typed WebSocket operations.
|
|
|
490
572
|
You can extend the base app with custom routes:
|
|
491
573
|
|
|
492
574
|
```typescript
|
|
493
|
-
export class ChatRoomDO extends BaseWebSocketDO<
|
|
575
|
+
export class ChatRoomDO extends BaseWebSocketDO<ChatSession, Env> {
|
|
494
576
|
app = this.getBaseApp()
|
|
495
577
|
.get('/stats', (ctx) => {
|
|
496
578
|
const users = Array.from(this.sessions.values()).map(s => ({
|
|
@@ -509,6 +591,14 @@ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
|
|
|
509
591
|
|
|
510
592
|
return ctx.json({ success: true });
|
|
511
593
|
});
|
|
594
|
+
|
|
595
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
596
|
+
super(ctx, env, {
|
|
597
|
+
createSession: (ctx, websocket) => {
|
|
598
|
+
return new ChatSession(websocket, this.sessions);
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
}
|
|
512
602
|
}
|
|
513
603
|
```
|
|
514
604
|
|
|
@@ -517,25 +607,38 @@ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
|
|
|
517
607
|
Session data is automatically serialized and persists across hibernation:
|
|
518
608
|
|
|
519
609
|
```typescript
|
|
520
|
-
class GameSession extends BaseSession<
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
610
|
+
class GameSession extends BaseSession<GameData, ServerMsg, ClientMsg, Env> {
|
|
611
|
+
constructor(
|
|
612
|
+
websocket: WebSocket,
|
|
613
|
+
sessions: Map<WebSocket, GameSession>
|
|
614
|
+
) {
|
|
615
|
+
super(websocket, sessions, {
|
|
616
|
+
createData: (ctx) => ({
|
|
617
|
+
playerName: ctx.req.query('name') || 'Anonymous',
|
|
618
|
+
score: 0,
|
|
619
|
+
inventory: [],
|
|
620
|
+
}),
|
|
621
|
+
|
|
622
|
+
handleMessage: async (message) => {
|
|
623
|
+
if (message.type === 'collectItem') {
|
|
624
|
+
this.data.inventory.push(message.item);
|
|
625
|
+
this.data.score += 10;
|
|
626
|
+
|
|
627
|
+
// Persist changes
|
|
628
|
+
this.update();
|
|
629
|
+
|
|
630
|
+
this.send({ type: 'scoreUpdate', score: this.data.score });
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
handleBufferMessage: async (message) => {
|
|
635
|
+
// Handle buffer messages if needed
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
handleClose: async () => {
|
|
639
|
+
console.log('Game session closed');
|
|
640
|
+
},
|
|
641
|
+
});
|
|
539
642
|
}
|
|
540
643
|
}
|
|
541
644
|
```
|
|
@@ -545,21 +648,40 @@ class GameSession extends BaseSession<Env, GameData, ServerMsg, ClientMsg> {
|
|
|
545
648
|
Errors in message handlers are caught and logged, but don't crash the connection:
|
|
546
649
|
|
|
547
650
|
```typescript
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
651
|
+
class MySession extends BaseSession<...> {
|
|
652
|
+
constructor(
|
|
653
|
+
websocket: WebSocket,
|
|
654
|
+
sessions: Map<WebSocket, MySession>
|
|
655
|
+
) {
|
|
656
|
+
super(websocket, sessions, {
|
|
657
|
+
createData: (ctx) => ({ /* ... */ }),
|
|
658
|
+
|
|
659
|
+
handleMessage: async (message) => {
|
|
660
|
+
try {
|
|
661
|
+
// Your logic here
|
|
662
|
+
if (message.type === 'dangerous') {
|
|
663
|
+
throw new Error('Invalid operation');
|
|
664
|
+
}
|
|
665
|
+
} catch (error) {
|
|
666
|
+
// Send error to client
|
|
667
|
+
this.send({
|
|
668
|
+
type: 'error',
|
|
669
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// Optionally close the connection
|
|
673
|
+
this.websocket.close(1008, 'Policy violation');
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
|
|
677
|
+
handleBufferMessage: async (message) => {
|
|
678
|
+
// Handle buffer messages
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
handleClose: async () => {
|
|
682
|
+
console.log('Session closed');
|
|
683
|
+
},
|
|
559
684
|
});
|
|
560
|
-
|
|
561
|
-
// Optionally close the connection
|
|
562
|
-
this.websocket.close(1008, 'Policy violation');
|
|
563
685
|
}
|
|
564
686
|
}
|
|
565
687
|
```
|
|
@@ -569,18 +691,25 @@ async handleMessage(message: ClientMessage): Promise<void> {
|
|
|
569
691
|
This package exports the following:
|
|
570
692
|
|
|
571
693
|
### Classes
|
|
572
|
-
- `BaseWebSocketDO` -
|
|
573
|
-
- `BaseSession` -
|
|
694
|
+
- `BaseWebSocketDO` - Base class for WebSocket Durable Objects (composition-based)
|
|
695
|
+
- `BaseSession` - Concrete session class with handler injection
|
|
574
696
|
- `ZodWebSocketClient` - Type-safe WebSocket client with Zod validation
|
|
575
|
-
- `ZodSession` -
|
|
697
|
+
- `ZodSession` - Concrete session class with Zod validation and handler injection
|
|
698
|
+
- `ZodWebSocketDO` - Base class for WebSocket DOs with Zod validation
|
|
576
699
|
- `WebsocketWrapper` - Low-level WebSocket wrapper with typed attachments
|
|
577
700
|
|
|
701
|
+
### Types
|
|
702
|
+
- `BaseSessionHandlers` - Handler interface for `BaseSession`
|
|
703
|
+
- `BaseWebSocketDOOptions` - Options interface for `BaseWebSocketDO`
|
|
704
|
+
- `ZodSessionHandlers` - Handler interface for `ZodSession`
|
|
705
|
+
- `ZodSessionOptions` - Options interface for `ZodSession`
|
|
706
|
+
- `ZodSessionOptionsOrFactory` - Options or factory function for `ZodSession`
|
|
707
|
+
- `ZodWebSocketDOOptions` - Options interface for `ZodWebSocketDO`
|
|
708
|
+
- `ZodWebSocketClientOptions` - Options interface for `ZodWebSocketClient`
|
|
709
|
+
|
|
578
710
|
### Utilities
|
|
579
711
|
- `zodMsgpack` - Msgpack encode/decode with Zod validation
|
|
580
712
|
|
|
581
|
-
### Types
|
|
582
|
-
All classes export their type parameters and interfaces for custom implementations.
|
|
583
|
-
|
|
584
713
|
## Complete Example
|
|
585
714
|
|
|
586
715
|
Here's a full example combining all features:
|
|
@@ -605,7 +734,7 @@ export type ClientMessage = z.infer<typeof ClientMessageSchema>;
|
|
|
605
734
|
export type ServerMessage = z.infer<typeof ServerMessageSchema>;
|
|
606
735
|
|
|
607
736
|
// do.ts - Server-side (Durable Object)
|
|
608
|
-
import { BaseWebSocketDO, ZodSession } from '@firtoz/websocket-do';
|
|
737
|
+
import { BaseWebSocketDO, ZodSession, type ZodSessionOptions } from '@firtoz/websocket-do';
|
|
609
738
|
import { ClientMessageSchema, ServerMessageSchema } from './schemas';
|
|
610
739
|
|
|
611
740
|
interface SessionData {
|
|
@@ -613,45 +742,47 @@ interface SessionData {
|
|
|
613
742
|
joinedAt: number;
|
|
614
743
|
}
|
|
615
744
|
|
|
616
|
-
class ChatSession extends ZodSession<
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
745
|
+
class ChatSession extends ZodSession<SessionData, ServerMessage, ClientMessage, Env> {
|
|
746
|
+
constructor(
|
|
747
|
+
websocket: WebSocket,
|
|
748
|
+
sessions: Map<WebSocket, ChatSession>,
|
|
749
|
+
options: ZodSessionOptions<ClientMessage, ServerMessage>
|
|
750
|
+
) {
|
|
751
|
+
super(websocket, sessions, options, {
|
|
752
|
+
createData: () => ({
|
|
753
|
+
name: 'Anonymous',
|
|
754
|
+
joinedAt: Date.now(),
|
|
755
|
+
}),
|
|
756
|
+
|
|
757
|
+
handleValidatedMessage: async (message) => {
|
|
758
|
+
switch (message.type) {
|
|
759
|
+
case 'setName':
|
|
760
|
+
const oldName = this.data.name;
|
|
761
|
+
this.data.name = message.name;
|
|
762
|
+
this.update();
|
|
763
|
+
|
|
764
|
+
this.send({ type: 'nameChanged', newName: message.name });
|
|
765
|
+
this.broadcast({ type: 'userJoined', name: message.name }, true);
|
|
766
|
+
break;
|
|
767
|
+
|
|
768
|
+
case 'message':
|
|
769
|
+
this.broadcast({
|
|
770
|
+
type: 'message',
|
|
771
|
+
text: message.text,
|
|
772
|
+
from: this.data.name,
|
|
773
|
+
});
|
|
774
|
+
break;
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
|
|
778
|
+
handleClose: async () => {
|
|
779
|
+
console.log(`${this.data.name} disconnected`);
|
|
780
|
+
},
|
|
781
|
+
});
|
|
651
782
|
}
|
|
652
783
|
}
|
|
653
784
|
|
|
654
|
-
export class ChatRoomDO extends BaseWebSocketDO<
|
|
785
|
+
export class ChatRoomDO extends BaseWebSocketDO<ChatSession, Env> {
|
|
655
786
|
app = this.getBaseApp()
|
|
656
787
|
.get('/info', (ctx) => {
|
|
657
788
|
const users = Array.from(this.sessions.values()).map(s => ({
|
|
@@ -661,8 +792,16 @@ export class ChatRoomDO extends BaseWebSocketDO<Env, ChatSession> {
|
|
|
661
792
|
return ctx.json({ users, count: users.length });
|
|
662
793
|
});
|
|
663
794
|
|
|
664
|
-
|
|
665
|
-
|
|
795
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
796
|
+
super(ctx, env, {
|
|
797
|
+
createSession: (ctx, websocket) => {
|
|
798
|
+
return new ChatSession(websocket, this.sessions, {
|
|
799
|
+
clientSchema: ClientMessageSchema,
|
|
800
|
+
serverSchema: ServerMessageSchema,
|
|
801
|
+
enableBufferMessages: true, // Use msgpack for efficiency
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
});
|
|
666
805
|
}
|
|
667
806
|
}
|
|
668
807
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firtoz/websocket-do",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.1",
|
|
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",
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"url": "https://github.com/firtoz/fullstack-toolkit/issues"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@cloudflare/workers-types": "^4.
|
|
49
|
-
"@firtoz/hono-fetcher": "^2.3.
|
|
50
|
-
"hono": "^4.
|
|
48
|
+
"@cloudflare/workers-types": "^4.20251228.0",
|
|
49
|
+
"@firtoz/hono-fetcher": "^2.3.2",
|
|
50
|
+
"hono": "^4.11.4"
|
|
51
51
|
},
|
|
52
52
|
"optionalDependencies": {
|
|
53
|
-
"msgpackr": "^1.11.
|
|
54
|
-
"react": "^19.2.
|
|
55
|
-
"zod": "^4.
|
|
53
|
+
"msgpackr": "^1.11.8",
|
|
54
|
+
"react": "^19.2.3",
|
|
55
|
+
"zod": "^4.3.5"
|
|
56
56
|
},
|
|
57
57
|
"engines": {
|
|
58
58
|
"node": ">=18.0.0"
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"access": "public"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@types/react": "^19.2.
|
|
65
|
-
"bun-types": "^1.3.
|
|
64
|
+
"@types/react": "^19.2.8",
|
|
65
|
+
"bun-types": "^1.3.6",
|
|
66
66
|
"typescript": "^5.9.3"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/src/BaseSession.ts
CHANGED
|
@@ -1,20 +1,73 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
2
|
import { WebsocketWrapper } from "./WebsocketWrapper";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
5
|
+
export type SessionData<TSession extends BaseSession<any, any, any, any>> =
|
|
6
|
+
TSession extends BaseSession<
|
|
7
|
+
infer TData,
|
|
8
|
+
infer _TServerMessage,
|
|
9
|
+
infer _TClientMessage,
|
|
10
|
+
infer _TEnv
|
|
11
|
+
>
|
|
12
|
+
? TData
|
|
13
|
+
: never;
|
|
14
|
+
|
|
15
|
+
export type SessionClientMessage<
|
|
16
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
17
|
+
TSession extends BaseSession<any, any, any, any>,
|
|
18
|
+
> =
|
|
19
|
+
TSession extends BaseSession<
|
|
20
|
+
infer _TData,
|
|
21
|
+
infer _TServerMessage,
|
|
22
|
+
infer TClientMessage,
|
|
23
|
+
infer _TEnv
|
|
24
|
+
>
|
|
6
25
|
? TClientMessage
|
|
7
26
|
: never;
|
|
8
27
|
|
|
9
|
-
export
|
|
10
|
-
// biome-ignore lint/suspicious/noExplicitAny:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
export type SessionServerMessage<
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
30
|
+
TSession extends BaseSession<any, any, any, any>,
|
|
31
|
+
> =
|
|
32
|
+
TSession extends BaseSession<
|
|
33
|
+
infer _TData,
|
|
34
|
+
infer TServerMessage,
|
|
35
|
+
infer _TClientMessage,
|
|
36
|
+
infer _TEnv
|
|
37
|
+
>
|
|
38
|
+
? TServerMessage
|
|
39
|
+
: never;
|
|
40
|
+
|
|
41
|
+
export type SessionEnv<
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
43
|
+
TSession extends BaseSession<any, any, any, any>,
|
|
44
|
+
> =
|
|
45
|
+
TSession extends BaseSession<
|
|
46
|
+
infer _TData,
|
|
47
|
+
infer _TServerMessage,
|
|
48
|
+
infer _TClientMessage,
|
|
49
|
+
infer TEnv extends Cloudflare.Env
|
|
50
|
+
>
|
|
51
|
+
? TEnv
|
|
52
|
+
: never;
|
|
53
|
+
|
|
54
|
+
export type BaseSessionHandlers<
|
|
55
|
+
TData,
|
|
56
|
+
_TServerMessage,
|
|
57
|
+
TClientMessage,
|
|
58
|
+
TEnv extends object = Cloudflare.Env,
|
|
59
|
+
> = {
|
|
60
|
+
createData: (ctx: Context<{ Bindings: TEnv }>) => TData;
|
|
61
|
+
handleMessage: (message: TClientMessage) => Promise<void>;
|
|
62
|
+
handleBufferMessage: (message: ArrayBuffer) => Promise<void>;
|
|
63
|
+
handleClose: () => Promise<void>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export class BaseSession<
|
|
67
|
+
TData,
|
|
68
|
+
TServerMessage,
|
|
69
|
+
TClientMessage,
|
|
70
|
+
TEnv extends object = Cloudflare.Env,
|
|
18
71
|
> {
|
|
19
72
|
private _data!: TData;
|
|
20
73
|
|
|
@@ -27,19 +80,27 @@ export abstract class BaseSession<
|
|
|
27
80
|
}
|
|
28
81
|
|
|
29
82
|
private readonly wrapper: WebsocketWrapper<TData, TServerMessage>;
|
|
83
|
+
protected readonly handlers: BaseSessionHandlers<
|
|
84
|
+
TData,
|
|
85
|
+
TServerMessage,
|
|
86
|
+
TClientMessage,
|
|
87
|
+
TEnv
|
|
88
|
+
>;
|
|
30
89
|
|
|
31
90
|
constructor(
|
|
32
91
|
public websocket: WebSocket,
|
|
33
92
|
protected sessions: Map<
|
|
34
93
|
WebSocket,
|
|
35
|
-
BaseSession<
|
|
94
|
+
BaseSession<TData, TServerMessage, TClientMessage, TEnv>
|
|
36
95
|
>,
|
|
96
|
+
handlers: BaseSessionHandlers<TData, TServerMessage, TClientMessage, TEnv>,
|
|
37
97
|
) {
|
|
38
98
|
this.wrapper = new WebsocketWrapper<TData, TServerMessage>(websocket);
|
|
99
|
+
this.handlers = handlers;
|
|
39
100
|
}
|
|
40
101
|
|
|
41
102
|
public startFresh(ctx: Context<{ Bindings: TEnv }>) {
|
|
42
|
-
this.data = this.createData(ctx);
|
|
103
|
+
this.data = this.handlers.createData(ctx);
|
|
43
104
|
this.wrapper.serializeAttachment(this.data);
|
|
44
105
|
}
|
|
45
106
|
|
|
@@ -56,20 +117,26 @@ export abstract class BaseSession<
|
|
|
56
117
|
this.wrapper.serializeAttachment(this.data);
|
|
57
118
|
}
|
|
58
119
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
protected broadcast(message: TServerMessage, excludeSelf = false) {
|
|
120
|
+
public broadcast(message: TServerMessage, excludeSelf = false) {
|
|
62
121
|
for (const session of this.sessions.values()) {
|
|
63
122
|
if (excludeSelf && session === this) continue;
|
|
64
123
|
session.send(message);
|
|
65
124
|
}
|
|
66
125
|
}
|
|
67
126
|
|
|
68
|
-
|
|
127
|
+
public send(message: TServerMessage) {
|
|
69
128
|
this.wrapper.send(message);
|
|
70
129
|
}
|
|
71
130
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
131
|
+
async handleMessage(message: TClientMessage): Promise<void> {
|
|
132
|
+
return this.handlers.handleMessage(message);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async handleBufferMessage(message: ArrayBuffer): Promise<void> {
|
|
136
|
+
return this.handlers.handleBufferMessage(message);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async handleClose(): Promise<void> {
|
|
140
|
+
return this.handlers.handleClose();
|
|
141
|
+
}
|
|
75
142
|
}
|
package/src/BaseWebSocketDO.ts
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
import type { DOWithHonoApp } from "@firtoz/hono-fetcher/honoDoFetcher";
|
|
3
3
|
import { type Context, Hono } from "hono";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
BaseSession,
|
|
6
|
+
SessionClientMessage,
|
|
7
|
+
SessionEnv,
|
|
8
|
+
} from "./BaseSession";
|
|
9
|
+
|
|
10
|
+
export type BaseWebSocketDOOptions<
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
12
|
+
TSession extends BaseSession<any, any, any, any>,
|
|
13
|
+
TEnv extends SessionEnv<TSession>,
|
|
14
|
+
> = {
|
|
15
|
+
createSession: (
|
|
16
|
+
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
17
|
+
websocket: WebSocket,
|
|
18
|
+
) => TSession | Promise<TSession>;
|
|
19
|
+
};
|
|
5
20
|
|
|
6
21
|
export abstract class BaseWebSocketDO<
|
|
7
|
-
|
|
8
|
-
TSession extends BaseSession<
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
23
|
+
TSession extends BaseSession<any, any, any, any> = BaseSession<
|
|
24
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
25
|
+
any,
|
|
26
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
27
|
+
any,
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
29
|
+
any,
|
|
30
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
31
|
+
any
|
|
32
|
+
>,
|
|
33
|
+
TEnv extends SessionEnv<TSession> = SessionEnv<TSession>,
|
|
9
34
|
>
|
|
10
35
|
extends DurableObject<TEnv>
|
|
11
36
|
implements DOWithHonoApp
|
|
12
37
|
{
|
|
13
38
|
protected readonly sessions = new Map<WebSocket, TSession>();
|
|
39
|
+
abstract readonly app: Hono<{ Bindings: TEnv }>;
|
|
14
40
|
|
|
15
|
-
constructor(
|
|
41
|
+
constructor(
|
|
42
|
+
ctx: DurableObjectState,
|
|
43
|
+
env: TEnv,
|
|
44
|
+
private readonly options: BaseWebSocketDOOptions<TSession, TEnv>,
|
|
45
|
+
) {
|
|
16
46
|
super(ctx, env);
|
|
17
47
|
|
|
18
48
|
this.ctx.blockConcurrencyWhile(async () => {
|
|
@@ -20,7 +50,9 @@ export abstract class BaseWebSocketDO<
|
|
|
20
50
|
await Promise.all(
|
|
21
51
|
websockets.map(async (websocket) => {
|
|
22
52
|
try {
|
|
23
|
-
|
|
53
|
+
// For resumed sessions, we don't have a Hono context
|
|
54
|
+
// Pass undefined and let implementers handle it
|
|
55
|
+
const session = await options.createSession(undefined, websocket);
|
|
24
56
|
session.resume();
|
|
25
57
|
this.sessions.set(websocket, session);
|
|
26
58
|
} catch (error) {
|
|
@@ -65,19 +97,13 @@ export abstract class BaseWebSocketDO<
|
|
|
65
97
|
);
|
|
66
98
|
}
|
|
67
99
|
|
|
68
|
-
abstract app: Hono<{ Bindings: TEnv }>;
|
|
69
|
-
|
|
70
|
-
protected abstract createSession(
|
|
71
|
-
websocket: WebSocket,
|
|
72
|
-
): TSession | Promise<TSession>;
|
|
73
|
-
|
|
74
100
|
async handleSession(
|
|
75
101
|
ctx: Context<{ Bindings: TEnv }>,
|
|
76
102
|
ws: WebSocket,
|
|
77
103
|
): Promise<void> {
|
|
78
104
|
this.ctx.acceptWebSocket(ws);
|
|
79
105
|
try {
|
|
80
|
-
const session = await this.createSession(ws);
|
|
106
|
+
const session = await this.options.createSession(ctx, ws);
|
|
81
107
|
session.startFresh(ctx);
|
|
82
108
|
this.sessions.set(ws, session);
|
|
83
109
|
} catch (error) {
|
package/src/ZodSession.ts
CHANGED
|
@@ -1,44 +1,77 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
1
2
|
import type { ZodType } from "zod";
|
|
2
3
|
import { BaseSession } from "./BaseSession";
|
|
3
4
|
import { zodMsgpack } from "./zodMsgpack";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
+
export type ZodSessionOptions<TClientMessage, TServerMessage> = {
|
|
6
7
|
clientSchema: ZodType<TClientMessage>;
|
|
7
8
|
serverSchema: ZodType<TServerMessage>;
|
|
8
9
|
enableBufferMessages?: boolean;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
>
|
|
21
|
-
|
|
22
|
-
|
|
10
|
+
sendProtocolError?: (
|
|
11
|
+
websocket: WebSocket,
|
|
12
|
+
errorMessage: string,
|
|
13
|
+
) => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ZodSessionHandlers<
|
|
17
|
+
TData,
|
|
18
|
+
_TServerMessage,
|
|
19
|
+
TClientMessage,
|
|
20
|
+
TEnv extends object,
|
|
21
|
+
> = {
|
|
22
|
+
createData: (ctx: Context<{ Bindings: TEnv }>) => TData;
|
|
23
|
+
handleValidatedMessage: (message: TClientMessage) => Promise<void>;
|
|
24
|
+
handleValidationError?: (
|
|
25
|
+
error: unknown,
|
|
26
|
+
originalMessage: unknown,
|
|
27
|
+
) => Promise<void>;
|
|
28
|
+
handleClose: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export class ZodSession<
|
|
32
|
+
TData,
|
|
33
|
+
TServerMessage,
|
|
34
|
+
TClientMessage,
|
|
35
|
+
TEnv extends object = Cloudflare.Env,
|
|
36
|
+
> extends BaseSession<TData, TServerMessage, TClientMessage, TEnv> {
|
|
37
|
+
private readonly clientCodec: ReturnType<typeof zodMsgpack<TClientMessage>>;
|
|
38
|
+
private readonly serverCodec: ReturnType<typeof zodMsgpack<TServerMessage>>;
|
|
23
39
|
protected readonly enableBufferMessages: boolean;
|
|
24
40
|
|
|
25
41
|
constructor(
|
|
26
42
|
websocket: WebSocket,
|
|
27
43
|
sessions: Map<
|
|
28
44
|
WebSocket,
|
|
29
|
-
ZodSession<
|
|
45
|
+
ZodSession<TData, TServerMessage, TClientMessage, TEnv>
|
|
46
|
+
>,
|
|
47
|
+
private readonly options: ZodSessionOptions<TClientMessage, TServerMessage>,
|
|
48
|
+
private readonly zodHandlers: ZodSessionHandlers<
|
|
49
|
+
TData,
|
|
50
|
+
TServerMessage,
|
|
51
|
+
TClientMessage,
|
|
52
|
+
TEnv
|
|
30
53
|
>,
|
|
31
|
-
protected options: ZodSessionOptions<TClientMessage, TServerMessage>,
|
|
32
54
|
) {
|
|
33
|
-
super(websocket, sessions
|
|
55
|
+
super(websocket, sessions, {
|
|
56
|
+
createData: zodHandlers.createData,
|
|
57
|
+
handleMessage: async (message) => {
|
|
58
|
+
return this._internalHandleMessage(message);
|
|
59
|
+
},
|
|
60
|
+
handleBufferMessage: async (message) => {
|
|
61
|
+
return this._internalHandleBufferMessage(message);
|
|
62
|
+
},
|
|
63
|
+
handleClose: async () => {
|
|
64
|
+
return zodHandlers.handleClose();
|
|
65
|
+
},
|
|
66
|
+
});
|
|
34
67
|
|
|
35
68
|
this.clientCodec = zodMsgpack(options.clientSchema);
|
|
36
69
|
this.serverCodec = zodMsgpack(options.serverSchema);
|
|
37
70
|
this.enableBufferMessages = options.enableBufferMessages ?? false;
|
|
38
71
|
}
|
|
39
72
|
|
|
40
|
-
//
|
|
41
|
-
async
|
|
73
|
+
// Internal method used by the base class handlers
|
|
74
|
+
private async _internalHandleMessage(message: TClientMessage): Promise<void> {
|
|
42
75
|
// If buffer messages are enabled, reject JSON messages
|
|
43
76
|
if (this.enableBufferMessages) {
|
|
44
77
|
console.error(
|
|
@@ -53,15 +86,17 @@ export abstract class ZodSession<
|
|
|
53
86
|
try {
|
|
54
87
|
// Validate the message using the client schema
|
|
55
88
|
const validatedMessage = this.options.clientSchema.parse(message);
|
|
56
|
-
await this.handleValidatedMessage(validatedMessage);
|
|
89
|
+
await this.zodHandlers.handleValidatedMessage(validatedMessage);
|
|
57
90
|
} catch (error) {
|
|
58
91
|
console.error("Invalid client message received:", error);
|
|
59
|
-
await this.
|
|
92
|
+
await this._internalHandleValidationError(error, message);
|
|
60
93
|
}
|
|
61
94
|
}
|
|
62
95
|
|
|
63
|
-
//
|
|
64
|
-
async
|
|
96
|
+
// Internal method used by the base class handlers
|
|
97
|
+
private async _internalHandleBufferMessage(
|
|
98
|
+
buffer: ArrayBuffer,
|
|
99
|
+
): Promise<void> {
|
|
65
100
|
// If buffer messages are disabled, reject buffer messages
|
|
66
101
|
if (!this.enableBufferMessages) {
|
|
67
102
|
console.error(
|
|
@@ -75,15 +110,33 @@ export abstract class ZodSession<
|
|
|
75
110
|
try {
|
|
76
111
|
const bytes = new Uint8Array(buffer);
|
|
77
112
|
const decodedMessage = this.clientCodec.decode(bytes);
|
|
78
|
-
await this.handleValidatedMessage(decodedMessage);
|
|
113
|
+
await this.zodHandlers.handleValidatedMessage(decodedMessage);
|
|
79
114
|
} catch (error) {
|
|
80
115
|
console.error("Failed to decode buffer message:", error);
|
|
81
|
-
await this.
|
|
116
|
+
await this._internalHandleValidationError(error, buffer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Internal validation error handler
|
|
121
|
+
private async _internalHandleValidationError(
|
|
122
|
+
error: unknown,
|
|
123
|
+
originalMessage: unknown,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
if (this.zodHandlers.handleValidationError) {
|
|
126
|
+
await this.zodHandlers.handleValidationError(error, originalMessage);
|
|
127
|
+
} else {
|
|
128
|
+
// Default implementation logs and continues
|
|
129
|
+
console.error(
|
|
130
|
+
"Validation error:",
|
|
131
|
+
error,
|
|
132
|
+
"Original message:",
|
|
133
|
+
originalMessage,
|
|
134
|
+
);
|
|
82
135
|
}
|
|
83
136
|
}
|
|
84
137
|
|
|
85
138
|
// Type-safe send method that automatically uses the correct format
|
|
86
|
-
|
|
139
|
+
public send(message: TServerMessage): void {
|
|
87
140
|
if (this.enableBufferMessages) {
|
|
88
141
|
this.sendBuffer(message);
|
|
89
142
|
} else {
|
|
@@ -118,12 +171,17 @@ export abstract class ZodSession<
|
|
|
118
171
|
}
|
|
119
172
|
}
|
|
120
173
|
|
|
121
|
-
// Send a protocol error message (always as JSON for compatibility)
|
|
174
|
+
// Send a protocol error message (always as JSON for compatibility by default)
|
|
122
175
|
private async sendProtocolError(errorMessage: string): Promise<void> {
|
|
123
176
|
try {
|
|
124
|
-
//
|
|
125
|
-
if (this.
|
|
126
|
-
|
|
177
|
+
// Use custom handler if provided, otherwise use default
|
|
178
|
+
if (this.options.sendProtocolError) {
|
|
179
|
+
await this.options.sendProtocolError(this.websocket, errorMessage);
|
|
180
|
+
} else {
|
|
181
|
+
// Default implementation: send a simple error object - no schema validation needed
|
|
182
|
+
if (this.websocket.readyState !== WebSocket.OPEN) return;
|
|
183
|
+
this.websocket.send(JSON.stringify({ error: errorMessage }));
|
|
184
|
+
}
|
|
127
185
|
} catch (error) {
|
|
128
186
|
console.error("Failed to send protocol error:", error);
|
|
129
187
|
}
|
|
@@ -131,7 +189,7 @@ export abstract class ZodSession<
|
|
|
131
189
|
|
|
132
190
|
// Type-safe broadcast that validates server messages
|
|
133
191
|
// Automatically uses the correct format based on session configuration
|
|
134
|
-
|
|
192
|
+
public broadcast(message: TServerMessage, excludeSelf = false): void {
|
|
135
193
|
for (const session of this.sessions.values()) {
|
|
136
194
|
if (excludeSelf && session === this) continue;
|
|
137
195
|
if (session instanceof ZodSession) {
|
|
@@ -139,23 +197,4 @@ export abstract class ZodSession<
|
|
|
139
197
|
}
|
|
140
198
|
}
|
|
141
199
|
}
|
|
142
|
-
|
|
143
|
-
// Abstract methods for implementers
|
|
144
|
-
protected abstract handleValidatedMessage(
|
|
145
|
-
message: TClientMessage,
|
|
146
|
-
): Promise<void>;
|
|
147
|
-
|
|
148
|
-
// Optional error handling - default implementation logs and continues
|
|
149
|
-
protected async handleValidationError(
|
|
150
|
-
error: unknown,
|
|
151
|
-
originalMessage: unknown,
|
|
152
|
-
): Promise<void> {
|
|
153
|
-
console.error(
|
|
154
|
-
"Validation error:",
|
|
155
|
-
error,
|
|
156
|
-
"Original message:",
|
|
157
|
-
originalMessage,
|
|
158
|
-
);
|
|
159
|
-
// Implementers can override this to send error responses to clients
|
|
160
|
-
}
|
|
161
200
|
}
|
package/src/ZodWebSocketDO.ts
CHANGED
|
@@ -1,27 +1,83 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import type {
|
|
3
|
+
SessionClientMessage,
|
|
4
|
+
SessionEnv,
|
|
5
|
+
SessionServerMessage,
|
|
6
|
+
} from "./BaseSession";
|
|
1
7
|
import { BaseWebSocketDO } from "./BaseWebSocketDO";
|
|
2
8
|
import type { ZodSession, ZodSessionOptions } from "./ZodSession";
|
|
3
9
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
TClientMessage
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
export type ZodSessionOptionsOrFactory<
|
|
11
|
+
TClientMessage,
|
|
12
|
+
TServerMessage,
|
|
13
|
+
TEnv extends Cloudflare.Env = Cloudflare.Env,
|
|
14
|
+
> =
|
|
15
|
+
| ZodSessionOptions<TClientMessage, TServerMessage>
|
|
16
|
+
| ((
|
|
17
|
+
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
18
|
+
websocket: WebSocket,
|
|
19
|
+
) => ZodSessionOptions<TClientMessage, TServerMessage>);
|
|
20
|
+
|
|
21
|
+
export type ZodWebSocketDOOptions<
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
23
|
+
TSession extends ZodSession<any, any, any, any>,
|
|
24
|
+
TClientMessage,
|
|
25
|
+
TServerMessage,
|
|
26
|
+
TEnv extends SessionEnv<TSession>,
|
|
27
|
+
> = {
|
|
28
|
+
zodSessionOptions: ZodSessionOptionsOrFactory<
|
|
14
29
|
TClientMessage,
|
|
15
|
-
TServerMessage
|
|
30
|
+
TServerMessage,
|
|
31
|
+
TEnv
|
|
16
32
|
>;
|
|
33
|
+
createZodSession: (
|
|
34
|
+
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
35
|
+
websocket: WebSocket,
|
|
36
|
+
options: ZodSessionOptions<TClientMessage, TServerMessage>,
|
|
37
|
+
) => TSession | Promise<TSession>;
|
|
38
|
+
};
|
|
17
39
|
|
|
18
|
-
|
|
40
|
+
export abstract class ZodWebSocketDO<
|
|
41
|
+
// biome-ignore lint/suspicious/noExplicitAny: We are using any on purpose to allow any type of session.
|
|
42
|
+
TSession extends ZodSession<any, any, any, any>,
|
|
43
|
+
TClientMessage extends
|
|
44
|
+
SessionClientMessage<TSession> = SessionClientMessage<TSession>,
|
|
45
|
+
TServerMessage extends
|
|
46
|
+
SessionServerMessage<TSession> = SessionServerMessage<TSession>,
|
|
47
|
+
TEnv extends SessionEnv<TSession> = SessionEnv<TSession>,
|
|
48
|
+
> extends BaseWebSocketDO<TSession, TEnv> {
|
|
49
|
+
protected readonly zodSessionOptions: ZodSessionOptionsOrFactory<
|
|
50
|
+
TClientMessage,
|
|
51
|
+
TServerMessage,
|
|
52
|
+
TEnv
|
|
53
|
+
>;
|
|
54
|
+
protected readonly createZodSessionFn: (
|
|
55
|
+
ctx: Context<{ Bindings: TEnv }> | undefined,
|
|
19
56
|
websocket: WebSocket,
|
|
20
57
|
options: ZodSessionOptions<TClientMessage, TServerMessage>,
|
|
21
|
-
)
|
|
58
|
+
) => TSession | Promise<TSession>;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
ctx: DurableObjectState,
|
|
62
|
+
env: TEnv,
|
|
63
|
+
options: ZodWebSocketDOOptions<
|
|
64
|
+
TSession,
|
|
65
|
+
TClientMessage,
|
|
66
|
+
TServerMessage,
|
|
67
|
+
TEnv
|
|
68
|
+
>,
|
|
69
|
+
) {
|
|
70
|
+
super(ctx, env, {
|
|
71
|
+
createSession: (ctx, websocket) => {
|
|
72
|
+
const zodOptions =
|
|
73
|
+
typeof options.zodSessionOptions === "function"
|
|
74
|
+
? options.zodSessionOptions(ctx, websocket)
|
|
75
|
+
: options.zodSessionOptions;
|
|
22
76
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
77
|
+
return options.createZodSession(ctx, websocket, zodOptions);
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
this.zodSessionOptions = options.zodSessionOptions;
|
|
81
|
+
this.createZodSessionFn = options.createZodSession;
|
|
26
82
|
}
|
|
27
83
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
BaseSession,
|
|
3
|
+
type BaseSessionHandlers,
|
|
4
|
+
type SessionClientMessage,
|
|
5
|
+
} from "./BaseSession";
|
|
6
|
+
export {
|
|
7
|
+
BaseWebSocketDO,
|
|
8
|
+
type BaseWebSocketDOOptions,
|
|
9
|
+
} from "./BaseWebSocketDO";
|
|
3
10
|
export { WebsocketWrapper } from "./WebsocketWrapper";
|
|
4
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
ZodSession,
|
|
13
|
+
type ZodSessionHandlers,
|
|
14
|
+
type ZodSessionOptions,
|
|
15
|
+
} from "./ZodSession";
|
|
5
16
|
export {
|
|
6
17
|
ZodWebSocketClient,
|
|
7
18
|
type ZodWebSocketClientOptions,
|
|
8
19
|
} from "./ZodWebSocketClient";
|
|
9
|
-
export {
|
|
20
|
+
export {
|
|
21
|
+
type ZodSessionOptionsOrFactory,
|
|
22
|
+
ZodWebSocketDO,
|
|
23
|
+
type ZodWebSocketDOOptions,
|
|
24
|
+
} from "./ZodWebSocketDO";
|
|
10
25
|
export { zodMsgpack } from "./zodMsgpack";
|