@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 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
- class ChatSession extends BaseSession<
74
- Env,
73
+ // Define handlers for your session
74
+ const chatSessionHandlers: BaseSessionHandlers<
75
75
  SessionData,
76
76
  ServerMessage,
77
- ClientMessage
78
- > {
79
- protected createData(ctx: Context<{ Bindings: Env }>): SessionData {
80
- return {
81
- userId: crypto.randomUUID(),
82
- joinedAt: Date.now(),
83
- };
84
- }
85
-
86
- async handleMessage(message: ClientMessage): Promise<void> {
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
- // Broadcast to all sessions
90
- this.broadcast({
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
- this.send({ type: 'welcome', userId: this.data.userId });
93
+ // Send messages
98
94
  break;
99
95
  }
100
- }
96
+ },
101
97
 
102
- async handleBufferMessage(message: ArrayBuffer): Promise<void> {
98
+ handleBufferMessage: async (message: ArrayBuffer) => {
103
99
  // Handle binary messages if needed
104
- }
100
+ },
105
101
 
106
- async handleClose(): Promise<void> {
107
- console.log(`Session closed for user ${this.data.userId}`);
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<Env, ChatSession> {
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
- protected createSession(websocket: WebSocket): ChatSession {
127
- return new ChatSession(websocket, this.sessions);
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
- 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;
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
- 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`);
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
- // 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
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<TEnv, TSession>`
454
+ ### `BaseWebSocketDO<TSession, TEnv>`
402
455
 
403
- Abstract class for creating WebSocket-enabled Durable Objects.
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
- #### Methods
463
+ #### Constructor
464
+
465
+ ```typescript
466
+ constructor(
467
+ ctx: DurableObjectState,
468
+ env: TEnv,
469
+ options: BaseWebSocketDOOptions<TSession, TEnv>
470
+ )
471
+ ```
411
472
 
412
- - `abstract createSession(websocket: WebSocket): TSession | Promise<TSession>`
413
- - Factory method to create session instances
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<TEnv, TData, TServerMessage, TClientMessage>`
497
+ ### `BaseSession<TData, TServerMessage, TClientMessage, TEnv>`
427
498
 
428
- Abstract class for managing individual WebSocket sessions.
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
- #### Methods
508
+ #### Constructor
438
509
 
439
- - `abstract createData(ctx: Context): TData`
440
- - Creates initial session data
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
- - `abstract handleMessage(message: TClientMessage): Promise<void>`
443
- - Handles text messages from client
518
+ #### Handlers Type
444
519
 
445
- - `abstract handleBufferMessage(message: ArrayBuffer): Promise<void>`
446
- - Handles binary messages from client
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
- - `abstract handleClose(): Promise<void>`
449
- - Cleanup when session closes
529
+ #### Methods
450
530
 
451
- - `protected send(message: TServerMessage): void`
531
+ - `send(message: TServerMessage): void`
452
532
  - Send message to this session's client
453
533
 
454
- - `protected broadcast(message: TServerMessage, excludeSelf?: boolean): void`
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<Env, ChatSession> {
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<Env, GameData, ServerMsg, ClientMsg> {
521
- protected createData(ctx: Context): GameData {
522
- return {
523
- playerName: ctx.req.query('name') || 'Anonymous',
524
- score: 0,
525
- inventory: [],
526
- };
527
- }
528
-
529
- async handleMessage(message: ClientMsg): Promise<void> {
530
- if (message.type === 'collectItem') {
531
- this.data.inventory.push(message.item);
532
- this.data.score += 10;
533
-
534
- // Persist changes
535
- this.update();
536
-
537
- this.send({ type: 'scoreUpdate', score: this.data.score });
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
- async handleMessage(message: ClientMessage): Promise<void> {
549
- try {
550
- // Your logic here
551
- if (message.type === 'dangerous') {
552
- throw new Error('Invalid operation');
553
- }
554
- } catch (error) {
555
- // Send error to client
556
- this.send({
557
- type: 'error',
558
- message: error instanceof Error ? error.message : 'Unknown error'
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` - Abstract base class for WebSocket Durable Objects
573
- - `BaseSession` - Abstract base class for WebSocket sessions
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` - Session base class with Zod validation built-in
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<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`);
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<Env, ChatSession> {
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
- protected createSession(websocket: WebSocket): ChatSession {
665
- return new ChatSession(websocket, this.sessions);
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": "6.0.1",
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": "workspace:*",
50
- "hono": "^4.10.1"
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.0",
65
+ "bun-types": "^1.3.1",
66
66
  "typescript": "^5.9.3"
67
67
  }
68
68
  }
@@ -1,20 +1,70 @@
1
1
  import type { Context } from "hono";
2
2
  import { WebsocketWrapper } from "./WebsocketWrapper";
3
3
 
4
- export type SessionClientMessage<TSession extends BaseSession> =
5
- TSession extends BaseSession<never, never, infer TClientMessage, never>
6
- ? TClientMessage
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 abstract class BaseSession<
10
- // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
11
- TEnv extends object = any,
12
- // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
13
- TData = any,
14
- // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
15
- TServerMessage = any,
16
- // biome-ignore lint/suspicious/noExplicitAny: Generic type parameter with flexible default
17
- TClientMessage = any,
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<TEnv, TData, TServerMessage, TClientMessage>
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
- protected abstract createData(ctx: Context<{ Bindings: TEnv }>): TData;
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
- protected send(message: TServerMessage) {
124
+ public send(message: TServerMessage) {
69
125
  this.wrapper.send(message);
70
126
  }
71
127
 
72
- abstract handleMessage(message: TClientMessage): Promise<void>;
73
- abstract handleBufferMessage(message: ArrayBuffer): Promise<void>;
74
- abstract handleClose(): Promise<void>;
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
  }
@@ -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 { BaseSession, SessionClientMessage } from "./BaseSession";
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
- TEnv extends object,
8
- TSession extends BaseSession<TEnv>,
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(ctx: DurableObjectState, env: TEnv) {
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
- const session = await this.createSession(websocket);
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 interface ZodSessionOptions<TClientMessage, TServerMessage> {
6
+ export type ZodSessionOptions<TClientMessage, TServerMessage> = {
6
7
  clientSchema: ZodType<TClientMessage>;
7
8
  serverSchema: ZodType<TServerMessage>;
8
9
  enableBufferMessages?: boolean;
9
- }
10
-
11
- export abstract class ZodSession<
12
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the environment
13
- TEnv extends object = any,
14
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the data
15
- TData = any,
16
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the server message
17
- TServerMessage = any,
18
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the client message
19
- TClientMessage = any,
20
- > extends BaseSession<TEnv, TData, TServerMessage, TClientMessage> {
21
- protected readonly clientCodec: ReturnType<typeof zodMsgpack<TClientMessage>>;
22
- protected readonly serverCodec: ReturnType<typeof zodMsgpack<TServerMessage>>;
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<TEnv, TData, TServerMessage, TClientMessage>
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
- // Override the base handleMessage to add validation
41
- async handleMessage(message: TClientMessage): Promise<void> {
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.handleValidationError(error, message);
92
+ await this._internalHandleValidationError(error, message);
60
93
  }
61
94
  }
62
95
 
63
- // Override buffer message handling to support msgpack decoding
64
- async handleBufferMessage(buffer: ArrayBuffer): Promise<void> {
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.handleValidationError(error, buffer);
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
- protected send(message: TServerMessage): void {
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
- // Send a simple error object - no schema validation needed
125
- if (this.websocket.readyState !== WebSocket.OPEN) return;
126
- this.websocket.send(JSON.stringify({ error: errorMessage }));
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
- protected broadcast(message: TServerMessage, excludeSelf = false): void {
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
  }
@@ -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 abstract class ZodWebSocketDO<
5
- TEnv extends object,
6
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the session
7
- TSession extends ZodSession<TEnv, any, any, any>,
8
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the client message
9
- TClientMessage = any,
10
- // biome-ignore lint/suspicious/noExplicitAny: We need to allow any for the server message
11
- TServerMessage = any,
12
- > extends BaseWebSocketDO<TEnv, TSession> {
13
- protected abstract getZodOptions(): ZodSessionOptions<
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
- protected abstract createZodSession(
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
- ): TSession | Promise<TSession>;
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
- protected createSession(websocket: WebSocket): TSession | Promise<TSession> {
24
- const options = this.getZodOptions();
25
- return this.createZodSession(websocket, options);
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 { BaseSession, type SessionClientMessage } from "./BaseSession";
2
- export { BaseWebSocketDO } from "./BaseWebSocketDO";
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 { ZodSession, type ZodSessionOptions } from "./ZodSession";
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 { ZodWebSocketDO } from "./ZodWebSocketDO";
20
+ export {
21
+ type ZodSessionOptionsOrFactory,
22
+ ZodWebSocketDO,
23
+ type ZodWebSocketDOOptions,
24
+ } from "./ZodWebSocketDO";
10
25
  export { zodMsgpack } from "./zodMsgpack";