npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-shared

Recent 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=true now attaches unreadCount to each room in the response, computed by MessageService.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?) — single updateMany that $push-es a ReadReceipt onto every message in the room with createdAt <= upTo and 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, calls markRoomAsRead, then broadcasts room:read-by { roomId, userId, readAt } to the other members of the room.
    • POST /api/rooms/direct now broadcasts room:added to every member of the new direct room via the gateway, so the recipient's sidebar populates without a refresh.
    • @enfin/chat-shared bumped to 1.3.2 — adds unreadCount?: number on Room, plus RoomReadPayload and RoomReadByPayload socket payload types.

    Drop-in upgrade: bump @enfin/chat-server to ^1.6.2 and @enfin/chat to ^1.6.2. Old clients that don't emit room:read keep 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 (hasLeftCallRef reset on unmount).
    • Cross-room popup disappears immediately when the user clicks End (markGroupCallLeft Set gates subsequent group-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-offer PC.

    Drop-in upgrade: bump @enfin/chat to ^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=3002

Options:

  • --apiKey (required): Your API key
  • --port (default: 3002): Server port
  • --mongoUri (default: mongodb://localhost:27017/chat_sdk): MongoDB connection string
  • --mode (default: managed): managed or external-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_URLAPI_VALIDATION_URLVALIDATION_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.uploadDirprocess.env.UPLOAD_DIRuploads (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.js

Or 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=managed

Validation 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 fileUploadHandler is 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 name and an ownerId (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 at maxCallParticipants).
// 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:

  1. Caller emits group-call:initiate with { roomId, participantIds }. The server creates an AudioCall record (kind: 'group') in ringing status, auto-adds the caller to participantsInCall, and broadcasts group-call:incoming to each participantId.
  2. Members accept by emitting group-call:join with { callId }. The server flips status to active, adds them to participantsInCall, and emits group-call:state to the room.
  3. Each peer opens RTCPeerConnections with every existing participant and emits group-call:signal to relay SDP offers/answers and ICE candidates. The server is a pure router — it never sees media, and only delivers packets whose toUserId is in the call's participants[].
  4. group-call:leave removes a participant. When the last participant leaves, the server emits group-call:ended and marks the call ended. The caller leaving ends the call for everyone.
  5. group-call:cancel lets the caller end the call before anyone has joined (no group-call:incoming was answered).
  6. The full call state — call: AudioCall, participantsInCall: string[] — is broadcast on group-call:state on every join/leave. On socket reconnect, the gateway re-pushes a fresh group-call:state to the user so they can re-join their existing peer connections.

Limits & tuning:

  • maxCallParticipants defaults to 20 and is hard-capped at 20 at boot. If you raise CHAT_MAX_CALL_PARTICIPANTS above 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, default 5000) 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 fileUploadHandler to ChatModule.forRoot that 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> /F

MongoDB connection failed

Ensure MongoDB is running and the URI is correct. If using Docker:

docker run -d -p 27017:27017 mongo

Invalid 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.com

For frontend SDK, see @enfin/chat.