The purpose of this article is to understand the basics of websockets and cache with a common known example : a Chat.
Usually, a Chat have to be really reactive with very good performance. There are many ways to achieve different levels of performance.
In this article, we’ll build a basic sample high-performance Chat together with Typescript, Redis and Websockets. I’ll guide you step-by-step as if I was doing it with you.
First, let’s take a look at our architecture.
- To the keep things simple, we’ll be doing the frontend in plain HTML/JS without framework
- The Nodejs server will be written with express as it’s most known.
- A Redis server to cache messages
Tips : If you wanna enhance performance further, you can opt for another framework like fastify, websocket.io, uWebSocket or whatever you want. We’ll stay with express as it’s well commonly known.
Project structure
.
├── docker-compose.yml
├── Dockerfile
├── src
│ ├── index.ts
│ ├── server.ts
│ ├── redisClient.ts
│ └── types.ts
├── tsconfig.json
└── package.json
Create the nodejs project
npm init
and feed the package.json file with the following configuration :
{ "name": "chat-app", "version": "1.0.0", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js" }, "dependencies": { "express": "^4.17.1", "redis": "^4.0.0", "ws": "^8.0.0" }, "devDependencies": { "@types/node": "^16.0.0", "@types/ws": "^8.0.0", "typescript": "^4.0.0" }
}
Install dependencies into your project
npm install express ws redis
npm install --save-dev typescript @types/node @types/ws
If not already done, don’t forget to configure your tsconfig.json :
{ "compilerOptions": { "target": "ES6", "module": "commonjs", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }
}
Coding
Redis client configuration
Let’s create our Redis configuration in a dedicated file redisClient.ts
. Here, we’re connecting the client to Redis and we listen for errors to raise it in the console.
import { createClient } from 'redis'; const client = createClient({ url: 'redis://redis:6379' // we'll create the docker image later
}); client.on('error', (err) => { console.error('Redis Client Error', err);
}); client.connect(); export default client;
Message type or interface
We’ll be creating an interface (create a type if you prefer) to structure our Chat messages. Let’s keep it simple and have only 3 attributes :
- username: the name of the message author
- message: the content of the message
- timestamp: date and hour of the message as a timestamp
export interface ChatMessage { username: string; message: string; timestamp: number;
}
Http & Websocket servers
Here, we’ll be creating two servers :
- HTTP server with express to handle http calls
- Websocket server to handle websocket messages
import express from 'express';
import { WebSocketServer } from 'ws';
import { ChatMessage } from './types';
import redisClient from './redisClient'; const app = express();
const port = 3000; app.get('/', (req, res) => { res.send('Chat server is running');
}); const server = app.listen(port, () => { console.log(`Web server is running on http://localhost:${port}`);
}); const wss = new WebSocketServer({ server }); wss.on('connection', (ws) => { console.log('Client connected'); // Send chat history to new client redisClient.lrange('chat_messages', 0, -1, (err, messages) => { if (err) { console.error('Error retrieving messages from Redis', err); return; } messages.forEach((message) => { ws.send(message); }); }); ws.on('message', (data) => { const message: ChatMessage = JSON.parse(data.toString()); message.timestamp = Date.now(); const messageString = JSON.stringify(message); // Save message to Redis redisClient.rpush('chat_messages', messageString); redisClient.ltrim('chat_messages', -100, -1); // Keep only the last 100 messages // Broadcast message to all clients wss.clients.forEach((client) => { if (client.readyState === ws.OPEN) { client.send(messageString); } }); }); ws.on('close', () => { console.log('Client disconnected'); });
}); console.log('WebSocket server is running on ws://localhost:3000');
So, what are we doing here :
- First, we setup the Web and WebSocket servers.
- Second, on the websocket connection, we pull the Chat history from redis
- Third, we listen for new messages and record them in redis, keeping only the 100 last message anytime
- Fourth, we broadcast each messages to all the connected clients of the Chat
If you wanna go further, you can implement an observer pattern where each websocket client is an observer. But for now, let’s keep the things simple.
For barrels fans, don’t forget to add your app entrypoint in index.ts :
import './server';
You can launch the server with :
npm run build
npm start
As we said it previously, we’ll build a simple example which does not take account of best practices for the article. Create a single index.hml
with the following code :
<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Chat</title>
</head>
<body> <div id="chat"> <ul id="messages"></ul> <input id="username" placeholder="Username"> <input id="message" placeholder="Message"> <button onclick="sendMessage()">Send</button> </div> <script> const ws = new WebSocket('ws://localhost:3000'); const messages = document.getElementById('messages'); ws.onmessage = (event) => { const message = JSON.parse(event.data); const li = document.createElement('li'); li.textContent = `[${new Date(message.timestamp).toLocaleTimeString()}] ${message.username}: ${message.message}`; messages.appendChild(li); }; function sendMessage() { const username = document.getElementById('username').value; const message = document.getElementById('message').value; ws.send(JSON.stringify({ username, message })); } </script>
</body>
</html>
So what are we doing here :
- We setup the html document to have the needed fields and button to send a message
- We setup the websocket connection with the server
- We publish every message to the websocket anytime the button is pressed calling the function sendMessage()
Create a Dockerfile for the server
# Use official Nodejs image
FROM node:18 # Work directory
WORKDIR /usr/src/app # Copy package.json & package-lock.json
COPY package*.json ./ # Install dependencies
RUN npm install # Copy sources files
COPY . . # Compile Typescript
RUN npm run build # Expose web server port
EXPOSE 3000 # Start the server
CMD ["npm", "start"]
Create a Docker compose file to assemble
Let’s assemble the web server and Radis server inside a docker-compose.yml file.
version: '3.8' services: redis: image: redis:latest container_name: redis ports: - "6379:6379" app: build: . container_name: chat-app ports: - "3000:3000" depends_on: - redis volumes: - .:/usr/src/app command: npm start
Building and starting the whole stack
To build and run the whole backend stack with docker images, use :
docker-compose up --build
Open your index.html file and start sending messages. We’re done ! A high-performance chat with websocket and Redis !
Hope it helps to understand basics. After that, you might be willing to take a look at Observer pattern and framworks like socket.io. Enjoy !