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.
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.
.
├── docker-compose.yml
├── Dockerfile
├── src
│ ├── index.ts
│ ├── server.ts
│ ├── redisClient.ts
│ └── types.ts
├── tsconfig.json
└── package.json
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"
}
}
npm install express ws redis
npm install --save-dev typescript @types/node @types/ws @types/express @types/redis
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
}
}
Let’s create our Redis configuration in a dedicated file ./src/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;
We’ll be creating an interface (create a type if you prefer) to structure our Chat messages in ./src/chatMessage.ts
. Let’s keep it simple and have only 3 attributes :
export interface ChatMessage {
username: string;
message: string;
timestamp: number;
}
Here, we’ll be creating two servers in ./src/server.ts
:
import express from "express";
import { WebSocketServer } from "ws";
import redisClient from "./redisClient";
import { ChatMessage } from "./chatMessage";
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_message", 0, -1).then(
(messages) => {
messages.forEach((message) => {
ws.send(message);
});
},
(error) => {
console.error("Error retrieving messages from Redis", error);
return;
}
);
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 :
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 ./src/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 :
# 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"]
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
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 !
Link : GitHub Repository
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 !