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