@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.
- package/package.json +2 -2
- package/wiki/best-practices/code-style-standards/function-patterns.md +31 -5
- package/wiki/best-practices/performance-optimization.md +39 -0
- package/wiki/best-practices/troubleshooting-tips.md +24 -3
- package/wiki/guides/tutorials/realtime-chat.md +724 -460
- package/wiki/references/base/filter-system/json-filtering.md +4 -2
- package/wiki/references/base/middlewares.md +24 -72
- package/wiki/references/base/repositories/advanced.md +83 -0
- package/wiki/references/base/repositories/index.md +1 -1
- package/wiki/references/components/socket-io.md +495 -78
- package/wiki/references/helpers/logger.md +222 -43
- package/wiki/references/helpers/network.md +273 -31
- package/wiki/references/helpers/socket-io.md +881 -71
- package/wiki/references/quick-reference.md +3 -5
- package/wiki/references/src-details/core.md +1 -2
- package/wiki/references/utilities/statuses.md +4 -2
|
@@ -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
|
-
|
|
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
|
-
-
|
|
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
|
|
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.
|
|
439
|
+
## 5. Chat Service
|
|
435
440
|
|
|
436
|
-
|
|
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/
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
'message:delete': (data: { messageId: string }) => void;
|
|
465
|
+
constructor(
|
|
466
|
+
@inject({ key: CoreBindings.APPLICATION_INSTANCE })
|
|
467
|
+
private application: BaseApplication,
|
|
451
468
|
|
|
452
|
-
|
|
453
|
-
|
|
469
|
+
@inject({
|
|
470
|
+
key: BindingKeys.build({
|
|
471
|
+
namespace: BindingNamespaces.REPOSITORY,
|
|
472
|
+
key: 'MessageRepository',
|
|
473
|
+
}),
|
|
474
|
+
})
|
|
475
|
+
private _messageRepo: MessageRepository,
|
|
454
476
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
477
|
+
@inject({
|
|
478
|
+
key: BindingKeys.build({
|
|
479
|
+
namespace: BindingNamespaces.REPOSITORY,
|
|
480
|
+
key: 'RoomRepository',
|
|
481
|
+
}),
|
|
482
|
+
})
|
|
483
|
+
private _roomRepo: RoomRepository,
|
|
458
484
|
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
//
|
|
464
|
-
|
|
465
|
-
//
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
471
|
-
|
|
508
|
+
if (!this._socketIOHelper) {
|
|
509
|
+
throw new Error('[ChatService] SocketIO not initialized. Server must be started first.');
|
|
510
|
+
}
|
|
472
511
|
|
|
473
|
-
|
|
474
|
-
|
|
512
|
+
return this._socketIOHelper;
|
|
513
|
+
}
|
|
475
514
|
|
|
476
|
-
//
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
}
|
|
525
|
+
// --- Set user online (using socket ID as user identifier for simplicity) ---
|
|
526
|
+
this.broadcastPresence({ userId: socketId, status: 'online' });
|
|
486
527
|
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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: {
|
|
868
|
-
const
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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.
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
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 {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
//
|
|
921
|
-
this.
|
|
922
|
-
}
|
|
980
|
+
// Register controllers
|
|
981
|
+
this.controller(ChatController);
|
|
923
982
|
|
|
924
|
-
|
|
983
|
+
// Setup Socket.IO
|
|
925
984
|
this.setupSocketIO();
|
|
926
985
|
}
|
|
927
986
|
|
|
928
|
-
|
|
987
|
+
postConfigure(): ValueOrPromise<void> {}
|
|
929
988
|
|
|
989
|
+
setupMiddlewares(): ValueOrPromise<void> {}
|
|
990
|
+
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
930
992
|
private setupSocketIO() {
|
|
931
|
-
|
|
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.
|
|
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:
|
|
1051
|
+
origin: process.env.APP_ENV_CORS_ORIGIN ?? '*',
|
|
936
1052
|
methods: ['GET', 'POST'],
|
|
1053
|
+
credentials: true,
|
|
937
1054
|
},
|
|
938
1055
|
});
|
|
939
1056
|
|
|
940
|
-
//
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1225
|
+
const messages = await this._chatService.getMessages({
|
|
1226
|
+
roomId,
|
|
1227
|
+
limit: limit ? parseInt(limit) : undefined,
|
|
1228
|
+
before: before ?? undefined,
|
|
1229
|
+
});
|
|
1099
1230
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
limit: limit ? parseInt(limit) : undefined,
|
|
1103
|
-
before,
|
|
1231
|
+
return c.json(messages, HTTP.ResultCodes.RS_2.Ok);
|
|
1232
|
+
},
|
|
1104
1233
|
});
|
|
1105
1234
|
|
|
1106
|
-
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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.
|
|
1292
|
+
this.setupLifecycle();
|
|
1159
1293
|
}
|
|
1160
1294
|
|
|
1161
|
-
|
|
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
|
|
1301
|
+
console.log('Connected | id:', this._socket.id);
|
|
1302
|
+
this._socket.emit('authenticate');
|
|
1164
1303
|
});
|
|
1165
1304
|
|
|
1166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
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('
|
|
1359
|
+
console.error('Error:', error);
|
|
1183
1360
|
});
|
|
1184
1361
|
}
|
|
1185
1362
|
|
|
1186
|
-
|
|
1187
|
-
|
|
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
|
|
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',
|
|
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',
|
|
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
|
|
1383
|
+
this._socket.emit('typing:start', opts);
|
|
1204
1384
|
}
|
|
1205
1385
|
|
|
1206
1386
|
stopTyping(opts: { roomId: string }) {
|
|
1207
|
-
this._socket.emit('typing:stop', opts
|
|
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({
|
|
1396
|
+
const chat = new ChatClient({ token: 'your-jwt-token' });
|
|
1217
1397
|
|
|
1218
|
-
|
|
1219
|
-
chat.
|
|
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
|
-
|
|
1403
|
+
### Using `SocketIOClientHelper`
|
|
1223
1404
|
|
|
1224
|
-
For
|
|
1405
|
+
For server-to-server or microservice communication, use the built-in `SocketIOClientHelper`:
|
|
1225
1406
|
|
|
1226
1407
|
```typescript
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1425
|
+
// Subscribe to events
|
|
1426
|
+
client.subscribe({
|
|
1427
|
+
event: 'message:new',
|
|
1428
|
+
handler: (data) => console.log('New message:', data),
|
|
1429
|
+
});
|
|
1235
1430
|
|
|
1236
|
-
|
|
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
|
-
|
|
1437
|
+
// Join rooms
|
|
1438
|
+
client.joinRooms({ rooms: ['room:general'] });
|
|
1439
|
+
},
|
|
1440
|
+
});
|
|
1441
|
+
```
|
|
1239
1442
|
|
|
1240
|
-
|
|
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 |
|
|
1249
|
-
| Direct Messages |
|
|
1250
|
-
| Typing Indicators |
|
|
1251
|
-
| Presence |
|
|
1252
|
-
| History | REST API with pagination |
|
|
1253
|
-
|
|
|
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](
|
|
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](
|
|
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
|