Chat & Presence — Socket.IO Event Catalog
Conventions
- C→S — emitted by the client, handled by the server
- S→C — emitted by the server, received by the client
- All payloads are JSON objects
- Timestamps are ISO 8601 strings
- The server never trusts client-provided
senderId; it always usessocket.data.userId
Connection Lifecycle
Handshake (C→S, implicit)
The client provides its auth token at connection time. No explicit event — this is handled by the Socket.IO middleware before any events fire.
// Dashboard / Manage (JWT cookie — sent automatically via withCredentials)
const socket = io(API_URL + '/tenant', { withCredentials: true });
// DM portal (JWT in auth option)
const socket = io(API_URL + '/tenant', {
auth: { token: '<jwt>' },
});
// Manage super-admin connects to /manage namespace
const socket = io(API_URL + '/manage', { withCredentials: true });On successful handshake:
- Server joins socket to
tenant:{tenantId}(auto, from token claims) - Server joins socket to
user:{userId}(auto, personal room) - Server sets
presence:{userId}Redis key - Server emits
user:onlinetotenant:{tenantId}
On failed handshake (invalid/expired token):
- Server calls
next(new Error('UNAUTHORIZED')) - Client receives
connect_errorwithmessage: 'UNAUTHORIZED' - Client should redirect to login
Presence Events
presence:list (C→S)
Request the current list of online users visible to this client.
// Payload
{}
// Server response — emitted back only to this socket
socket.emit('presence:list', {
users: [
{
userId: 'clx...',
name: 'Alice',
image: 'https://...',
role: 'admin',
tenantId: 'ten...',
onlineSince: '2026-04-03T10:00:00Z',
},
// ...
],
});For /tenant namespace: returns online users in the same tenant.
For /manage namespace: returns all online users across all tenants (includes tenantId in each entry so the admin can group by tenant).
presence:heartbeat (C→S)
Sent by the client every 60 seconds to keep the presence key alive. No response needed.
// Payload
{}user:online (S→C)
Broadcast to the tenant room when a user connects.
// Payload
{
userId: 'clx...',
name: 'Alice',
image: 'https://...',
role: 'admin',
tenantId: 'ten...',
onlineSince: '2026-04-03T10:00:00Z',
}user:offline (S→C)
Broadcast to the tenant room when a user disconnects (clean or TTL expiry via a background sweeper).
// Payload
{
userId: 'clx...',
tenantId: 'ten...',
}Chat Events
chat:send (C→S)
Send a direct message to another user.
// Payload
{
recipientId: 'clx...', // target user ID
content: string, // plain text, max 4000 chars
clientMsgId: string, // client-generated UUID for dedup / optimistic UI
}Server response — emitted back to sender only (ack):
socket.emit('chat:sent', {
clientMsgId: string, // echoes the clientMsgId for optimistic UI reconciliation
messageId: string, // server-assigned DB ID
createdAt: string, // ISO timestamp persisted in DB
});Server also emits chat:message to the recipient’s personal room user:{recipientId}:
// Emitted to recipient
{
messageId: string,
tenantId: string,
senderId: string,
senderName: string,
senderImage: string | null,
content: string,
createdAt: string,
}chat:message (S→C)
Received by a user when someone sends them a DM. See payload above.
chat:history (C→S)
Load conversation history with a specific user.
// Payload
{
peerId: string, // the other user in the conversation
before?: string, // ISO timestamp — load messages before this (pagination cursor)
limit?: number, // default 30, max 100
}Server response — emitted back to requesting socket only:
socket.emit('chat:history', {
peerId: string,
messages: [
{
messageId: string,
senderId: string,
content: string,
createdAt: string,
readAt: string | null,
},
// ... oldest first
],
hasMore: boolean, // true if there are older messages
});chat:read (C→S)
Mark all messages from a specific sender as read.
// Payload
{
peerId: string, // the sender whose messages are being marked read
}Server:
- Updates
readAtfor all unreadChatMessagerecords whererecipientId = socket.data.userId AND senderId = peerId AND readAt IS NULL - Emits
chat:read_ackback to the reading user (for multi-tab sync) - Emits
chat:read_receiptto the original sender
// chat:read_ack — emitted to user:{readerId} (all tabs of the reader)
{
peerId: string,
readAt: string,
}
// chat:read_receipt — emitted to user:{senderId} (all tabs of the sender)
{
readerId: string,
readAt: string,
}chat:typing (C→S)
Optional. Notify the recipient that the sender is typing.
// Payload
{
recipientId: string,
isTyping: boolean,
}Server emits chat:typing to user:{recipientId}:
{
senderId: string,
isTyping: boolean,
}No persistence — ephemeral signal only.
chat:error (S→C)
Emitted to the sender when a chat:send fails.
{
clientMsgId: string, // echoes the original clientMsgId
code: 'FORBIDDEN' | 'CONTENT_TOO_LONG' | 'RATE_LIMITED' | 'INTERNAL_ERROR',
message: string,
}Error Handling Summary
| Scenario | Server action | Client receives |
|---|---|---|
| Expired token | next(new Error('UNAUTHORIZED')) | connect_error |
| Sending to user outside tenant | emit chat:error FORBIDDEN | chat:error |
| Content > 4000 chars | emit chat:error CONTENT_TOO_LONG | chat:error |
| DB write fails | emit chat:error INTERNAL_ERROR | chat:error |
The client should never rely on the server emitting a response to consider an action done — always use the explicit ack events (chat:sent, chat:history, etc.).