Solidis logoSolidis
← 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/Sub
1import { 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 Integration
1import { 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 Server
1import { 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}