wexa-chat
v0.2.10
Published
Chat core library with MongoDB and Redis support
Maintainers
Readme
wexa-chat (Chat Core)
TypeScript chat core for MongoDB with optional Redis and a built‑in WebSocket transport. Ships server APIs and a lightweight browser client bundle used by the demo app.
- Server:
import { initChat } from 'wexa-chat' - Client:
import { SocketProvider, useSocket, getSocketManager, type ChatEvent } from 'wexa-chat/client'
This README mirrors the integration used in the Next.js demo app.
Installation
npm install wexa-chat mongoose ioredisServer usage
Initialize once with initChat(mongoose, options, httpServer?). If your app starts before an HTTP server exists (e.g., SSR), you can re‑initialize later with the server to attach the WebSocket transport.
import mongoose from 'mongoose';
import type { Server as HTTPServer } from 'http';
import { initChat } from 'wexa-chat';
let chatInstance: Awaited<ReturnType<typeof initChat>> | null = null;
export async function getChatInstance(httpServer?: HTTPServer) {
if (chatInstance) {
// Attach WS transport when server becomes available
if (httpServer && !(chatInstance.transport as any).socket) {
chatInstance = await initChat(mongoose, {
participantModels: ['User'],
memberRoles: ['member', 'admin'],
mongoUri: process.env.MONGODB_URI!,
// Optional Redis for cross‑instance fanout
redis: process.env.REDIS_URL || process.env.REDIS_HOST
? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
: undefined,
// IMPORTANT: must match the client (recommended '/ws')
socket: { path: '/ws' },
enableTextSearch: true,
}, httpServer);
}
return chatInstance;
}
// First‑time init; only pass socket when server exists
chatInstance = await initChat(mongoose, {
participantModels: ['User'],
memberRoles: ['member', 'admin'],
mongoUri: process.env.MONGODB_URI!,
redis: process.env.REDIS_URL || process.env.REDIS_HOST
? { url: process.env.REDIS_URL, host: process.env.REDIS_HOST, port: Number(process.env.REDIS_PORT || 6379) }
: undefined,
socket: httpServer ? { path: '/ws' } : undefined,
enableTextSearch: true,
}, httpServer);
return chatInstance;
}WebSocket transport
- Transport uses native
wsand attaches an HTTP upgrade listener whenhttpServeris provided. - Set
socket.pathto the URL path for upgrades. The client bundle expects/ws; configure the same on the server. - Authentication: client connects to
ws(s)://host/ws?userId=...&userModel=User. The server validates the handshake and associates the user.
Next.js warm‑up route
Warm the server so the upgrade listener is attached before the browser connects:
// pages/api/socket.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import type { Server as HTTPServer } from 'http';
import { getChatInstance } from '@/lib/chat';
export const config = { api: { bodyParser: false } };
export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
const server = (res.socket as any).server as HTTPServer;
await getChatInstance(server);
res.status(200).end('OK');
}The browser client performs a one‑time GET of /api/socket before opening the WebSocket.
Options overview (see src/types/init.ts)
export type SocketConfig = {
path?: string;
cors?: { origin?: string | string[]; methods?: string[] };
};
export type InitOptions = {
participantModels: string[]; // e.g. ['User']
memberRoles: string[]; // e.g. ['member', 'admin']
mongoUri: string; // MongoDB connection
enableTextSearch?: boolean;
redis?: { // optional
url?: string; host?: string; port?: number; password?: string; db?: number;
socketConnectTimeout?: number; keepAlive?: number;
};
socket?: SocketConfig; // WebSocket config
presence?: { enabled?: boolean; heartbeatSec?: number };
rateLimit?: { sendPerMin?: number; typingPerMin?: number };
}Services API
chat.services exposes conversations and messages services.
Conversations
createOrFindConversation({ organizationId, a:{model,id}, b:{model,id} })getConversation(organizationId, conversationId)searchConversations({ organizationId, participantModel?, participantId?, limit?, cursor? })searchByParticipantPair({ organizationId, a, b })
Messages
sendMessage({ organizationId, conversationId, senderModel, senderId, text, source, kind?, parentMessageId?, rootThreadId?, connectorIds? })listMessages({ organizationId, conversationId, limit?, cursor? })listMessagesWithSenders({ organizationId, conversationId, limit?, cursor?, populateSenders: true, populateOptions? })markRead({ organizationId, conversationId, participantModel, participantId, messageId })receiveWhatsappMessage({ whatsappChatId, message })receiveLinkedinMessage({ linkedinChatId, message })
DTO types live in src/types/dto.ts.
Populating Message Senders with Infinite Scroll Support
The listMessagesWithSenders method allows you to retrieve messages with populated sender details, optimized for infinite scroll in chat interfaces. The implementation uses efficient batched lookups:
// Basic usage
const messagesWithSenders = await chat.services.messages.listMessagesWithSenders({
organizationId: 'org-123',
conversationId: 'conv-456',
populateSenders: true // Required to enable population
});
// With customization
const messagesWithCustomSenders = await chat.services.messages.listMessagesWithSenders({
organizationId: 'org-123',
conversationId: 'conv-456',
populateSenders: true,
populateOptions: {
fields: '_id name email profilePicture', // Fields to select from sender model
modelMapping: {
'User': 'CustomUserModel', // If your senderModel doesn't match the actual Mongoose model name
'Bot': 'BotModel'
}
}
});
// Accessing populated data
messagesWithSenders.items.forEach(message => {
console.log(`Message from ${message.sender?.name}: ${message.text}`);
});The populated sender data is available in the sender property of each message. The implementation uses a batched approach that:
- Fetches messages using MongoDB aggregation
- Groups messages by sender model type
- Loads all senders for each model type in a single query per model
- Attaches sender data to each message efficiently
This approach minimizes database queries and maximizes performance, even with multiple sender types.
Infinite Scroll Implementation
The returned data structure supports infinite scroll with cursor-based pagination:
// First load
const firstPage = await chat.services.messages.listMessagesWithSenders({
organizationId: 'org-123',
conversationId: 'conv-456',
populateSenders: true,
limit: 20 // Number of messages per "page"
});
// Display messages in your UI
displayMessages(firstPage.items);
// When user scrolls and needs more messages
if (firstPage.hasMore) {
// Load next batch using the cursor
const nextPage = await chat.services.messages.listMessagesWithSenders({
organizationId: 'org-123',
conversationId: 'conv-456',
populateSenders: true,
limit: 20,
cursor: firstPage.nextCursor // Pass the cursor from previous batch
});
// Append these messages to your UI
displayMessages([...existingMessages, ...nextPage.items]);
// Store the new cursor for further loading
nextCursor = nextPage.nextCursor;
hasMore = nextPage.hasMore;
}The method returns messages sorted by creation time (newest first), making it ideal for chat interfaces where newer messages appear at the bottom. The cursor-based pagination ensures efficient loading of large conversation histories.
The type MessageWithSender is exported from the package for proper TypeScript support.
Sending messages and connectors
Messages include a required source field (array) to indicate one or more origins for a message. Valid values are enforced by an enum of strings:
export const sourceType = {
LINKEDIN: 'linkedin',
WHATSAPP: 'whatsapp',
EMAIL: 'email',
CORE: 'core',
} as const;
export type SourceType = (typeof sourceType)[keyof typeof sourceType];When sending a message via sendMessage, you must provide source: SourceType[]. For internal server‑generated messages, use [sourceType.CORE].
If you include external connectors in the source (e.g., linkedin, whatsapp), you can optionally pass connectorIds to perform the external send and to persist identifiers for quick follow‑ups:
await chat.services.messages.sendMessage({
organizationId: 'org-123',
conversationId: 'conv-456',
senderModel: 'User',
senderId: 'user-1',
text: 'Hello from Wexa',
source: [sourceType.CORE, sourceType.LINKEDIN, sourceType.WHATSAPP],
connectorIds: {
linkedin: { connectorId: 'ln-connector-1', contactId: 'https://linkedin.com/in/someone' },
whatsapp: { connectorId: 'wa-connector-1', contactId: '+1 234 567 8901' },
},
});Under the hood (src/services/messages.service.ts):
- LinkedIn: posts to the LinkedIn connector with
{ linkedin_url, text }. - WhatsApp: posts to the WhatsApp connector with
{ phone_numbers, text }(phone is sanitized to remove spaces and a leading+). - Each external task runs concurrently. A successful task's
sourceremains in the stored message. Failures are collected into afailed_sourcearray attached to the returned message instance at runtime:message.failed_source: Array<{ source: string; message: string }>. - The conversation document is updated with last message metadata and, when applicable, with connector identifiers:
lastLinkedInId: string— set fromconnectorIds.linkedin.contactIdwhensourceincludeslinkedin.lastWhatsAppId: string— set from a sanitizedconnectorIds.whatsapp.contactIdwhensourceincludeswhatsapp.
This allows subsequent UI actions to prefill the last known contact handles for LinkedIn and WhatsApp.
Receiving inbound connector messages
When an external connector delivers a new inbound message, you can persist it directly using the chat ID stored on the conversation:
await chat.services.messages.receiveWhatsappMessage({
whatsappChatId: 'wa-chat-123',
message: 'Hello from WhatsApp',
});
await chat.services.messages.receiveLinkedinMessage({
linkedinChatId: 'li-chat-456',
message: 'Hello from LinkedIn',
});Both helpers will:
- Locate the conversation by the given chat ID.
- Use the
Applicationparticipant from the conversation as the sender. - Create a new message with
kind: 'text'andsourceset to the respective connector. - Update the conversation summary fields (
lastMessageAt,lastMessagePreview,lastMessageSenderId,lastMessageSenderModel).
Models
The package exports the following model types which can be imported directly:
import {
// Model interfaces (document structures)
type IConversation,
type IMessage,
// Mongoose model types
type ConversationModel,
type MessageModel,
// Extended types
type MessageWithSender // Message with populated sender data
} from 'wexa-chat';To access the actual models:
// Initialize the chat system
const chat = await initChat(mongoose, options);
// Access models
const { Conversation, Message } = chat.models;
// Example usage
const conversations = await Conversation.find({ organizationId }).lean();
const messages = await Message.find({ conversationId }).sort({ createdAt: -1 }).lean();Browser client
Entry: wexa-chat/client exports SocketProvider, useSocket, getSocketManager, and type ChatEvent.
Wrap your app:
import { SocketProvider } from 'wexa-chat/client';
export default function App({ children }: { children: React.ReactNode }) {
const getUserId = () => mySession?.user?.id as string | undefined;
return <SocketProvider getUserId={getUserId}>{children}</SocketProvider>;
}Use in components:
import { useEffect } from 'react';
import { useSocket, type ChatEvent } from 'wexa-chat/client';
export function ChatWindow({ conversationId }: { conversationId: string }) {
const { isConnected, send, onEvent } = useSocket();
useEffect(() => {
if (!conversationId) return;
if (isConnected) send({ type: 'join-conversation', conversationId });
const off = onEvent((e: ChatEvent) => {
if (e.type === 'message:created' && e.message?.conversationId === conversationId) {
// update UI
}
});
return off;
}, [conversationId, isConnected, send, onEvent]);
return null;
}You can also send:
send({ type: 'leave-conversation', conversationId });
send({ type: 'typing', conversationId, isTyping: true });Events (server → client)
message:created—{ type: 'message:created', message }conversation:read—{ type: 'conversation:read', conversationId, messageId, participantModel, participantId, at }typing—{ type: 'typing', conversationId, participantModel, participantId, state: 'start' | 'stop' }presence:join/presence:leave—{ type, organizationId, participantModel, participantId, at }
If Redis is configured, events are pub/sub‑broadcast across instances.
Build
npm run buildExports (from package.json):
{
"exports": {
".": { "import": "./dist/index.mjs", "require": "./dist/index.js", "types": "./dist/index.d.ts" },
"./client": { "import": "./dist/client/index.js", "types": "./dist/client/index.d.ts" }
}
}Environment
MONGODB_URIREDIS_URLorREDIS_HOST/REDIS_PORT/REDIS_PASSWORD/REDIS_DB- Ensure
socket.pathmatches the client (recommended/ws).
License
MIT
