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 🙏

© 2025 – Pkg Stats / Ryan Hefner

wexa-chat

v0.2.10

Published

Chat core library with MongoDB and Redis support

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 ioredis

Server 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 ws and attaches an HTTP upgrade listener when httpServer is provided.
  • Set socket.path to 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:

  1. Fetches messages using MongoDB aggregation
  2. Groups messages by sender model type
  3. Loads all senders for each model type in a single query per model
  4. 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 source remains in the stored message. Failures are collected into a failed_source array 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 from connectorIds.linkedin.contactId when source includes linkedin.
    • lastWhatsAppId: string — set from a sanitized connectorIds.whatsapp.contactId when source includes whatsapp.

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:

  1. Locate the conversation by the given chat ID.
  2. Use the Application participant from the conversation as the sender.
  3. Create a new message with kind: 'text' and source set to the respective connector.
  4. 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 build

Exports (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_URI
  • REDIS_URL or REDIS_HOST/REDIS_PORT/REDIS_PASSWORD/REDIS_DB
  • Ensure socket.path matches the client (recommended /ws).

License

MIT