@enfin/chat-server
v1.6.2
Published
Drop-in NestJS chat module — Socket.IO gateway, MongoDB persistence, file uploads, presence, calls. Mount via ChatModule.forRoot({ apiKey }).
Downloads
70
Readme
@enfin/chat-server
NestJS chat module — Socket.IO gateway, MongoDB persistence, file uploads, presence, and audio calls.
Install
npm install @enfin/chat-server @enfin/chat-sharedRecent fixes
1.6.2 /
@enfin/[email protected]— unread message counts + read receipts. The server side of the badge-and-tick feature:GET /api/rooms?userId=&withUnread=truenow attachesunreadCountto each room in the response, computed byMessageService.getUnreadCount(counts messages in the room not authored by the requesting user, that have not yet been recorded as read by them). Sender's own messages are excluded.MessageService.markRoomAsRead(tenantId, roomId, userId, upTo?)— singleupdateManythat$push-es aReadReceiptonto every message in the room withcreatedAt <= upToand not authored by the reader. Bulk path keeps a 100-unread-room open cheap.- New socket event
room:read(client → server) with payload{ roomId, upTo? }. Server membership-checks the sender, callsmarkRoomAsRead, then broadcastsroom:read-by { roomId, userId, readAt }to the other members of the room. POST /api/rooms/directnow broadcastsroom:addedto every member of the new direct room via the gateway, so the recipient's sidebar populates without a refresh.@enfin/chat-sharedbumped to 1.3.2 — addsunreadCount?: numberonRoom, plusRoomReadPayloadandRoomReadByPayloadsocket payload types.
Drop-in upgrade: bump
@enfin/chat-serverto^1.6.2and@enfin/chatto^1.6.2. Old clients that don't emitroom:readkeep working — the unread count just doesn't decrement when they open a chat (next reconnect re-seeds).1.5.2 /
@enfin/[email protected]— group-call reliability patch. Five bug fixes shipped in the frontend SDK this round; no server changes were required for any of them. The fixes are mesh-leader glue only:- Cross-room Join from the "other-room" popup now works (was being dropped by an unmounted room-scoped hook).
- Re-ringing departed users works again (
hasLeftCallRefreset on unmount). - Cross-room popup disappears immediately when the user clicks End (
markGroupCallLeftSet gates subsequentgroup-call:state). - The redundant in-room "Group call / N members" overlay no longer fires on top of the unified popup.
- Two-user mesh audio no longer deadlocks — the caller's first offer can now land because the joiner's hook waits for refresh-offers instead of trying to lex-initiate into a glare-guarded
have-local-offerPC.
Drop-in upgrade: bump
@enfin/chatto^1.4.4. The chat-server does not need a version bump.1.5.2 — unified group-call popup, public component exports.
1.5.1 — group chat & group call feature documentation.
Quick Start (CLI)
For quick local testing:
npx chat-server start --apiKey=chat_xxx --port=3002Options:
--apiKey(required): Your API key--port(default: 3002): Server port--mongoUri(default: mongodb://localhost:27017/chat_sdk): MongoDB connection string--mode(default: managed):managedorexternal-db
Mount in Your NestJS App
Option 1: Fresh MongoDB (no existing Mongoose)
Use ChatMongooseModule to create the connection, then mount ChatModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ChatMongooseModule, ChatModule } from '@enfin/chat-server';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ChatMongooseModule.forRoot(process.env.MONGO_URI || 'mongodb://localhost:27017/myapp'),
ChatModule.forRoot({
apiKey: process.env.CHAT_API_KEY || 'chat_xxx',
}),
],
})
export class AppModule {}Option 2: Already Have Mongoose
If your app already calls MongooseModule.forRoot(uri), skip ChatMongooseModule:
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { ChatModule } from '@enfin/chat-server';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRoot('mongodb://localhost:27017/myapp'),
ChatModule.forRoot({
apiKey: process.env.CHAT_API_KEY || 'chat_xxx',
}),
],
})
export class AppModule {}ChatModule registers its schemas on whatever Mongoose connection exists.
ChatModuleOptions
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| apiKey | string | Yes | Your platform API key |
| mongoUri | string | No | Customer-provided DB (external-db mode) |
| mode | 'managed' | 'external-db' | No | Default: managed |
| version | string | No | API version string |
| platformMongoUri | string | No | Platform DB for key validation |
| uploadDir | string | No | Where uploaded files are written. Relative paths resolve against process.cwd(). Overrides UPLOAD_DIR env. |
| fileUploadHandler | function | No | Custom upload handler. Receives the parsed file and returns the URL. Use for S3 / Azure / GCS. See Custom file upload handler. |
| maxGroupMembers | number | No | Max members per group room. Overrides CHAT_MAX_GROUP_MEMBERS env. Default 256. |
| maxCallParticipants | number | No | Max participants per group audio call (mesh WebRTC). Overrides CHAT_MAX_CALL_PARTICIPANTS env. Default 20. |
Modes
managed (default)
The chat server validates API keys against the admin BE. The admin BE URL is read from the ADMIN_API_VALIDATION_URL env var (or API_VALIDATION_URL / VALIDATION_URL as fallbacks). The admin BE is a separate NestJS service that owns the API key registry — see the admin/ directory or use the hosted instance at https://prtl-ms.mr-coder.io to generate keys.
external-db
The customer provides their own MongoDB. The server stores chat data there but still validates keys against your platform. Use this for customers who want isolation.
Environment Variables
| Variable | Description |
|----------|-------------|
| MONGO_URI | Fallback MongoDB URI (used if not provided in options) |
| PORT | Server port (default: 3002) |
| UPLOAD_DIR | Where uploaded files are written. Relative paths resolve against process.cwd() (e.g. public/images → <cwd>/public/images). Absolute paths are used as-is. Default: uploads (i.e. <cwd>/uploads). |
| ADMIN_API_VALIDATION_URL | Optional. URL of the admin BE that owns your tenant's API key registry. Defaults to https://api-ms.mr-coder.io (the hosted platform). Override in your chat-server's .env only if you self-host the admin BE or use a private white-label deployment. Resolution chain: ADMIN_API_VALIDATION_URL → API_VALIDATION_URL → VALIDATION_URL → default. |
| API_VALIDATION_URL | Fallback name for ADMIN_API_VALIDATION_URL |
| VALIDATION_URL | Fallback name for ADMIN_API_VALIDATION_URL |
| CHAT_MAX_GROUP_MEMBERS | Max members allowed in a single group room. Default 256. Non-integer or non-positive values fall back to the default with a one-time warning. Resolution: options.maxGroupMembers → this env var → 256. |
| CHAT_MAX_CALL_PARTICIPANTS | Max participants allowed in a single group audio call. Default 20. Hard-capped at 20 — mesh WebRTC tops out around 20 peers. Use an SFU (mediasoup / LiveKit) for larger calls. |
Configuring the upload directory
Resolution order: options.uploadDir → process.env.UPLOAD_DIR → uploads (relative to cwd).
# Default: <cwd>/uploads
node dist/main.js
# Relative path: <cwd>/public/images (the file URL is still /uploads/<uuid>.<ext>)
UPLOAD_DIR=public/images node dist/main.js
# Absolute path
UPLOAD_DIR=/var/data/chat-uploads node dist/main.jsOr in NestJS options:
ChatModule.forRoot({
apiKey: process.env.CHAT_API_KEY!,
uploadDir: 'public/images', // resolves to <cwd>/public/images
}),The directory is created automatically on startup if it does not exist. Uploaded files are served at GET /uploads/<filename> (handled by ChatModule — no extra useStaticAssets needed in your main.ts).
API key validation
Every API key sent by a client (HTTP x-api-key header, socket auth, or body) is validated against an admin BE that owns the API key registry. The chat-server calls POST ${ADMIN_API_VALIDATION_URL}/api/validation/validate with { apiKey, clientVersion } and caches the result for 5 minutes.
Where ADMIN_API_VALIDATION_URL points
It depends on who issued your API key:
| You got your API key from | ADMIN_API_VALIDATION_URL should be |
|---|---|
| The hosted platform at https://prtl-ms.mr-coder.io | Leave unset — https://api-ms.mr-coder.io is the default |
| Your own self-hosted admin BE (running the admin/back-end source from this repo) | The URL where you deployed it, e.g. https://admin.yourcompany.com or http://localhost:3001 for local dev |
| A private white-label deployment of the platform | Whatever the platform operator gave you |
The default is https://api-ms.mr-coder.io (the hosted platform), defined in src/constants.ts. Most consumers can leave ADMIN_API_VALIDATION_URL unset — it's only required if you self-host the admin BE. To change the default for the whole package, edit src/constants.ts.
Example .env
# chat-server/.env
# Database
MONGO_URI=mongodb://localhost:27017/chat_sdk
# API key validation — who issued your key
ADMIN_API_VALIDATION_URL=https://api-ms.mr-coder.io
# Server
PORT=3002
CHAT_MODE=managedValidation cache
Results are cached in-memory for 5 minutes per key (VALIDATION_CACHE_TTL_MS). To invalidate early (e.g. after a key is revoked), call apiKeyService.clearCache() from your own code.
Custom file upload handler
For production deployments you usually want files in object storage (S3, Azure Blob, Google Cloud Storage) instead of on the chat server's disk. Pass a fileUploadHandler to ChatModule.forRoot to take over the upload step.
The handler receives the file (already parsed by multer — file.path is on local disk and file.buffer is in memory) plus the validated tenantId and roomId, and returns the URL where the file is now reachable. The URL is stored in the Message exactly like the default behaviour — no frontend changes are needed.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { ChatModule, FileUploadHandler } from '@enfin/chat-server';
const s3 = new S3Client({ region: 'us-east-1' });
const uploadToS3: FileUploadHandler = async ({ file, tenantId, roomId }) => {
const key = `${tenantId}/${roomId}/${Date.now()}-${file.originalname}`;
await s3.send(new PutObjectCommand({
Bucket: 'my-chat-uploads',
Key: key,
Body: file.buffer ?? require('fs').createReadStream(file.path),
ContentType: file.mimetype,
}));
return {
fileUrl: `https://my-chat-uploads.s3.amazonaws.com/${key}`,
fileName: file.originalname,
fileType: file.mimetype,
fileSize: file.size,
};
};
ChatModule.forRoot({
apiKey: process.env.CHAT_API_KEY!,
fileUploadHandler: uploadToS3,
});Notes:
- If
fileUploadHandleris not provided, the default multer-to-disk behaviour is unchanged. - The handler is called after API key and room validation, so it only ever sees authorised uploads.
- Return any URL the frontend can
fetch— absolute (S3, CDN) or relative (your own CDN in front of the bucket). - For tenant isolation, namespace the storage key with
tenantId(as shown above) so tenants cannot read each other's files. - The built-in
GET /uploads/<filename>route is still registered but will be unused when you return external URLs. It is harmless to leave in place.
REST Endpoints
All endpoints require x-api-key header unless noted.
| Method | Path | Description |
|--------|-----|-------------|
| POST | /api/validation/validate | Validate API key |
| POST | /api/users/register | Register a user (accepts profile fields, all optional except name) |
| GET | /api/users | List all users (with presence) |
| GET | /api/users/:userId | Get one user (with presence) |
| PATCH | /api/users/:userId | Update profile fields |
| DELETE | /api/users/:userId?cascade=true | Delete a user. cascade=true also drops rooms they own + their messages, and removes them from any rooms they're a member of. |
| GET | /api/rooms | List rooms for a user |
| POST | /api/rooms/direct | Open/get direct room |
| POST | /api/rooms/group | Create a group room (enforces maxGroupMembers) |
| GET | /api/rooms/:roomId/messages | Get room messages |
| POST | /api/upload | Upload file (multipart/form-data) |
User profile fields
All optional except name:
| Field | Notes |
|---|---|
| name | Display name (required on register) |
| avatar | URL to image |
| email | Stored lowercased + trimmed |
| phoneNumber | Free-form string (no validation) |
| countryCode | 2-letter or "+XX" |
| statusMessage | Max 140 chars (e.g. "In a meeting") |
| locale | BCP-47 (e.g. "en-US") |
| timezone | IANA (e.g. "Asia/Colombo") |
| role | Free-form; consumer owns semantics |
Register accepts any subset; PATCH validates that at least one updatable field is present.
When a user is updated or removed, the server emits user:updated / user:removed on the /chat namespace so every connected client in the tenant receives the change in real time.
Group rooms
Groups are rooms with type: 'group' and a members: string[] array. They behave like 1:1 rooms for messaging, file uploads, typing indicators, and presence — the differences are:
- A group has a
nameand anownerId(the creator). - Members can be added/removed (server enforces
maxGroupMembers). - The room emits a
group-call:*namespace on the socket for group audio calls (mesh WebRTC, capped atmaxCallParticipants).
// Create a group
const res = await fetch(`${serverUrl}/api/rooms/group`, {
method: 'POST',
headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Engineering',
members: ['user_a', 'user_b', 'user_c'],
}),
});
const room = await res.json(); // { _id, name, type: 'group', members: [...], ownerId: 'user_a' }Cap-enforcement error shape:
{ "error": "group member limit reached (max 256; current 256, tried to add 1)" }Socket Events
Namespace: /chat
Connect
Handshake auth required:
socket = io('http://localhost:3002/chat', {
auth: { apiKey: 'chat_xxx', userId: 'u1', userName: 'Alice' }
});Client → Server
| Event | Payload | Description |
|-------|--------|-------------|
| message | { roomId, content } | Send message |
| typing | { roomId, isTyping } | Typing indicator |
| presence | { status } | Set presence (online/away/offline) |
| join-room | { roomId } | Join a room |
| leave-room | { roomId } | Leave a room |
| call | { to, offer } | WebRTC offer |
| call-accept | { callId, answer } | WebRTC answer |
| call-reject | { callId } | Reject call |
| call-end | { callId } | End call |
| room:create | { name, type, members } | Create a room (type: 'direct' \| 'group') |
| room:add-member | { roomId, userId } | Add a single member to a group |
| room:add-members | { roomId, userIds[] } | Batch add (enforces maxGroupMembers) |
| room:remove-member | { roomId, userId } | Remove a member (owner-only) |
| room:rename | { roomId, name } | Rename a group (owner-only) |
| group-call:initiate | { roomId, participantIds[] } | Initiate a group audio call. Caller is auto-joined; the listed users receive group-call:incoming. |
| group-call:join | { callId } | Join an in-progress group call |
| group-call:leave | { callId } | Leave (or end, if last participant) a group call |
| group-call:cancel | { callId } | Caller cancels before anyone has joined |
| group-call:signal | { callId, roomId, fromUserId, toUserId, sdp?, candidate? } | Relay an SDP/ICE signal to one peer. The server routes by toUserId; both users must be in the call's participants[]. |
Server → Client
| Event | Payload | Description |
|-------|--------|-------------|
| message | { roomId, messages[] } | New message(s) |
| typing | { roomId, userId, isTyping } | User typing |
| presence:changed | { userId, status } | Presence update |
| room:updated | { room } | Room updated |
| call | { callId, from, offer } | Incoming call |
| call-accepted | { callId, answer } | Call accepted |
| call-rejected | { callId } | Call rejected |
| call-ended | { callId } | Call ended |
| room:created | { room } | Room created (broadcast to creator) |
| group-call:incoming | { call } | Incoming group call (ringing) |
| group-call:state | { call, participantsInCall } | Full call state snapshot. Re-broadcast on join/leave and on socket reconnect (when the user is in call.participants). |
| group-call:ended | { call, endedBy?, reason? } | Group call ended (last participant left, or caller cancelled) |
| group-call:signal | { callId, roomId, fromUserId, toUserId, sdp?, candidate? } | Incoming SDP/ICE signal from a peer |
| error | { code, message } | Error event |
| users:list | { users[] } | User list on join |
Group audio calls
In addition to the 1:1 call / call-accept events above, the chat-server supports group audio calls in any group room (rooms with type: 'group'). Group calls use a mesh WebRTC topology — every participant sends and receives an audio stream to/from every other participant — and are capped at maxCallParticipants peers per call (default 20, hard-capped at 20).
The flow:
- Caller emits
group-call:initiatewith{ roomId, participantIds }. The server creates anAudioCallrecord (kind: 'group') inringingstatus, auto-adds the caller toparticipantsInCall, and broadcastsgroup-call:incomingto eachparticipantId. - Members accept by emitting
group-call:joinwith{ callId }. The server flips status toactive, adds them toparticipantsInCall, and emitsgroup-call:stateto the room. - Each peer opens
RTCPeerConnections with every existing participant and emitsgroup-call:signalto relay SDP offers/answers and ICE candidates. The server is a pure router — it never sees media, and only delivers packets whosetoUserIdis in the call'sparticipants[]. group-call:leaveremoves a participant. When the last participant leaves, the server emitsgroup-call:endedand marks the callended. The caller leaving ends the call for everyone.group-call:cancellets the caller end the call before anyone has joined (nogroup-call:incomingwas answered).- The full call state —
call: AudioCall,participantsInCall: string[]— is broadcast ongroup-call:stateon every join/leave. On socket reconnect, the gateway re-pushes a freshgroup-call:stateto the user so they can re-join their existing peer connections.
Limits & tuning:
maxCallParticipantsdefaults to20and is hard-capped at20at boot. If you raiseCHAT_MAX_CALL_PARTICIPANTSabove 20, the server logs a warning and clamps to 20. For larger calls, deploy an SFU (mediasoup / LiveKit) rather than raising the cap.- Mesh WebRTC is fine for ≤10 peers. Past that, uplink bandwidth and CPU decoding become the bottleneck — use headphones, a wired connection, and TURN for best results.
- A small disconnect grace (
GROUP_CALL_DISCONNECT_GRACE_MS, default5000) absorbs brief socket blips before the server treats a peer as left.
Custom STUN / TURN: see @enfin/chat's ChatConfig.iceServers in the client README. The server does not need to know about ICE servers — clients share them in the offer/answer.
File Uploads
POST to /api/upload with multipart/form-data:
- Field:
file - Header:
x-api-key - Body:
{ roomId: string }
Response:
{
"success": true,
"fileUrl": "/uploads/uuid-filename.jpg",
"fileName": "photo.jpg",
"fileType": "image/jpeg",
"fileSize": 12345
}Max file size: 50MB. Files are served at GET /uploads/<filename> by the SDK's built-in controller — you do not need to register useStaticAssets in your main.ts.
Production: the built-in disk storage is for development and small deployments. For production pass a
fileUploadHandlertoChatModule.forRootthat streams the file to S3 / Azure Blob / GCS and returns the public URL. See Custom file upload handler.
Troubleshooting
EADDRINUSE
Another process is on your port. Kill it or use a different port:
# Find process
netstat -ano | findstr :3002
# Kill on Windows
taskkill /PID <pid> /FMongoDB connection failed
Ensure MongoDB is running and the URI is correct. If using Docker:
docker run -d -p 27017:27017 mongoInvalid apiKey
Keys must exist in your platform database. In managed mode, the server looks up the key on startup. In external-db mode, provide valid keys in your customer's DB.
ADMIN_API_VALIDATION_URL not configured
This error is no longer possible in v1.3.1+. The chat-server always has a default (https://api-ms.mr-coder.io from src/constants.ts) and only needs the env var if you're overriding the default — for example, to point at a self-hosted admin BE.
# chat-server/.env (only needed for self-hosted admin BE)
ADMIN_API_VALIDATION_URL=https://admin.yourcompany.comFor frontend SDK, see @enfin/chat.
