@venizia/ignis-docs 0.0.4 → 0.0.5

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.
@@ -1,10 +1,8 @@
1
1
  # Building a Real-Time Chat Application
2
2
 
3
- This tutorial shows you how to build a real-time chat application with rooms, direct messages, typing indicators, and presence using Socket.IO.
3
+ This tutorial shows you how to build a real-time chat application with rooms, direct messages, typing indicators, and presence using Socket.IO — powered by the `SocketIOComponent` and `SocketIOServerHelper`.
4
4
 
5
- **⏱️ Time to Complete:** ~75 minutes
6
-
7
- ## What You'll Build
5
+ **What You'll Build:**
8
6
 
9
7
  - Chat rooms (channels)
10
8
  - Direct messages between users
@@ -16,7 +14,8 @@ This tutorial shows you how to build a real-time chat application with rooms, di
16
14
 
17
15
  - Completed [Building a CRUD API](./building-a-crud-api.md)
18
16
  - Understanding of [Socket.IO Component](/references/components/socket-io)
19
- - Redis for pub/sub (optional but recommended for scaling)
17
+ - Understanding of [Socket.IO Helper](/references/helpers/socket-io)
18
+ - Redis for pub/sub (required by `SocketIOServerHelper`)
20
19
 
21
20
  ## 1. Project Setup
22
21
 
@@ -27,10 +26,16 @@ bun init -y
27
26
 
28
27
  # Install dependencies
29
28
  bun add hono @hono/zod-openapi @venizia/ignis dotenv-flow
30
- bun add drizzle-orm drizzle-zod pg socket.io
29
+ bun add drizzle-orm drizzle-zod pg
31
30
  bun add -d typescript @types/bun @venizia/dev-configs drizzle-kit @types/pg
31
+
32
+ # For Bun runtime — Socket.IO engine
33
+ bun add @socket.io/bun-engine
32
34
  ```
33
35
 
36
+ > [!NOTE]
37
+ > You do **not** need to install `socket.io` directly. It is included as a dependency of `@venizia/ignis-helpers`. For the client side, install `socket.io-client` separately.
38
+
34
39
  ## 2. Database Models
35
40
 
36
41
  Models in IGNIS combine Drizzle ORM schemas with Entity classes.
@@ -431,94 +436,270 @@ export class MessageRepository extends DefaultCRUDRepository<typeof Message.sche
431
436
  }
432
437
  ```
433
438
 
434
- ## 5. Socket.IO Events
439
+ ## 5. Chat Service
435
440
 
436
- Define your event types:
441
+ The `ChatService` handles business logic for rooms, messages, and presence. It also registers Socket.IO event handlers on each authenticated client via the `clientConnectedFn` callback.
437
442
 
438
443
  ```typescript
439
- // src/types/socket.types.ts
444
+ // src/services/chat.service.ts
445
+ import {
446
+ BaseApplication,
447
+ BaseService,
448
+ CoreBindings,
449
+ inject,
450
+ SocketIOBindingKeys,
451
+ SocketIOServerHelper,
452
+ BindingKeys,
453
+ BindingNamespaces,
454
+ } from '@venizia/ignis';
455
+ import { ISocketIOClient, getError } from '@venizia/ignis-helpers';
456
+ import { Socket } from 'socket.io';
457
+ import { MessageRepository } from '../repositories/message.repository';
458
+ import { RoomRepository } from '../repositories/room.repository';
459
+ import { UserRepository } from '../repositories/user.repository';
440
460
 
441
- // Client -> Server events
442
- export interface ClientToServerEvents {
443
- // Rooms
444
- 'room:join': (roomId: string) => void;
445
- 'room:leave': (roomId: string) => void;
461
+ export class ChatService extends BaseService {
462
+ private _socketIOHelper: SocketIOServerHelper | null = null;
463
+ private _typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
446
464
 
447
- // Messages
448
- 'message:send': (data: { roomId: string; content: string; type?: string }) => void;
449
- 'message:edit': (data: { messageId: string; content: string }) => void;
450
- 'message:delete': (data: { messageId: string }) => void;
465
+ constructor(
466
+ @inject({ key: CoreBindings.APPLICATION_INSTANCE })
467
+ private application: BaseApplication,
451
468
 
452
- // Direct messages
453
- 'dm:send': (data: { receiverId: string; content: string }) => void;
469
+ @inject({
470
+ key: BindingKeys.build({
471
+ namespace: BindingNamespaces.REPOSITORY,
472
+ key: 'MessageRepository',
473
+ }),
474
+ })
475
+ private _messageRepo: MessageRepository,
454
476
 
455
- // Typing
456
- 'typing:start': (roomId: string) => void;
457
- 'typing:stop': (roomId: string) => void;
477
+ @inject({
478
+ key: BindingKeys.build({
479
+ namespace: BindingNamespaces.REPOSITORY,
480
+ key: 'RoomRepository',
481
+ }),
482
+ })
483
+ private _roomRepo: RoomRepository,
458
484
 
459
- // Presence
460
- 'presence:update': (status: 'online' | 'away' | 'busy') => void;
461
- }
485
+ @inject({
486
+ key: BindingKeys.build({
487
+ namespace: BindingNamespaces.REPOSITORY,
488
+ key: 'UserRepository',
489
+ }),
490
+ })
491
+ private _userRepo: UserRepository,
492
+ ) {
493
+ super({ scope: ChatService.name });
494
+ }
462
495
 
463
- // Server -> Client events
464
- export interface ServerToClientEvents {
465
- // Messages
466
- 'message:new': (message: Message) => void;
467
- 'message:edited': (message: Message) => void;
468
- 'message:deleted': (data: { messageId: string; roomId: string }) => void;
496
+ // ---------------------------------------------------------------------------
497
+ // SocketIOServerHelper lazy getter (bound after server starts via post-start hook)
498
+ // ---------------------------------------------------------------------------
499
+ private get socketIOHelper(): SocketIOServerHelper {
500
+ if (!this._socketIOHelper) {
501
+ this._socketIOHelper =
502
+ this.application.get<SocketIOServerHelper>({
503
+ key: SocketIOBindingKeys.SOCKET_IO_INSTANCE,
504
+ isOptional: true,
505
+ }) ?? null;
506
+ }
469
507
 
470
- // Direct messages
471
- 'dm:new': (message: DirectMessage) => void;
508
+ if (!this._socketIOHelper) {
509
+ throw new Error('[ChatService] SocketIO not initialized. Server must be started first.');
510
+ }
472
511
 
473
- // Typing
474
- 'typing:update': (data: { roomId: string; userId: string; isTyping: boolean }) => void;
512
+ return this._socketIOHelper;
513
+ }
475
514
 
476
- // Presence
477
- 'presence:changed': (data: { userId: string; status: string; lastSeenAt?: Date }) => void;
515
+ // ---------------------------------------------------------------------------
516
+ // Socket event handlers called from clientConnectedFn after authentication
517
+ // ---------------------------------------------------------------------------
518
+ registerClientHandlers(opts: { socket: Socket }) {
519
+ const logger = this.logger.for(this.registerClientHandlers.name);
520
+ const { socket } = opts;
521
+ const socketId = socket.id;
478
522
 
479
- // Room
480
- 'room:user-joined': (data: { roomId: string; user: User }) => void;
481
- 'room:user-left': (data: { roomId: string; userId: string }) => void;
523
+ logger.info('Registering chat handlers | socketId: %s', socketId);
482
524
 
483
- // Errors
484
- 'error': (error: { code: string; message: string }) => void;
485
- }
525
+ // --- Set user online (using socket ID as user identifier for simplicity) ---
526
+ this.broadcastPresence({ userId: socketId, status: 'online' });
486
527
 
487
- export interface InterServerEvents {
488
- ping: () => void;
489
- }
528
+ // --- Room handlers ---
529
+ socket.on('room:join', async (data: { roomId: string; userId: string }) => {
530
+ try {
531
+ await this.joinRoom({ roomId: data.roomId, userId: data.userId });
490
532
 
491
- export interface SocketData {
492
- userId: string;
493
- username: string;
494
- }
495
- ```
533
+ // Also join the Socket.IO room for real-time delivery
534
+ socket.join(`room:${data.roomId}`);
496
535
 
497
- ## 6. Chat Service
536
+ // Notify room members
537
+ this.socketIOHelper.send({
538
+ destination: `room:${data.roomId}`,
539
+ payload: {
540
+ topic: 'room:user-joined',
541
+ data: { roomId: data.roomId, userId: data.userId },
542
+ },
543
+ });
498
544
 
499
- ```typescript
500
- // src/services/chat.service.ts
501
- import { injectable, inject } from '@venizia/ignis';
502
- import { BaseService } from '@venizia/ignis';
503
- import { MessageRepository } from '../repositories/message.repository';
504
- import { RoomRepository } from '../repositories/room.repository';
505
- import { UserRepository } from '../repositories/user.repository';
506
- import { getError } from '@venizia/ignis-helpers';
545
+ logger.info('User joined room | userId: %s | roomId: %s', data.userId, data.roomId);
546
+ } catch (error) {
547
+ this.socketIOHelper.send({
548
+ destination: socketId,
549
+ payload: {
550
+ topic: 'error',
551
+ data: { code: 'JOIN_FAILED', message: (error as Error).message },
552
+ },
553
+ });
554
+ }
555
+ });
507
556
 
508
- @injectable()
509
- export class ChatService extends BaseService {
510
- constructor(
511
- @inject('repositories.MessageRepository')
512
- private _messageRepo: MessageRepository,
513
- @inject('repositories.RoomRepository')
514
- private _roomRepo: RoomRepository,
515
- @inject('repositories.UserRepository')
516
- private _userRepo: UserRepository,
517
- ) {
518
- super({ scope: ChatService.name });
557
+ socket.on('room:leave', (data: { roomId: string; userId: string }) => {
558
+ socket.leave(`room:${data.roomId}`);
559
+
560
+ this.socketIOHelper.send({
561
+ destination: `room:${data.roomId}`,
562
+ payload: {
563
+ topic: 'room:user-left',
564
+ data: { roomId: data.roomId, userId: data.userId },
565
+ },
566
+ });
567
+
568
+ logger.info('User left room | userId: %s | roomId: %s', data.userId, data.roomId);
569
+ });
570
+
571
+ // --- Message handlers ---
572
+ socket.on(
573
+ 'message:send',
574
+ async (data: { roomId: string; content: string; type?: string; userId: string }) => {
575
+ try {
576
+ const message = await this.sendMessage({
577
+ roomId: data.roomId,
578
+ senderId: data.userId,
579
+ content: data.content,
580
+ type: data.type,
581
+ });
582
+
583
+ // Broadcast to room
584
+ this.socketIOHelper.send({
585
+ destination: `room:${data.roomId}`,
586
+ payload: { topic: 'message:new', data: message },
587
+ });
588
+
589
+ // Clear typing indicator
590
+ this.clearTyping({ socketId, roomId: data.roomId });
591
+ } catch (error) {
592
+ this.socketIOHelper.send({
593
+ destination: socketId,
594
+ payload: {
595
+ topic: 'error',
596
+ data: { code: 'SEND_FAILED', message: (error as Error).message },
597
+ },
598
+ });
599
+ }
600
+ },
601
+ );
602
+
603
+ socket.on('message:edit', async (data: { messageId: string; content: string; userId: string }) => {
604
+ try {
605
+ const message = await this.editMessage({
606
+ messageId: data.messageId,
607
+ userId: data.userId,
608
+ content: data.content,
609
+ });
610
+
611
+ this.socketIOHelper.send({
612
+ destination: `room:${message.roomId}`,
613
+ payload: { topic: 'message:edited', data: message },
614
+ });
615
+ } catch (error) {
616
+ this.socketIOHelper.send({
617
+ destination: socketId,
618
+ payload: {
619
+ topic: 'error',
620
+ data: { code: 'EDIT_FAILED', message: (error as Error).message },
621
+ },
622
+ });
623
+ }
624
+ });
625
+
626
+ socket.on('message:delete', async (data: { messageId: string; userId: string }) => {
627
+ try {
628
+ const message = await this.deleteMessage({
629
+ messageId: data.messageId,
630
+ userId: data.userId,
631
+ });
632
+
633
+ this.socketIOHelper.send({
634
+ destination: `room:${message.roomId}`,
635
+ payload: {
636
+ topic: 'message:deleted',
637
+ data: { messageId: data.messageId, roomId: message.roomId },
638
+ },
639
+ });
640
+ } catch (error) {
641
+ this.socketIOHelper.send({
642
+ destination: socketId,
643
+ payload: {
644
+ topic: 'error',
645
+ data: { code: 'DELETE_FAILED', message: (error as Error).message },
646
+ },
647
+ });
648
+ }
649
+ });
650
+
651
+ // --- Direct message handlers ---
652
+ socket.on('dm:send', async (data: { receiverId: string; content: string; userId: string }) => {
653
+ try {
654
+ const message = await this.sendDirectMessage({
655
+ senderId: data.userId,
656
+ receiverId: data.receiverId,
657
+ content: data.content,
658
+ });
659
+
660
+ // Send to receiver
661
+ this.socketIOHelper.send({
662
+ destination: data.receiverId,
663
+ payload: { topic: 'dm:new', data: message },
664
+ });
665
+
666
+ // Send back to sender
667
+ this.socketIOHelper.send({
668
+ destination: socketId,
669
+ payload: { topic: 'dm:new', data: message },
670
+ });
671
+ } catch (error) {
672
+ this.socketIOHelper.send({
673
+ destination: socketId,
674
+ payload: {
675
+ topic: 'error',
676
+ data: { code: 'DM_FAILED', message: (error as Error).message },
677
+ },
678
+ });
679
+ }
680
+ });
681
+
682
+ // --- Typing handlers ---
683
+ socket.on('typing:start', (data: { roomId: string }) => {
684
+ this.handleTyping({ socketId, roomId: data.roomId, isTyping: true });
685
+ });
686
+
687
+ socket.on('typing:stop', (data: { roomId: string }) => {
688
+ this.handleTyping({ socketId, roomId: data.roomId, isTyping: false });
689
+ });
690
+
691
+ // --- Disconnect ---
692
+ socket.on('disconnect', () => {
693
+ logger.info('User disconnected | socketId: %s', socketId);
694
+ this.broadcastPresence({ userId: socketId, status: 'offline' });
695
+ });
696
+
697
+ logger.info('Chat handlers registered | socketId: %s', socketId);
519
698
  }
520
699
 
700
+ // ---------------------------------------------------------------------------
521
701
  // Room operations
702
+ // ---------------------------------------------------------------------------
522
703
  async createRoom(opts: { name: string; description?: string; isPrivate?: boolean; createdBy: string }) {
523
704
  const room = await this._roomRepo.create(opts);
524
705
 
@@ -534,14 +715,12 @@ export class ChatService extends BaseService {
534
715
  throw getError({ statusCode: 404, message: 'Room not found' });
535
716
  }
536
717
 
537
- // Check if private room requires invitation
538
718
  if (room.isPrivate) {
539
719
  const isMember = await this._roomRepo.isMember({ roomId: opts.roomId, userId: opts.userId });
540
720
  if (!isMember) {
541
721
  throw getError({ statusCode: 403, message: 'Cannot join private room' });
542
722
  }
543
723
  } else {
544
- // Auto-join public rooms
545
724
  await this._roomRepo.addMember({ roomId: opts.roomId, userId: opts.userId, role: 'member' });
546
725
  }
547
726
 
@@ -556,9 +735,10 @@ export class ChatService extends BaseService {
556
735
  return this._roomRepo.findByUser({ userId: opts.userId });
557
736
  }
558
737
 
738
+ // ---------------------------------------------------------------------------
559
739
  // Message operations
740
+ // ---------------------------------------------------------------------------
560
741
  async sendMessage(opts: { roomId: string; senderId: string; content: string; type?: string }) {
561
- // Verify user is member of room
562
742
  const isMember = await this._roomRepo.isMember({ roomId: opts.roomId, userId: opts.senderId });
563
743
  if (!isMember) {
564
744
  throw getError({ statusCode: 403, message: 'Not a member of this room' });
@@ -571,7 +751,6 @@ export class ChatService extends BaseService {
571
751
  type: opts.type ?? 'text',
572
752
  });
573
753
 
574
- // Get sender info for the response
575
754
  const sender = await this._userRepo.findById(opts.senderId);
576
755
 
577
756
  return {
@@ -610,7 +789,6 @@ export class ChatService extends BaseService {
610
789
  }
611
790
 
612
791
  if (message.senderId !== opts.userId) {
613
- // Check if user is room admin
614
792
  const member = await this._roomRepo.getMember({ roomId: message.roomId, userId: opts.userId });
615
793
  if (!member || member.role !== 'admin') {
616
794
  throw getError({ statusCode: 403, message: 'Cannot delete this message' });
@@ -629,7 +807,6 @@ export class ChatService extends BaseService {
629
807
  };
630
808
 
631
809
  if (opts.before) {
632
- // Cursor-based pagination
633
810
  const beforeMessage = await this._messageRepo.findById(opts.before);
634
811
  if (beforeMessage) {
635
812
  where.createdAt = { lt: beforeMessage.createdAt };
@@ -643,7 +820,9 @@ export class ChatService extends BaseService {
643
820
  });
644
821
  }
645
822
 
823
+ // ---------------------------------------------------------------------------
646
824
  // Direct message operations
825
+ // ---------------------------------------------------------------------------
647
826
  async sendDirectMessage(opts: { senderId: string; receiverId: string; content: string }) {
648
827
  return this._messageRepo.createDirectMessage(opts);
649
828
  }
@@ -661,7 +840,9 @@ export class ChatService extends BaseService {
661
840
  return this._messageRepo.findConversations({ userId: opts.userId });
662
841
  }
663
842
 
843
+ // ---------------------------------------------------------------------------
664
844
  // Presence operations
845
+ // ---------------------------------------------------------------------------
665
846
  async setOnline(opts: { userId: string }) {
666
847
  await this._userRepo.updateById(opts.userId, {
667
848
  isOnline: true,
@@ -680,182 +861,36 @@ export class ChatService extends BaseService {
680
861
  const members = await this._roomRepo.getMembers({ roomId: opts.roomId });
681
862
  return members.filter(m => m.user.isOnline);
682
863
  }
683
- }
684
- ```
685
-
686
- ## 7. Socket.IO Handler
687
-
688
- ```typescript
689
- // src/socket/chat.handler.ts
690
- import { Server, Socket } from 'socket.io';
691
- import { ChatService } from '../services/chat.service';
692
- import { RedisHelper, LoggerFactory } from '@venizia/ignis-helpers';
693
- import {
694
- ClientToServerEvents,
695
- ServerToClientEvents,
696
- InterServerEvents,
697
- SocketData,
698
- } from '../types/socket.types';
699
-
700
- type ChatSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
701
- type ChatServer = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
702
-
703
- export class ChatSocketHandler {
704
- private _logger = LoggerFactory.getLogger(['ChatSocketHandler']);
705
- private _typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
706
-
707
- constructor(
708
- private _io: ChatServer,
709
- private _chatService: ChatService,
710
- ) {}
711
-
712
- setupHandlers(socket: ChatSocket) {
713
- const userId = socket.data.userId;
714
- const username = socket.data.username;
715
-
716
- this._logger.info('User connected', { userId, username, socketId: socket.id });
717
-
718
- // Set user online
719
- this._chatService.setOnline({ userId });
720
- this.broadcastPresence({ userId, status: 'online' });
721
-
722
- // Room handlers
723
- socket.on('room:join', async (roomId) => {
724
- try {
725
- await this._chatService.joinRoom({ roomId, userId });
726
- socket.join(`room:${roomId}`);
727
-
728
- // Notify room members
729
- socket.to(`room:${roomId}`).emit('room:user-joined', {
730
- roomId,
731
- user: { id: userId, username },
732
- });
733
-
734
- this._logger.info('User joined room', { userId, roomId });
735
- } catch (error) {
736
- socket.emit('error', { code: 'JOIN_FAILED', message: error.message });
737
- }
738
- });
739
-
740
- socket.on('room:leave', async (roomId) => {
741
- socket.leave(`room:${roomId}`);
742
- socket.to(`room:${roomId}`).emit('room:user-left', { roomId, userId });
743
- this._logger.info('User left room', { userId, roomId });
744
- });
745
-
746
- // Message handlers
747
- socket.on('message:send', async (data) => {
748
- try {
749
- const message = await this._chatService.sendMessage({
750
- roomId: data.roomId,
751
- senderId: userId,
752
- content: data.content,
753
- type: data.type,
754
- });
755
-
756
- // Broadcast to room
757
- this._io.to(`room:${data.roomId}`).emit('message:new', message);
758
-
759
- // Clear typing indicator
760
- this.clearTyping({ socket, roomId: data.roomId });
761
- } catch (error) {
762
- socket.emit('error', { code: 'SEND_FAILED', message: error.message });
763
- }
764
- });
765
-
766
- socket.on('message:edit', async (data) => {
767
- try {
768
- const message = await this._chatService.editMessage({
769
- messageId: data.messageId,
770
- userId,
771
- content: data.content,
772
- });
773
- this._io.to(`room:${message.roomId}`).emit('message:edited', message);
774
- } catch (error) {
775
- socket.emit('error', { code: 'EDIT_FAILED', message: error.message });
776
- }
777
- });
778
-
779
- socket.on('message:delete', async (data) => {
780
- try {
781
- const message = await this._chatService.deleteMessage({ messageId: data.messageId, userId });
782
- this._io.to(`room:${message.roomId}`).emit('message:deleted', {
783
- messageId: data.messageId,
784
- roomId: message.roomId,
785
- });
786
- } catch (error) {
787
- socket.emit('error', { code: 'DELETE_FAILED', message: error.message });
788
- }
789
- });
790
-
791
- // Direct message handlers
792
- socket.on('dm:send', async (data) => {
793
- try {
794
- const message = await this._chatService.sendDirectMessage({
795
- senderId: userId,
796
- receiverId: data.receiverId,
797
- content: data.content,
798
- });
799
-
800
- // Send to receiver (if online)
801
- this._io.to(`user:${data.receiverId}`).emit('dm:new', message);
802
-
803
- // Send back to sender
804
- socket.emit('dm:new', message);
805
- } catch (error) {
806
- socket.emit('error', { code: 'DM_FAILED', message: error.message });
807
- }
808
- });
809
-
810
- // Typing handlers
811
- socket.on('typing:start', (roomId) => {
812
- this.handleTyping({ socket, roomId, isTyping: true });
813
- });
814
-
815
- socket.on('typing:stop', (roomId) => {
816
- this.handleTyping({ socket, roomId, isTyping: false });
817
- });
818
-
819
- // Presence handlers
820
- socket.on('presence:update', (status) => {
821
- this.broadcastPresence({ userId, status });
822
- });
823
-
824
- // Disconnect
825
- socket.on('disconnect', () => {
826
- this._logger.info('User disconnected', { userId, socketId: socket.id });
827
- this._chatService.setOffline({ userId });
828
- this.broadcastPresence({ userId, status: 'offline' });
829
- });
830
-
831
- // Join user's personal room for DMs
832
- socket.join(`user:${userId}`);
833
- }
834
864
 
835
- private handleTyping(opts: { socket: ChatSocket; roomId: string; isTyping: boolean }) {
836
- const userId = opts.socket.data.userId;
837
- const key = `${opts.roomId}:${userId}`;
865
+ // ---------------------------------------------------------------------------
866
+ // Typing indicator helpers
867
+ // ---------------------------------------------------------------------------
868
+ private handleTyping(opts: { socketId: string; roomId: string; isTyping: boolean }) {
869
+ const key = `${opts.roomId}:${opts.socketId}`;
838
870
 
839
- // Clear existing timeout
840
871
  const existingTimeout = this._typingTimeouts.get(key);
841
872
  if (existingTimeout) {
842
873
  clearTimeout(existingTimeout);
843
874
  }
844
875
 
845
- // Broadcast typing status
846
- opts.socket.to(`room:${opts.roomId}`).emit('typing:update', {
847
- roomId: opts.roomId,
848
- userId,
849
- isTyping: opts.isTyping,
876
+ // Broadcast typing status to room
877
+ this.socketIOHelper.send({
878
+ destination: `room:${opts.roomId}`,
879
+ payload: {
880
+ topic: 'typing:update',
881
+ data: { roomId: opts.roomId, userId: opts.socketId, isTyping: opts.isTyping },
882
+ },
850
883
  });
851
884
 
852
885
  if (opts.isTyping) {
853
886
  // Auto-stop typing after 3 seconds
854
887
  const timeout = setTimeout(() => {
855
- opts.socket.to(`room:${opts.roomId}`).emit('typing:update', {
856
- roomId: opts.roomId,
857
- userId,
858
- isTyping: false,
888
+ this.socketIOHelper.send({
889
+ destination: `room:${opts.roomId}`,
890
+ payload: {
891
+ topic: 'typing:update',
892
+ data: { roomId: opts.roomId, userId: opts.socketId, isTyping: false },
893
+ },
859
894
  });
860
895
  this._typingTimeouts.delete(key);
861
896
  }, 3000);
@@ -864,47 +899,66 @@ export class ChatSocketHandler {
864
899
  }
865
900
  }
866
901
 
867
- private clearTyping(opts: { socket: ChatSocket; roomId: string }) {
868
- const userId = opts.socket.data.userId;
869
- const key = `${opts.roomId}:${userId}`;
870
-
902
+ private clearTyping(opts: { socketId: string; roomId: string }) {
903
+ const key = `${opts.roomId}:${opts.socketId}`;
871
904
  const timeout = this._typingTimeouts.get(key);
905
+
872
906
  if (timeout) {
873
907
  clearTimeout(timeout);
874
908
  this._typingTimeouts.delete(key);
875
909
  }
876
910
 
877
- opts.socket.to(`room:${opts.roomId}`).emit('typing:update', {
878
- roomId: opts.roomId,
879
- userId,
880
- isTyping: false,
911
+ this.socketIOHelper.send({
912
+ destination: `room:${opts.roomId}`,
913
+ payload: {
914
+ topic: 'typing:update',
915
+ data: { roomId: opts.roomId, userId: opts.socketId, isTyping: false },
916
+ },
881
917
  });
882
918
  }
883
919
 
884
920
  private broadcastPresence(opts: { userId: string; status: string }) {
885
- this._io.emit('presence:changed', {
886
- userId: opts.userId,
887
- status: opts.status,
888
- lastSeenAt: new Date(),
921
+ this.socketIOHelper.send({
922
+ payload: {
923
+ topic: 'presence:changed',
924
+ data: { userId: opts.userId, status: opts.status, lastSeenAt: new Date().toISOString() },
925
+ },
889
926
  });
890
927
  }
891
928
  }
892
929
  ```
893
930
 
894
- ## 8. Application Setup
931
+ > [!IMPORTANT]
932
+ > **Lazy getter pattern**: `SocketIOServerHelper` is bound via a post-start hook, so it's not available during DI construction. The `private get socketIOHelper()` getter resolves it lazily on first access. See [Socket.IO Component](/references/components/socket-io#step-3-use-in-servicescontrollers) for details.
933
+
934
+ ## 6. Application Setup
935
+
936
+ The application binds Redis, authentication, and the client connected handler via `SocketIOBindingKeys`, then registers `SocketIOComponent`. No manual Socket.IO server creation needed.
895
937
 
896
938
  ```typescript
897
939
  // src/application.ts
898
- import { BaseApplication, IApplicationInfo, SocketIOComponent } from '@venizia/ignis';
899
- import { EnvHelper } from '@venizia/ignis-helpers';
900
- import { Server } from 'socket.io';
901
- import { ChatSocketHandler } from './socket/chat.handler';
940
+ import {
941
+ applicationEnvironment,
942
+ BaseApplication,
943
+ BindingKeys,
944
+ BindingNamespaces,
945
+ IApplicationConfigs,
946
+ IApplicationInfo,
947
+ ISocketIOServerBaseOptions,
948
+ RedisHelper,
949
+ SocketIOBindingKeys,
950
+ SocketIOComponent,
951
+ SocketIOServerHelper,
952
+ ValueOrPromise,
953
+ } from '@venizia/ignis';
902
954
  import { ChatService } from './services/chat.service';
903
- import { verifyToken } from './middleware/auth';
955
+ import { ChatController } from './controllers/chat.controller';
956
+ import { UserRepository } from './repositories/user.repository';
957
+ import { RoomRepository, RoomMemberRepository } from './repositories/room.repository';
958
+ import { MessageRepository, DirectMessageRepository } from './repositories/message.repository';
904
959
 
905
960
  export class ChatApp extends BaseApplication {
906
- private _io: Server;
907
- private _chatHandler: ChatSocketHandler;
961
+ private redisHelper: RedisHelper;
908
962
 
909
963
  getAppInfo(): IApplicationInfo {
910
964
  return { name: 'chat-api', version: '1.0.0' };
@@ -912,61 +966,161 @@ export class ChatApp extends BaseApplication {
912
966
 
913
967
  staticConfigure() {}
914
968
 
915
- preConfigure() {
916
- // Register services and repositories
969
+ preConfigure(): ValueOrPromise<void> {
970
+ // Register repositories
971
+ this.repository(UserRepository);
972
+ this.repository(RoomRepository);
973
+ this.repository(RoomMemberRepository);
974
+ this.repository(MessageRepository);
975
+ this.repository(DirectMessageRepository);
976
+
977
+ // Register services
917
978
  this.service(ChatService);
918
- // ... other bindings
919
979
 
920
- // Add Socket.IO component
921
- this.component(SocketIOComponent);
922
- }
980
+ // Register controllers
981
+ this.controller(ChatController);
923
982
 
924
- postConfigure() {
983
+ // Setup Socket.IO
925
984
  this.setupSocketIO();
926
985
  }
927
986
 
928
- setupMiddlewares() {}
987
+ postConfigure(): ValueOrPromise<void> {}
929
988
 
989
+ setupMiddlewares(): ValueOrPromise<void> {}
990
+
991
+ // ---------------------------------------------------------------------------
930
992
  private setupSocketIO() {
931
- const httpServer = this.getHttpServer();
993
+ // 1. Redis connection — SocketIOServerHelper creates 3 duplicate connections
994
+ // for adapter (pub/sub) and emitter automatically
995
+ this.redisHelper = new RedisHelper({
996
+ name: 'chat-redis',
997
+ host: process.env.APP_ENV_REDIS_HOST ?? 'localhost',
998
+ port: +(process.env.APP_ENV_REDIS_PORT ?? 6379),
999
+ password: process.env.APP_ENV_REDIS_PASSWORD,
1000
+ autoConnect: false,
1001
+ });
932
1002
 
933
- this._io = new Server(httpServer, {
1003
+ this.bind<RedisHelper>({
1004
+ key: SocketIOBindingKeys.REDIS_CONNECTION,
1005
+ }).toValue(this.redisHelper);
1006
+
1007
+ // 2. Authentication handler — called when a client emits 'authenticate'
1008
+ // Receives the Socket.IO handshake (headers, query, auth object)
1009
+ const authenticateFn: ISocketIOServerBaseOptions['authenticateFn'] = handshake => {
1010
+ const token =
1011
+ handshake.headers.authorization?.replace('Bearer ', '') ??
1012
+ handshake.auth?.token;
1013
+
1014
+ if (!token) {
1015
+ return false;
1016
+ }
1017
+
1018
+ // Implement your JWT/session verification here
1019
+ // For example: return verifyJWT(token);
1020
+ return true;
1021
+ };
1022
+
1023
+ this.bind<ISocketIOServerBaseOptions['authenticateFn']>({
1024
+ key: SocketIOBindingKeys.AUTHENTICATE_HANDLER,
1025
+ }).toValue(authenticateFn);
1026
+
1027
+ // 3. Client connected handler — called AFTER successful authentication
1028
+ // This is where you register custom event handlers on each socket
1029
+ const clientConnectedFn: ISocketIOServerBaseOptions['clientConnectedFn'] = ({ socket }) => {
1030
+ const chatService = this.get<ChatService>({
1031
+ key: BindingKeys.build({
1032
+ namespace: BindingNamespaces.SERVICE,
1033
+ key: ChatService.name,
1034
+ }),
1035
+ });
1036
+
1037
+ chatService.registerClientHandlers({ socket });
1038
+ };
1039
+
1040
+ this.bind<ISocketIOServerBaseOptions['clientConnectedFn']>({
1041
+ key: SocketIOBindingKeys.CLIENT_CONNECTED_HANDLER,
1042
+ }).toValue(clientConnectedFn);
1043
+
1044
+ // 4. (Optional) Custom server options — override defaults
1045
+ this.bind({
1046
+ key: SocketIOBindingKeys.SERVER_OPTIONS,
1047
+ }).toValue({
1048
+ identifier: 'chat-socket-server',
1049
+ path: '/io',
934
1050
  cors: {
935
- origin: EnvHelper.get('APP_ENV_CORS_ORIGIN') ?? '*',
1051
+ origin: process.env.APP_ENV_CORS_ORIGIN ?? '*',
936
1052
  methods: ['GET', 'POST'],
1053
+ credentials: true,
937
1054
  },
938
1055
  });
939
1056
 
940
- // Authentication middleware
941
- this._io.use(async (socket, next) => {
942
- try {
943
- const token = socket.handshake.auth.token;
944
- if (!token) {
945
- return next(new Error('Authentication required'));
946
- }
1057
+ // 5. Register the component — that's it!
1058
+ // SocketIOComponent handles:
1059
+ // - Runtime detection (Node.js / Bun)
1060
+ // - Post-start hook to create SocketIOServerHelper after server starts
1061
+ // - Redis adapter + emitter setup (automatic)
1062
+ // - Binding SocketIOServerHelper to SOCKET_IO_INSTANCE
1063
+ this.component(SocketIOComponent);
1064
+ }
947
1065
 
948
- const user = await verifyToken(token);
949
- socket.data.userId = user.id;
950
- socket.data.username = user.username;
951
- next();
952
- } catch (error) {
953
- next(new Error('Invalid token'));
954
- }
1066
+ // ---------------------------------------------------------------------------
1067
+ override async stop(): Promise<void> {
1068
+ this.logger.info('[stop] Shutting down chat application...');
1069
+
1070
+ // 1. Shutdown Socket.IO (disconnects all clients, closes IO server, quits Redis)
1071
+ const socketIOHelper = this.get<SocketIOServerHelper>({
1072
+ key: SocketIOBindingKeys.SOCKET_IO_INSTANCE,
1073
+ isOptional: true,
955
1074
  });
956
1075
 
957
- // Get chat service from container
958
- const chatService = this.container.get<ChatService>('services.ChatService');
959
- this._chatHandler = new ChatSocketHandler(this._io, chatService);
1076
+ if (socketIOHelper) {
1077
+ await socketIOHelper.shutdown();
1078
+ }
1079
+
1080
+ // 2. Disconnect Redis helper
1081
+ if (this.redisHelper) {
1082
+ await this.redisHelper.disconnect();
1083
+ }
960
1084
 
961
- // Handle connections
962
- this._io.on('connection', (socket) => {
963
- this._chatHandler.setupHandlers(socket);
964
- });
1085
+ await super.stop();
965
1086
  }
966
1087
  }
967
1088
  ```
968
1089
 
969
- ## 9. REST API for Chat History
1090
+ ### How It Works
1091
+
1092
+ ```
1093
+ Application Lifecycle
1094
+ ═════════════════════
1095
+
1096
+ preConfigure()
1097
+ ├── Register repositories, services, controllers
1098
+ └── setupSocketIO()
1099
+ ├── Bind RedisHelper → REDIS_CONNECTION
1100
+ ├── Bind authenticateFn → AUTHENTICATE_HANDLER
1101
+ ├── Bind clientConnectedFn → CLIENT_CONNECTED_HANDLER
1102
+ ├── Bind server options → SERVER_OPTIONS
1103
+ └── this.component(SocketIOComponent)
1104
+
1105
+ initialize()
1106
+ └── SocketIOComponent.binding()
1107
+ ├── resolveBindings() — reads all bound values
1108
+ ├── RuntimeModules.detect() — auto-detect Node.js or Bun
1109
+ └── registerPostStartHook('socket-io-initialize')
1110
+
1111
+ start()
1112
+ ├── startBunModule() / startNodeModule() — server starts
1113
+ └── executePostStartHooks()
1114
+ └── 'socket-io-initialize'
1115
+ ├── Create SocketIOServerHelper (with Redis adapter + emitter)
1116
+ ├── Bind to SOCKET_IO_INSTANCE
1117
+ └── Wire into server (runtime-specific)
1118
+
1119
+ Client connects → 'authenticate' event → authenticateFn() → 'authenticated'
1120
+ └── clientConnectedFn({ socket }) → chatService.registerClientHandlers({ socket })
1121
+ ```
1122
+
1123
+ ## 7. REST API for Chat History
970
1124
 
971
1125
  ```typescript
972
1126
  // src/controllers/chat.controller.ts
@@ -974,164 +1128,144 @@ import { z } from '@hono/zod-openapi';
974
1128
  import {
975
1129
  BaseController,
976
1130
  controller,
977
- get,
978
- post,
979
1131
  inject,
980
- HTTP,
981
- jsonContent,
1132
+ jsonResponse,
982
1133
  TRouteContext,
1134
+ BindingKeys,
1135
+ BindingNamespaces,
983
1136
  } from '@venizia/ignis';
1137
+ import { HTTP } from '@venizia/ignis-helpers';
984
1138
  import { ChatService } from '../services/chat.service';
985
1139
 
986
1140
  const ChatRoutes = {
987
1141
  GET_ROOMS: {
988
1142
  method: HTTP.Methods.GET,
989
1143
  path: '/rooms',
990
- responses: {
991
- [HTTP.ResultCodes.RS_2.Ok]: jsonContent({
992
- description: 'User rooms',
993
- schema: z.array(z.any()),
994
- }),
995
- },
1144
+ responses: jsonResponse({
1145
+ description: 'User rooms',
1146
+ schema: z.array(z.any()),
1147
+ }),
996
1148
  },
997
1149
  CREATE_ROOM: {
998
1150
  method: HTTP.Methods.POST,
999
1151
  path: '/rooms',
1000
- request: {
1001
- body: jsonContent({
1002
- schema: z.object({
1003
- name: z.string().min(1).max(100),
1004
- description: z.string().optional(),
1005
- isPrivate: z.boolean().default(false),
1006
- }),
1007
- }),
1008
- },
1009
- responses: {
1010
- [HTTP.ResultCodes.RS_2.Created]: jsonContent({
1011
- description: 'Created room',
1012
- schema: z.any(),
1013
- }),
1014
- },
1152
+ responses: jsonResponse({
1153
+ description: 'Created room',
1154
+ schema: z.any(),
1155
+ }),
1015
1156
  },
1016
1157
  GET_MESSAGES: {
1017
1158
  method: HTTP.Methods.GET,
1018
- path: '/rooms/:roomId/messages',
1019
- request: {
1020
- params: z.object({ roomId: z.string().uuid() }),
1021
- query: z.object({
1022
- limit: z.string().optional(),
1023
- before: z.string().optional(),
1024
- }),
1025
- },
1026
- responses: {
1027
- [HTTP.ResultCodes.RS_2.Ok]: jsonContent({
1028
- description: 'Room messages',
1029
- schema: z.array(z.any()),
1030
- }),
1031
- },
1159
+ path: '/rooms/{roomId}/messages',
1160
+ responses: jsonResponse({
1161
+ description: 'Room messages',
1162
+ schema: z.array(z.any()),
1163
+ }),
1032
1164
  },
1033
1165
  GET_CONVERSATIONS: {
1034
1166
  method: HTTP.Methods.GET,
1035
1167
  path: '/conversations',
1036
- responses: {
1037
- [HTTP.ResultCodes.RS_2.Ok]: jsonContent({
1038
- description: 'User conversations',
1039
- schema: z.array(z.any()),
1040
- }),
1041
- },
1168
+ responses: jsonResponse({
1169
+ description: 'User conversations',
1170
+ schema: z.array(z.any()),
1171
+ }),
1042
1172
  },
1043
1173
  GET_DIRECT_MESSAGES: {
1044
1174
  method: HTTP.Methods.GET,
1045
- path: '/dm/:userId',
1046
- request: {
1047
- params: z.object({ userId: z.string().uuid() }),
1048
- query: z.object({
1049
- limit: z.string().optional(),
1050
- before: z.string().optional(),
1051
- }),
1052
- },
1053
- responses: {
1054
- [HTTP.ResultCodes.RS_2.Ok]: jsonContent({
1055
- description: 'Direct messages',
1056
- schema: z.array(z.any()),
1057
- }),
1058
- },
1175
+ path: '/dm/{userId}',
1176
+ responses: jsonResponse({
1177
+ description: 'Direct messages',
1178
+ schema: z.array(z.any()),
1179
+ }),
1059
1180
  },
1060
1181
  } as const;
1061
1182
 
1062
- type ChatRoutes = typeof ChatRoutes;
1063
-
1064
1183
  @controller({ path: '/chat' })
1065
1184
  export class ChatController extends BaseController {
1066
1185
  constructor(
1067
- @inject('services.ChatService')
1186
+ @inject({
1187
+ key: BindingKeys.build({
1188
+ namespace: BindingNamespaces.SERVICE,
1189
+ key: 'ChatService',
1190
+ }),
1191
+ })
1068
1192
  private _chatService: ChatService,
1069
1193
  ) {
1070
- super({ scope: ChatController.name, path: '/chat' });
1071
- }
1072
-
1073
- override binding() {}
1074
-
1075
- @get({ configs: ChatRoutes.GET_ROOMS })
1076
- async getRooms(c: TRouteContext) {
1077
- const userId = c.get('userId');
1078
- const rooms = await this._chatService.getUserRooms({ userId });
1079
- return c.json(rooms);
1194
+ super({ scope: ChatController.name });
1195
+ this.definitions = ChatRoutes;
1080
1196
  }
1081
1197
 
1082
- @post({ configs: ChatRoutes.CREATE_ROOM })
1083
- async createRoom(c: TRouteContext) {
1084
- const userId = c.get('userId');
1085
- const data = c.req.valid<{ name: string; description?: string; isPrivate: boolean }>('json');
1198
+ override binding() {
1199
+ // GET /chat/rooms
1200
+ this.bindRoute({ configs: ChatRoutes.GET_ROOMS }).to({
1201
+ handler: async (c: TRouteContext) => {
1202
+ const userId = c.get('userId');
1203
+ const rooms = await this._chatService.getUserRooms({ userId });
1204
+ return c.json(rooms, HTTP.ResultCodes.RS_2.Ok);
1205
+ },
1206
+ });
1086
1207
 
1087
- const room = await this._chatService.createRoom({
1088
- ...data,
1089
- createdBy: userId,
1208
+ // POST /chat/rooms
1209
+ this.bindRoute({ configs: ChatRoutes.CREATE_ROOM }).to({
1210
+ handler: async (c: TRouteContext) => {
1211
+ const userId = c.get('userId');
1212
+ const data = await c.req.json<{ name: string; description?: string; isPrivate: boolean }>();
1213
+ const room = await this._chatService.createRoom({ ...data, createdBy: userId });
1214
+ return c.json(room, HTTP.ResultCodes.RS_2.Created);
1215
+ },
1090
1216
  });
1091
1217
 
1092
- return c.json(room, HTTP.ResultCodes.RS_2.Created);
1093
- }
1218
+ // GET /chat/rooms/:roomId/messages
1219
+ this.bindRoute({ configs: ChatRoutes.GET_MESSAGES }).to({
1220
+ handler: async (c: TRouteContext) => {
1221
+ const roomId = c.req.param('roomId');
1222
+ const limit = c.req.query('limit');
1223
+ const before = c.req.query('before');
1094
1224
 
1095
- @get({ configs: ChatRoutes.GET_MESSAGES })
1096
- async getMessages(c: TRouteContext) {
1097
- const { roomId } = c.req.valid<{ roomId: string }>('param');
1098
- const { limit, before } = c.req.valid<{ limit?: string; before?: string }>('query');
1225
+ const messages = await this._chatService.getMessages({
1226
+ roomId,
1227
+ limit: limit ? parseInt(limit) : undefined,
1228
+ before: before ?? undefined,
1229
+ });
1099
1230
 
1100
- const messages = await this._chatService.getMessages({
1101
- roomId,
1102
- limit: limit ? parseInt(limit) : undefined,
1103
- before,
1231
+ return c.json(messages, HTTP.ResultCodes.RS_2.Ok);
1232
+ },
1104
1233
  });
1105
1234
 
1106
- return c.json(messages);
1107
- }
1235
+ // GET /chat/conversations
1236
+ this.bindRoute({ configs: ChatRoutes.GET_CONVERSATIONS }).to({
1237
+ handler: async (c: TRouteContext) => {
1238
+ const userId = c.get('userId');
1239
+ const conversations = await this._chatService.getConversations({ userId });
1240
+ return c.json(conversations, HTTP.ResultCodes.RS_2.Ok);
1241
+ },
1242
+ });
1108
1243
 
1109
- @get({ configs: ChatRoutes.GET_CONVERSATIONS })
1110
- async getConversations(c: TRouteContext) {
1111
- const userId = c.get('userId');
1112
- const conversations = await this._chatService.getConversations({ userId });
1113
- return c.json(conversations);
1114
- }
1244
+ // GET /chat/dm/:userId
1245
+ this.bindRoute({ configs: ChatRoutes.GET_DIRECT_MESSAGES }).to({
1246
+ handler: async (c: TRouteContext) => {
1247
+ const currentUserId = c.get('userId');
1248
+ const otherUserId = c.req.param('userId');
1249
+ const limit = c.req.query('limit');
1250
+ const before = c.req.query('before');
1251
+
1252
+ const messages = await this._chatService.getDirectMessages({
1253
+ userId1: currentUserId,
1254
+ userId2: otherUserId,
1255
+ limit: limit ? parseInt(limit) : undefined,
1256
+ before: before ?? undefined,
1257
+ });
1115
1258
 
1116
- @get({ configs: ChatRoutes.GET_DIRECT_MESSAGES })
1117
- async getDirectMessages(c: TRouteContext) {
1118
- const currentUserId = c.get('userId');
1119
- const { userId: otherUserId } = c.req.valid<{ userId: string }>('param');
1120
- const { limit, before } = c.req.valid<{ limit?: string; before?: string }>('query');
1121
-
1122
- const messages = await this._chatService.getDirectMessages({
1123
- userId1: currentUserId,
1124
- userId2: otherUserId,
1125
- limit: limit ? parseInt(limit) : undefined,
1126
- before,
1259
+ return c.json(messages, HTTP.ResultCodes.RS_2.Ok);
1260
+ },
1127
1261
  });
1128
-
1129
- return c.json(messages);
1130
1262
  }
1131
1263
  }
1132
1264
  ```
1133
1265
 
1134
- ## 10. Client Usage
1266
+ ## 8. Client Usage
1267
+
1268
+ Clients must follow the `SocketIOServerHelper` authentication flow: **connect** → **emit `authenticate`** → **receive `authenticated`** — then they're ready to send and receive events.
1135
1269
 
1136
1270
  ### JavaScript Client Example
1137
1271
 
@@ -1139,72 +1273,118 @@ export class ChatController extends BaseController {
1139
1273
  // client/chat-client.ts
1140
1274
  import { io, Socket } from 'socket.io-client';
1141
1275
 
1142
- interface Message {
1143
- id: string;
1144
- content: string;
1145
- senderId: string;
1146
- sender: { username: string; avatar?: string };
1147
- createdAt: string;
1148
- }
1276
+ const SERVER_URL = 'http://localhost:3000';
1277
+ const SOCKET_PATH = '/io';
1149
1278
 
1150
1279
  class ChatClient {
1151
1280
  private _socket: Socket;
1152
-
1153
- constructor(opts: { serverUrl: string; token: string }) {
1154
- this._socket = io(opts.serverUrl, {
1155
- auth: { token: opts.token },
1281
+ private _authenticated = false;
1282
+
1283
+ constructor(opts: { token: string }) {
1284
+ this._socket = io(SERVER_URL, {
1285
+ path: SOCKET_PATH,
1286
+ transports: ['websocket', 'polling'],
1287
+ extraHeaders: {
1288
+ Authorization: `Bearer ${opts.token}`,
1289
+ },
1156
1290
  });
1157
1291
 
1158
- this.setupListeners();
1292
+ this.setupLifecycle();
1159
1293
  }
1160
1294
 
1161
- private setupListeners() {
1295
+ // ---------------------------------------------------------------------------
1296
+ // Connection lifecycle — follows SocketIOServerHelper's auth flow
1297
+ // ---------------------------------------------------------------------------
1298
+ private setupLifecycle() {
1299
+ // Step 1: Connected — now send authenticate event
1162
1300
  this._socket.on('connect', () => {
1163
- console.log('Connected to chat server');
1301
+ console.log('Connected | id:', this._socket.id);
1302
+ this._socket.emit('authenticate');
1164
1303
  });
1165
1304
 
1166
- this._socket.on('message:new', (message: Message) => {
1305
+ // Step 2: Authenticated ready to use
1306
+ this._socket.on('authenticated', (data: { id: string; time: string }) => {
1307
+ console.log('Authenticated | id:', data.id);
1308
+ this._authenticated = true;
1309
+ this.setupEventHandlers();
1310
+ });
1311
+
1312
+ // Authentication failed
1313
+ this._socket.on('unauthenticated', (data: { message: string }) => {
1314
+ console.error('Authentication failed:', data.message);
1315
+ this._authenticated = false;
1316
+ });
1317
+
1318
+ // Keep-alive ping from server (every 30s)
1319
+ this._socket.on('ping', () => {
1320
+ // Server is checking we're alive — no action needed
1321
+ });
1322
+
1323
+ this._socket.on('disconnect', (reason: string) => {
1324
+ console.log('Disconnected:', reason);
1325
+ this._authenticated = false;
1326
+ });
1327
+ }
1328
+
1329
+ // ---------------------------------------------------------------------------
1330
+ // Custom event handlers — registered after authentication
1331
+ // ---------------------------------------------------------------------------
1332
+ private setupEventHandlers() {
1333
+ this._socket.on('message:new', (message) => {
1167
1334
  console.log('New message:', message);
1168
- // Update UI
1335
+ });
1336
+
1337
+ this._socket.on('dm:new', (message) => {
1338
+ console.log('Direct message:', message);
1169
1339
  });
1170
1340
 
1171
1341
  this._socket.on('typing:update', (data) => {
1172
- console.log(`User ${data.userId} is ${data.isTyping ? 'typing' : 'stopped typing'}`);
1173
- // Show/hide typing indicator
1342
+ const action = data.isTyping ? 'typing' : 'stopped typing';
1343
+ console.log(`User ${data.userId} is ${action} in room ${data.roomId}`);
1174
1344
  });
1175
1345
 
1176
1346
  this._socket.on('presence:changed', (data) => {
1177
1347
  console.log(`User ${data.userId} is now ${data.status}`);
1178
- // Update online status
1348
+ });
1349
+
1350
+ this._socket.on('room:user-joined', (data) => {
1351
+ console.log(`User ${data.userId} joined room ${data.roomId}`);
1352
+ });
1353
+
1354
+ this._socket.on('room:user-left', (data) => {
1355
+ console.log(`User ${data.userId} left room ${data.roomId}`);
1179
1356
  });
1180
1357
 
1181
1358
  this._socket.on('error', (error) => {
1182
- console.error('Socket error:', error);
1359
+ console.error('Error:', error);
1183
1360
  });
1184
1361
  }
1185
1362
 
1186
- joinRoom(opts: { roomId: string }) {
1187
- this._socket.emit('room:join', opts.roomId);
1363
+ // ---------------------------------------------------------------------------
1364
+ // Public API
1365
+ // ---------------------------------------------------------------------------
1366
+ joinRoom(opts: { roomId: string; userId: string }) {
1367
+ this._socket.emit('room:join', opts);
1188
1368
  }
1189
1369
 
1190
- leaveRoom(opts: { roomId: string }) {
1191
- this._socket.emit('room:leave', opts.roomId);
1370
+ leaveRoom(opts: { roomId: string; userId: string }) {
1371
+ this._socket.emit('room:leave', opts);
1192
1372
  }
1193
1373
 
1194
- sendMessage(opts: { roomId: string; content: string }) {
1195
- this._socket.emit('message:send', { roomId: opts.roomId, content: opts.content });
1374
+ sendMessage(opts: { roomId: string; content: string; userId: string }) {
1375
+ this._socket.emit('message:send', opts);
1196
1376
  }
1197
1377
 
1198
- sendDirectMessage(opts: { receiverId: string; content: string }) {
1199
- this._socket.emit('dm:send', { receiverId: opts.receiverId, content: opts.content });
1378
+ sendDirectMessage(opts: { receiverId: string; content: string; userId: string }) {
1379
+ this._socket.emit('dm:send', opts);
1200
1380
  }
1201
1381
 
1202
1382
  startTyping(opts: { roomId: string }) {
1203
- this._socket.emit('typing:start', opts.roomId);
1383
+ this._socket.emit('typing:start', opts);
1204
1384
  }
1205
1385
 
1206
1386
  stopTyping(opts: { roomId: string }) {
1207
- this._socket.emit('typing:stop', opts.roomId);
1387
+ this._socket.emit('typing:stop', opts);
1208
1388
  }
1209
1389
 
1210
1390
  disconnect() {
@@ -1213,49 +1393,133 @@ class ChatClient {
1213
1393
  }
1214
1394
 
1215
1395
  // Usage
1216
- const chat = new ChatClient({ serverUrl: 'http://localhost:3000', token: 'your-jwt-token' });
1396
+ const chat = new ChatClient({ token: 'your-jwt-token' });
1217
1397
 
1218
- chat.joinRoom({ roomId: 'room-uuid' });
1219
- chat.sendMessage({ roomId: 'room-uuid', content: 'Hello everyone!' });
1398
+ // After 'authenticated' event fires, you can use:
1399
+ // chat.joinRoom({ roomId: 'room-uuid', userId: 'user-uuid' });
1400
+ // chat.sendMessage({ roomId: 'room-uuid', content: 'Hello everyone!', userId: 'user-uuid' });
1220
1401
  ```
1221
1402
 
1222
- ## 11. Scaling with Redis
1403
+ ### Using `SocketIOClientHelper`
1223
1404
 
1224
- For production, use Redis for Socket.IO adapter:
1405
+ For server-to-server or microservice communication, use the built-in `SocketIOClientHelper`:
1225
1406
 
1226
1407
  ```typescript
1227
- // src/application.ts
1228
- import { createAdapter } from '@socket.io/redis-adapter';
1229
- import { createClient } from 'redis';
1230
- import { EnvHelper } from '@venizia/ignis-helpers';
1408
+ import { SocketIOClientHelper } from '@venizia/ignis-helpers';
1409
+
1410
+ const client = new SocketIOClientHelper({
1411
+ identifier: 'chat-service-client',
1412
+ host: 'http://localhost:3000',
1413
+ options: {
1414
+ path: '/io',
1415
+ extraHeaders: {
1416
+ Authorization: 'Bearer service-token',
1417
+ },
1418
+ },
1419
+ onConnected: () => {
1420
+ client.authenticate();
1421
+ },
1422
+ onAuthenticated: () => {
1423
+ console.log('Ready!');
1231
1424
 
1232
- private async setupSocketIO() {
1233
- const pubClient = createClient({ url: EnvHelper.get('APP_ENV_REDIS_URL') });
1234
- const subClient = pubClient.duplicate();
1425
+ // Subscribe to events
1426
+ client.subscribe({
1427
+ event: 'message:new',
1428
+ handler: (data) => console.log('New message:', data),
1429
+ });
1235
1430
 
1236
- await Promise.all([pubClient.connect(), subClient.connect()]);
1431
+ // Emit events
1432
+ client.emit({
1433
+ topic: 'message:send',
1434
+ data: { roomId: 'general', content: 'Hello from service!', userId: 'service-user' },
1435
+ });
1237
1436
 
1238
- this._io.adapter(createAdapter(pubClient, subClient));
1437
+ // Join rooms
1438
+ client.joinRooms({ rooms: ['room:general'] });
1439
+ },
1440
+ });
1441
+ ```
1239
1442
 
1240
- // ... rest of setup
1241
- }
1443
+ ## 9. Scaling with Redis
1444
+
1445
+ Redis scaling is **built-in** and **automatic** when using `SocketIOComponent`. You do not need to set up the Redis adapter manually.
1446
+
1447
+ ### How It Works
1448
+
1449
+ When you bind a `RedisHelper` to `SocketIOBindingKeys.REDIS_CONNECTION`, the `SocketIOServerHelper` automatically:
1450
+
1451
+ 1. Creates 3 duplicate Redis connections from your helper
1452
+ 2. Sets up `@socket.io/redis-adapter` for cross-instance pub/sub
1453
+ 3. Sets up `@socket.io/redis-emitter` for message broadcasting
1454
+
1455
+ ```
1456
+ Process A Redis Process B
1457
+ ┌─────────────┐ ┌──────────┐ ┌─────────────┐
1458
+ │ SocketIO │──pub────► │ │ ◄──pub────│ SocketIO │
1459
+ │ ServerHelper │◄──sub──── │ Pub/Sub │ ──sub────►│ ServerHelper │
1460
+ │ │ │ │ │ │
1461
+ │ Emitter │──emit───► │ Streams │ ◄──emit───│ Emitter │
1462
+ └─────────────┘ └──────────┘ └─────────────┘
1463
+ ```
1464
+
1465
+ ### Multi-Instance Deployment
1466
+
1467
+ Run multiple instances behind a load balancer — Redis keeps them in sync:
1468
+
1469
+ ```bash
1470
+ # Instance 1
1471
+ APP_ENV_SERVER_PORT=3001 bun run start
1472
+
1473
+ # Instance 2
1474
+ APP_ENV_SERVER_PORT=3002 bun run start
1475
+
1476
+ # Both instances share Socket.IO state via Redis
1477
+ # Clients on Instance 1 can receive messages from Instance 2
1478
+ ```
1479
+
1480
+ All calls to `socketIOHelper.send()` go through the Redis emitter, so messages reach clients regardless of which server instance they're connected to.
1481
+
1482
+ ### Redis Configuration
1483
+
1484
+ The only thing you need is a `RedisHelper` bound to the correct key (already done in the application setup):
1485
+
1486
+ ```typescript
1487
+ this.redisHelper = new RedisHelper({
1488
+ name: 'chat-redis',
1489
+ host: process.env.APP_ENV_REDIS_HOST ?? 'localhost',
1490
+ port: +(process.env.APP_ENV_REDIS_PORT ?? 6379),
1491
+ password: process.env.APP_ENV_REDIS_PASSWORD,
1492
+ autoConnect: false,
1493
+ });
1494
+
1495
+ this.bind<RedisHelper>({
1496
+ key: SocketIOBindingKeys.REDIS_CONNECTION,
1497
+ }).toValue(this.redisHelper);
1242
1498
  ```
1243
1499
 
1244
1500
  ## Summary
1245
1501
 
1246
1502
  | Feature | Implementation |
1247
1503
  |---------|---------------|
1248
- | Rooms | Socket.IO rooms + database |
1249
- | Direct Messages | Personal rooms + database |
1250
- | Typing Indicators | Socket events with auto-timeout |
1251
- | Presence | Online status tracking |
1252
- | History | REST API with pagination |
1253
- | Scaling | Redis adapter for pub/sub |
1504
+ | Rooms | `SocketIOServerHelper` rooms + database persistence |
1505
+ | Direct Messages | `socketIOHelper.send({ destination: receiverId })` + database |
1506
+ | Typing Indicators | Custom socket events with auto-timeout (3s) |
1507
+ | Presence | `socketIOHelper.send()` broadcast on connect/disconnect |
1508
+ | History | REST API with cursor-based pagination |
1509
+ | Authentication | `SocketIOServerHelper` built-in flow (connect → authenticate → authenticated) |
1510
+ | Scaling | Redis adapter/emitter — automatic via `RedisHelper` binding |
1511
+ | Runtime | Auto-detected (Node.js or Bun) by `SocketIOComponent` |
1254
1512
 
1255
1513
  ## Next Steps
1256
1514
 
1257
- - Add file/image sharing with [Storage Helper](../../references/helpers/storage.md)
1515
+ - Add file/image sharing with [Storage Helper](/references/helpers/storage)
1258
1516
  - Add push notifications
1259
1517
  - Implement read receipts
1260
1518
  - Add message reactions
1261
- - Deploy with [Deployment Guide](../../best-practices/deployment-strategies.md)
1519
+ - Deploy with [Deployment Guide](/best-practices/deployment-strategies)
1520
+
1521
+ ## See Also
1522
+
1523
+ - [Socket.IO Component](/references/components/socket-io) — Component reference
1524
+ - [Socket.IO Helper](/references/helpers/socket-io) — Server + Client helper API
1525
+ - [Socket.IO Test Example](https://github.com/VENIZIA-AI/ignis/tree/main/examples/socket-io-test) — Working example with automated test client