← Back to Tutorials
Advanced
45 min
Building a Real-time Chat Application
Create a scalable real-time chat system using Redis Pub/Sub with Solidis and WebSockets.
What You'll Build
- Real-time message broadcasting with Redis Pub/Sub
- WebSocket server integration
- Multiple chat rooms support
- Message history and persistence
1
Chat Manager with Pub/Sub1import { SolidisFeaturedClient } from '@vcms-io/solidis/featured';
2
3export interface Message {
4 id: string;
5 roomId: string;
6 userId: string;
7 username: string;
8 content: string;
9 timestamp: number;
10}
11
12export class ChatManager {
13 private publisher: SolidisFeaturedClient;
14 private subscriber: SolidisFeaturedClient;
15 private messageHandlers: Map<
16 string,
17 Map<string, (message: Message) => void>
18 >;
19
20 constructor(options: { host?: string; port?: number } = {}) {
21 // Separate clients for pub and sub
22 this.publisher = new SolidisFeaturedClient({
23 host: options.host || '127.0.0.1',
24 port: options.port || 6379,
25 });
26
27 this.subscriber = new SolidisFeaturedClient({
28 host: options.host || '127.0.0.1',
29 port: options.port || 6379,
30 });
31
32 this.messageHandlers = new Map();
33 }
34
35 async connect(): Promise<void> {
36 await this.publisher.connect();
37 await this.subscriber.connect();
38
39 // Set up message handler
40 this.subscriber.on('message', (channel, message) => {
41 const roomId = channel.replace('chat:', '');
42 const data = JSON.parse(message.toString()) as Message;
43
44 for (const handler of this.messageHandlers.get(roomId)?.values() ?? []) {
45 handler(data);
46 }
47 });
48 }
49
50 /**
51 * Join a chat room
52 */
53 async joinRoom(
54 roomId: string,
55 subscriberId: string,
56 onMessage: (message: Message) => void
57 ): Promise<void> {
58 const channel = `chat:${roomId}`;
59 const handlers = this.messageHandlers.get(roomId) ?? new Map();
60
61 handlers.set(subscriberId, onMessage);
62 this.messageHandlers.set(roomId, handlers);
63
64 if (handlers.size === 1) {
65 await this.subscriber.subscribe(channel);
66 }
67 }
68
69 /**
70 * Leave a chat room
71 */
72 async leaveRoom(roomId: string, subscriberId: string): Promise<void> {
73 const channel = `chat:${roomId}`;
74 const handlers = this.messageHandlers.get(roomId);
75
76 if (!handlers?.delete(subscriberId) || handlers.size > 0) {
77 return;
78 }
79
80 this.messageHandlers.delete(roomId);
81 await this.subscriber.unsubscribe(channel);
82 }
83
84 /**
85 * Send a message to a room
86 */
87 async sendMessage(message: Message): Promise<void> {
88 const channel = `chat:${message.roomId}`;
89
90 // Publish to channel
91 await this.publisher.publish(channel, JSON.stringify(message));
92
93 // Store in message history
94 await this.storeMessage(message);
95 }
96
97 /**
98 * Store message in history
99 */
100 private async storeMessage(message: Message): Promise<void> {
101 const key = `chat:history:${message.roomId}`;
102
103 // Store as sorted set with timestamp as score
104 await this.publisher.zadd(
105 key,
106 message.timestamp,
107 JSON.stringify(message)
108 );
109
110 // Keep only last 100 messages (use raw command for ZREMRANGEBYRANK)
111 await this.publisher.send([['ZREMRANGEBYRANK', key, '0', '-101']]);
112
113 // Set expiry (e.g., 7 days)
114 await this.publisher.expire(key, 7 * 24 * 3600);
115 }
116
117 /**
118 * Get message history
119 */
120 async getHistory(
121 roomId: string,
122 limit: number = 50
123 ): Promise<Message[]> {
124 const key = `chat:history:${roomId}`;
125
126 // Get last N messages
127 const messages = await this.publisher.zrange(key, `${-limit}`, '-1');
128
129 return messages.map((message) => JSON.parse(message) as Message);
130 }
131
132 /**
133 * Get active users in a room
134 */
135 async getActiveUsers(roomId: string): Promise<string[]> {
136 const key = `chat:users:${roomId}`;
137 return await this.publisher.smembers(key);
138 }
139
140 /**
141 * Mark user as active in room
142 */
143 async markUserActive(roomId: string, userId: string): Promise<void> {
144 const key = `chat:users:${roomId}`;
145 await this.publisher.sadd(key, userId);
146 await this.publisher.expire(key, 300); // 5 minutes
147 }
148
149 /**
150 * Remove user from room
151 */
152 async removeUser(roomId: string, userId: string): Promise<void> {
153 const key = `chat:users:${roomId}`;
154 await this.publisher.srem(key, userId);
155 }
156}2
WebSocket Server Integration1import { WebSocketServer, WebSocket } from 'ws';
2import { ChatManager, Message } from './chat-manager';
3import { randomUUID } from 'node:crypto';
4
5interface ClientData {
6 userId: string;
7 username: string;
8 roomId: string | null;
9}
10
11export class ChatServer {
12 private wss: WebSocketServer;
13 private chat: ChatManager;
14 private clients: Map<WebSocket, ClientData>;
15
16 constructor(port: number, chat: ChatManager) {
17 this.wss = new WebSocketServer({ port });
18 this.chat = chat;
19 this.clients = new Map();
20
21 this.setupServer();
22 }
23
24 private setupServer() {
25 this.wss.on('connection', (ws: WebSocket) => {
26 console.log('New client connected');
27
28 // Initialize client data
29 this.clients.set(ws, {
30 userId: randomUUID(),
31 username: 'Anonymous',
32 roomId: null,
33 });
34
35 ws.on('message', async (data: Buffer) => {
36 try {
37 const message = JSON.parse(data.toString());
38 await this.handleMessage(ws, message);
39 } catch (error) {
40 console.error('Error handling message:', error);
41 this.sendError(ws, 'Invalid message format');
42 }
43 });
44
45 ws.on('close', async () => {
46 const client = this.clients.get(ws);
47 if (client?.roomId) {
48 await this.chat.leaveRoom(client.roomId, client.userId);
49 await this.chat.removeUser(client.roomId, client.userId);
50 }
51 this.clients.delete(ws);
52 console.log('Client disconnected');
53 });
54 });
55 }
56
57 private async handleMessage(ws: WebSocket, message: any) {
58 const client = this.clients.get(ws);
59 if (!client) return;
60
61 switch (message.type) {
62 case 'join':
63 await this.handleJoin(ws, client, message.roomId, message.username);
64 break;
65
66 case 'leave':
67 await this.handleLeave(ws, client);
68 break;
69
70 case 'message':
71 await this.handleChatMessage(ws, client, message.content);
72 break;
73
74 case 'history':
75 await this.handleHistory(ws, client);
76 break;
77
78 default:
79 this.sendError(ws, 'Unknown message type');
80 }
81 }
82
83 private async handleJoin(
84 ws: WebSocket,
85 client: ClientData,
86 roomId: string,
87 username: string
88 ) {
89 // Leave current room if any
90 if (client.roomId) {
91 await this.chat.leaveRoom(client.roomId, client.userId);
92 await this.chat.removeUser(client.roomId, client.userId);
93 }
94
95 // Update client data
96 client.roomId = roomId;
97 client.username = username || 'Anonymous';
98
99 // Join new room
100 await this.chat.joinRoom(roomId, client.userId, (message) => {
101 this.sendToClient(ws, {
102 type: 'message',
103 data: message,
104 });
105 });
106
107 // Mark user as active
108 await this.chat.markUserActive(roomId, client.userId);
109
110 // Send join confirmation
111 this.sendToClient(ws, {
112 type: 'joined',
113 roomId,
114 userId: client.userId,
115 });
116
117 // Send message history
118 const history = await this.chat.getHistory(roomId);
119 this.sendToClient(ws, {
120 type: 'history',
121 messages: history,
122 });
123 }
124
125 private async handleLeave(ws: WebSocket, client: ClientData) {
126 if (client.roomId) {
127 await this.chat.leaveRoom(client.roomId, client.userId);
128 await this.chat.removeUser(client.roomId, client.userId);
129 client.roomId = null;
130
131 this.sendToClient(ws, { type: 'left' });
132 }
133 }
134
135 private async handleChatMessage(
136 ws: WebSocket,
137 client: ClientData,
138 content: string
139 ) {
140 if (!client.roomId) {
141 this.sendError(ws, 'Not in a room');
142 return;
143 }
144
145 const message: Message = {
146 id: randomUUID(),
147 roomId: client.roomId,
148 userId: client.userId,
149 username: client.username,
150 content,
151 timestamp: Date.now(),
152 };
153
154 await this.chat.sendMessage(message);
155 }
156
157 private async handleHistory(ws: WebSocket, client: ClientData) {
158 if (!client.roomId) {
159 this.sendError(ws, 'Not in a room');
160 return;
161 }
162
163 const history = await this.chat.getHistory(client.roomId);
164 this.sendToClient(ws, {
165 type: 'history',
166 messages: history,
167 });
168 }
169
170 private sendToClient(ws: WebSocket, data: any) {
171 if (ws.readyState === WebSocket.OPEN) {
172 ws.send(JSON.stringify(data));
173 }
174 }
175
176 private sendError(ws: WebSocket, message: string) {
177 this.sendToClient(ws, {
178 type: 'error',
179 message,
180 });
181 }
182}3
Start the Server1import { ChatManager } from './chat-manager';
2import { ChatServer } from './chat-server';
3
4async function start() {
5 const chat = new ChatManager({
6 host: '127.0.0.1',
7 port: 6379,
8 });
9
10 await chat.connect();
11 console.log('Connected to Redis');
12
13 const server = new ChatServer(8080, chat);
14 console.log('WebSocket server running on ws://localhost:8080');
15}
16
17start().catch(console.error);Client Example
1// Simple WebSocket client example
2const ws = new WebSocket('ws://localhost:8080');
3
4ws.onopen = () => {
5 // Join a room
6 ws.send(JSON.stringify({
7 type: 'join',
8 roomId: 'general',
9 username: 'John Doe',
10 }));
11};
12
13ws.onmessage = (event) => {
14 const data = JSON.parse(event.data);
15
16 switch (data.type) {
17 case 'joined':
18 console.log('Joined room:', data.roomId);
19 sendMessage('Hello, everyone!');
20 break;
21
22 case 'message':
23 console.log(`${data.data.username}: ${data.data.content}`);
24 break;
25
26 case 'history':
27 console.log('Message history:', data.messages);
28 break;
29 }
30};
31
32// Send a message
33function sendMessage(content: string) {
34 ws.send(JSON.stringify({
35 type: 'message',
36 content,
37 }));
38}