Implementation function:
- Single person private chat (1:1)
- Group chat (rooms/channels)
- In group chat
@某人(mentions) and generate reminders - Chat records (persistence, paging, retrieval)
- Online/offline message processing and offline push (overview)
- Security, scaling and performance recommendations
Design ideas → Data model → WebSocket event protocol → Backend (Node.js + Socket.IO + MongoDB) complete example → Frontend (HTML + JS + socket.io-client) example → Advanced and deployment concerns
design ideas
Transport layer: Use WebSocket (socket.io is recommended to simplify room/reconnection/heartbeat/cross-domain, etc.), or native ws.
Authentication: Return JWT (or session) after logging in, and use token verification when connecting to WebSocket.
Room Model:
- Private chat: Create a "private chat room id" for each pair of users, such as
dm:<userAId>:<userBId>(ids can be sorted from small to large to avoid duplication). - Group chat: use
room:<roomId>.
- Private chat: Create a "private chat room id" for each pair of users, such as
Message persistence: The message is written to DB (MongoDB recommended, document type is suitable for chatting), and the
mentionsfield array (the user id marked by @) is recorded.@ Implementation: The client completes the username list (or uid) in real time when inputting it. When sending a message, it puts
mentions: [userId...]in the message body, and the server also performs a parsing check (anti-counterfeiting).Offline/Unread: Record which target users have not read (unread map) when writing messages, or maintain
lastReadAtto facilitate statistics of unread and push.Message pulling/pagination: REST API uses
GET /rooms/:id/messages?before=<timestamp|id>&limit=20to paginate loading history.Notification: When the message contains someone's id (mentions), immediately push a
mentionevent to the user (if online), or record unread in the DB and send mobile notifications in conjunction with Push Notification (APNs/FCM).Scale/Horizontal: Use Redis pub/sub or socket.io-adapter (redis-adapter) for multi-instance event broadcasting.
Data model (MongoDB/Mongoose style example)
// User
{
_id: ObjectId,
username: String, // 唯一
displayName: String,
avatarUrl: String,
passwordHash: String,
lastSeenAt: Date,
createdAt: Date
}
// Room
{
_id: ObjectId,
type: "dm" | "group",
members: [ObjectId],
name?: String, // 群名
ownerId?: ObjectId,
createdAt: Date,
meta: {}
}
// Message
{
_id: ObjectId,
roomId: ObjectId,
senderId: ObjectId,
content: String, // 原始文本(可存富文本或 markdown)
contentHtml?: String, // 可选:预渲染后的 html
mentions: [ObjectId], // 被 @ 的用户 id 列表
attachments?: [{ url, type, filename }],
createdAt: Date,
editedAt?: Date,
deleted?: Boolean,
readBy?: [{ userId: ObjectId, at: Date }] // 或在另表维护 read 状态
}WebSocket/Event Protocol (recommended)
Uniformly use JSON event object { type, payload } or socket.io’s event name method.The following is expressed using the socket.io event name.
Client events (client → server):
auth: Send token after connection (can be omitted if using socket.io’s auth option).join_room{ roomId }: Join the room (server verifies permissions).leave_room{ roomId }send_message{ roomId, content, mentions: [userId], attachments?: [...] }typing{ roomId, isTyping: true|false }fetch_messages{ roomId, beforeId?, limit? }(REST can also be used)
Server events (server → client):
message{ message }(real-time delivery)message_ack{ tempId, messageId }(can be used for client echo confirmation)typing{ roomId, userId, isTyping }mention{ roomId, messageId, fromUserId }(special mention reminder)presence{ userId, online }orpresence_updatehistory{ messages: [...] }(in response to fetch_messages)error{ code, message }
Backend example (Node.js + Express + Socket.IO + Mongoose)
This teaching example includes password verification, HTTP/Socket authentication, membership checks and mention validation. Prepare user and room data first; production still needs rate limits, idempotency and connection/room permission revocation.
First npm init -y, install:
npm i express socket.io mongoose jsonwebtoken bcryptjs corsExample server.js:
// server.js
const express = require('express');
const http = require('http');
const mongoose = require('mongoose');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { Server } = require('socket.io');
const cors = require('cors');
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) throw new Error('Set JWT_SECRET before starting the demo');
const MONGO = process.env.MONGO || 'mongodb://localhost:27017/chatapp';
const User = mongoose.model('User', new mongoose.Schema({
username: { type: String, unique: true, required: true },
displayName: String,
passwordHash: { type: String, required: true }
}));
const Room = mongoose.model('Room', new mongoose.Schema({
type: { type: String, enum: ['dm', 'group'] },
members: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
name: String
}));
const Message = mongoose.model('Message', new mongoose.Schema({
roomId: { type: mongoose.Schema.Types.ObjectId, ref: 'Room' },
senderId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
content: String,
mentions: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
createdAt: { type: Date, default: Date.now }
}));
const app = express();
app.use(cors());
app.use(express.json({ limit: '32kb' }));
const asyncRoute = handler => (req, res, next) =>
Promise.resolve(handler(req, res, next)).catch(next);
function fail(status, message) {
const error = new Error(message);
error.status = status;
throw error;
}
function validId(id) {
return typeof id === 'string' && mongoose.isObjectIdOrHexString(id);
}
async function authenticate(token) {
if (typeof token !== 'string' || !token) fail(401, 'authentication required');
let payload;
try {
payload = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
} catch {
fail(401, 'invalid or expired token');
}
if (!validId(payload.userId) || !Number.isFinite(payload.exp)) fail(401, 'invalid token');
const user = await User.findById(payload.userId);
if (!user) fail(401, 'user not found');
return { user, userId: String(user._id), expiresAt: payload.exp * 1000 };
}
async function requireRoom(roomId, userId) {
if (!validId(roomId)) fail(400, 'invalid room id');
const room = await Room.findById(roomId);
if (!room) fail(404, 'room not found');
if (!room.members.some(id => String(id) === userId)) fail(403, 'no permission');
return room;
}
// Prepare users with bcrypt password hashes separately; this is not registration.
app.post('/login', asyncRoute(async (req, res) => {
const { username, password } = req.body || {};
if (typeof username !== 'string' || typeof password !== 'string') {
fail(400, 'username and password required');
}
const user = await User.findOne({ username });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
fail(401, 'invalid credentials');
}
const token = jwt.sign({ userId: String(user._id) }, JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '1h' });
res.json({ token, user: { _id: user._id, username: user.username } });
}));
// Express routes require their own authentication; io.use does not protect them.
app.get('/rooms/:roomId/messages', asyncRoute(async (req, res) => {
const token = /^Bearer (.+)$/.exec(req.get('Authorization') || '')?.[1];
const { userId } = await authenticate(token);
const { roomId } = req.params;
await requireRoom(roomId, userId);
const requestedLimit = Number(req.query.limit ?? 20);
if (!Number.isInteger(requestedLimit) || requestedLimit < 1) fail(400, 'invalid limit');
const before = req.query.before;
if (before !== undefined && !validId(before)) fail(400, 'invalid cursor');
const query = { roomId };
if (before) query._id = { $lt: before };
const messages = await Message.find(query).sort({ _id: -1 })
.limit(Math.min(50, requestedLimit)).lean();
res.json({ messages: messages.reverse() });
}));
app.use((error, req, res, next) => {
const status = error.status || 500;
res.status(status).json({ error: status === 500 ? 'internal error' : error.message });
});
const server = http.createServer(app);
const io = new Server(server, { cors: { origin: '*' } });
io.use(async (socket, next) => {
try {
socket.data = await authenticate(socket.handshake.auth?.token);
next();
} catch {
next(new Error('authentication failed'));
}
});
io.on('connection', socket => {
const { userId } = socket.data;
socket.join('user:' + userId);
const handle = handler => async (payload = {}) => {
try {
if (Date.now() >= socket.data.expiresAt) fail(401, 'token expired');
if (!payload || typeof payload !== 'object') fail(400, 'invalid payload');
await handler(payload);
} catch (error) {
socket.emit('error', {
code: error.status || 500,
message: error.status ? error.message : 'internal error'
});
}
};
socket.on('join_room', handle(async ({ roomId }) => {
await requireRoom(roomId, userId);
socket.join('room:' + roomId);
socket.emit('joined', { roomId });
}));
socket.on('leave_room', handle(async ({ roomId }) => {
if (!validId(roomId)) fail(400, 'invalid room id');
socket.leave('room:' + roomId);
}));
socket.on('send_message', handle(async ({ roomId, content, mentions = [], tempId }) => {
if (typeof content !== 'string' || !content.trim() || content.length > 10000) {
fail(400, 'invalid content');
}
const room = await requireRoom(roomId, userId);
if (!Array.isArray(mentions) || mentions.length > 50 || !mentions.every(validId)) {
fail(400, 'invalid mentions');
}
const mentionIds = [...new Set(mentions)];
const members = new Set(room.members.map(String));
if (mentionIds.some(id => !members.has(id))) fail(403, 'mention outside room');
const mentionedUsers = await User.find({ _id: { $in: mentionIds } });
if (mentionedUsers.length !== mentionIds.length) fail(400, 'unknown mention');
const escapeRegex = value => value.replace(/[.*+?^$(){}|[\]\\]/g, '\\$&');
for (const user of mentionedUsers) {
const pattern = new RegExp('(^|[^A-Za-z0-9_])@' +
escapeRegex(user.username) + '(?=$|[^A-Za-z0-9_])');
if (!pattern.test(content)) fail(400, 'mention missing from content');
}
const msg = await new Message({
roomId, senderId: userId, content, mentions: mentionIds
}).save();
const message = {
_id: msg._id, roomId, senderId: userId, content,
mentions: mentionIds, createdAt: msg.createdAt
};
io.to('room:' + roomId).emit('message', message);
socket.emit('message_ack', { tempId, messageId: msg._id });
for (const uid of mentionIds) {
io.to('user:' + uid).emit('mention', {
roomId, messageId: msg._id, from: userId, content
});
}
}));
socket.on('typing', handle(async ({ roomId, isTyping }) => {
await requireRoom(roomId, userId);
if (typeof isTyping !== 'boolean') fail(400, 'invalid typing state');
socket.to('room:' + roomId).emit('typing', { roomId, userId, isTyping });
}));
});
async function start() {
await mongoose.connect(MONGO);
server.listen(3000, () => console.log('listening 3000'));
}
start().catch(error => {
console.error(error);
process.exitCode = 1;
});How to run:
- Start MongoDB, prepare users with bcrypt passwordHash and rooms with user members, then set a separate random JWT_SECRET before running
node server.js. - Send username/password to
POST /loginfor a token, connect withio({ auth: { token }})and separately sendAuthorization: Bearer <token>on history requests.
Minimal example of frontend (HTML + socket.io-client)
Installation: Introduce socket.io-client (CDN or bundler) on the front-end page.
Example index.html:
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Chat Demo</title></head>
<body>
<div>
<input id="token" placeholder="paste token from /login" style="width:400px"/>
<button id="connectBtn">Connect</button>
</div>
<div id="chat" style="display:none;">
<div>
Room: <input id="roomId" value="" />
<button id="join">Join</button>
</div>
<div id="messages" style="height:300px;overflow:auto;border:1px solid #ccc;padding:8px;"></div>
<div>
<input id="msg" style="width:70%" placeholder="@username to mention" />
<button id="send">Send</button>
</div>
</div>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
<script>
let socket;
document.getElementById('connectBtn').onclick = () => {
const token = document.getElementById('token').value;
socket = io('http://localhost:3000', { auth: { token }});
socket.on('connect', () => {
console.log('connected', socket.id);
document.getElementById('chat').style.display = 'block';
});
socket.on('message', (m) => {
const el = document.createElement('div');
el.innerText = `[${new Date(m.createdAt).toLocaleTimeString()}] ${m.senderId}: ${m.content}`;
document.getElementById('messages').appendChild(el);
});
socket.on('mention', (x) => {
const el = document.createElement('div');
el.style.background = '#ffc';
el.innerText = `MENTION from ${x.from} in ${x.roomId}: ${x.content}`;
document.getElementById('messages').appendChild(el);
});
};
document.getElementById('join').onclick = () => {
const roomId = document.getElementById('roomId').value;
socket.emit('join_room', { roomId });
};
document.getElementById('send').onclick = () => {
const roomId = document.getElementById('roomId').value;
const content = document.getElementById('msg').value;
// 简化:客户端解析 @username => 替换为 userId (生产需先 resolve username->id)
// 这里示例直接不解析 mentions
socket.emit('send_message', { roomId, content, mentions: [] , tempId: 't'+Date.now()});
};
</script>
</body>
</html>Note: In actual production, before the client sends the message Must put
@usernameparsed intouserIdAnd inmentionsThe array is passed to the backend (the autocomplete interface can be called when inputtingGET /users?query=...)。
Implementation details of @mentions
- Client: Implement
@in the input box to trigger autocomplete and return matching username and id (REST:GET /users?query=...). - Sending: this example uses
mentions: [userId]; adding position/username objects requires matching protocol and server validation changes. - Server: check sender and mentioned-user membership and validate @username in content. The example uses basic username boundaries; rich text should use structured mention nodes.
- Notification: If the user is offline, push mobile notification or email.And display the unread mention list on the UI.
Store unread and read
Two common options:
readByfield memory reads time per person (suitable for small groups); writing cost is higher than query cost.- The
user_roomstable maintainslastReadAt(orlastReadMessageId), which determines unreads by comparing message times/IDs (more common and scalable).
Security and Performance Recommendations (Mastery Section)
- Authentication: Mandatory jwt verification during WebSocket handshake; all socket events are checked for permissions on the server side (do not trust the client).
- Rate control: Limit the flow of
send_messageto prevent screen flushing (Redis token bucket). - Content filtering: Perform sensitive word filtering/XSS filtering (for example, use the library to safely render contentHtml).
- Anti-duplication/Impotent: The client sends a message with
tempId, the server returnsmessage_ackafter deduplication, and the client replaces it with a temporary message. - Horizontal expansion: Use the
socket.io-redisadapter when deploying multiple instances, and rely on Redis pub/sub to synchronize messages and presence. - Storage and Indexing: Establish
roomId + createdAtindex in Message collection to query history and paging efficiently. - Backup and Archiving: Archive or cold store expired messages to avoid expansion of the main database.
- Message Audit/Compliance: If there are compliance requirements, keep complete audit logs, deletion policies and export functions.
- File/Large Attachment: The file is not directly uploaded to DB, but object storage (S3) is used to store the metadata and URL in the message document, and generate a temporary access token for the private file.
- Privacy: Encrypt sensitive fields (for example, some companies need to encrypt content).
Suggestions for advanced features (optional)
- Rich text, emoticons, replies (reference messageId), message withdrawal, editing.
- Online/offline device push (FCM/APNs).
- Event sourcing or Kafka message bus is used in complex scenarios.
- Search (Elasticsearch) is used for chat content search.
- Typing indicator, read receipts (read receipts), user online list.
- Group management (invite, kick out, permission hierarchy, announcement, pinned message).
Deployment & Operation and Maintenance Key Points
- Use HTTPS + WSS in production.
- A Redis adapter forwards events between instances; HTTP long-polling still needs sticky sessions. WebSocket-only transport can omit session affinity.Deployment documentation.
- Monitoring: number of connections, message throughput, latency, error rate, memory/GC.
- Grayscale, rollback strategy, log collection (structured log).