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

@basalam-saas/chat-sdk

v0.5.0

Published

TypeScript client for chat-as-a-service — REST + WebSocket with reconnect, seq-dedup, and backfill built in.

Readme

@basalam-saas/chat-sdk

TypeScript client for chat-as-a-service — REST + WebSocket, with reconnect and message deduplication + backfill built in so you don't have to get the hard parts right yourself.

npm install @basalam-saas/chat-sdk   # (or build from source: npm install && npm run build)

1. Your backend mints a user token

The chat service never sees your users' passwords. When a user logs into your app, your backend mints a short-lived JWT signed with the per-tenant signing secret the chat service gave you (HS256), and hands it to your frontend:

import jwt from "jsonwebtoken";

const token = jwt.sign(
  { iss: "<your-tenant-slug>", sub: user.id, name: user.displayName, avatar: user.avatarUrl },
  process.env.CHAT_SIGNING_SECRET,         // issued by the chat service, kept server-side
  { algorithm: "HS256", expiresIn: "1h" },
);

sub is your own user id (any string). The chat service JIT-creates the user on first use. (Server-to-server calls — provisioning conversations, registering webhooks — use your API key instead, not this user token.)

2. Your frontend talks to chat

import { ChatClient } from "@basalam-saas/chat-sdk";

const chat = new ChatClient({
  baseUrl: "https://chat.example.ir/api/v1",
  wsUrl: "wss://chat.example.ir/ws",
  token,                                   // from step 1
});

// Receive in real time. The SDK dedups by (conversation, seq) and backfills
// anything missed across a reconnect — you just render what arrives.
chat.on("message", (m) => appendToUI(m));
chat.on("typing", (t) => showTyping(t));
chat.connect();

// Start a 1:1 and send.
const convo = await chat.createConversation({ type: "direct", participantExternalIds: ["seller-42"] });
await chat.sendMessage(convo.id, { body: "Hi!", clientMsgId: crypto.randomUUID() });

// Quote-reply: quote an earlier message in the SAME conversation. It comes back
// on every read as m.reply_to = { id, seq, sender_user_id, type, body, deleted_at }.
await chat.sendMessage(convo.id, { body: "on it", replyToMessageId: lastMessage.id });

// History (seq cursor — not offset).
const { items } = await chat.getMessages(convo.id, { limit: 50 });

// Inbox: each row carries the newest message, ready to render as the preview line.
const { items: convos } = await chat.listConversations();
convos.forEach((c) => renderRow(c.title, c.last_message?.body, c.unread_count));

// Seen: every message carries its own state (see below).
const ticks = m.seen_by_all ? "✓✓" : `✓ ${m.seen_by_count}`;

// Read receipts, typing, attachments.
await chat.markRead(convo.id, convo.last_message_seq);
chat.sendTyping(convo.id, true);
const attachmentId = await chat.upload(file);            // request → PUT to Ceph → confirm
await chat.sendMessage(convo.id, { attachmentId });

Groups: members and muting

// Owner/admin only. Ids are YOUR external_user_ids; unknown users are created.
await chat.addMembers(convo.id, ["seller-42", "support-7"]);

// Remove someone (owner/admin). The owner can't be removed.
await chat.removeMember(convo.id, "seller-42");   // → updated Conversation

// Leave it yourself. Resolves to nothing — you're not a member any more,
// so there's no conversation left to return. Drop it from your UI.
await chat.leave(convo.id, myExternalUserId);

// Mute/unmute FOR YOURSELF; read it back from convo.my_muted.
await chat.setMuted(convo.id, true);

Everyone in the conversation gets member.added / member.removed, and a removed user is notified on their own channel so their client can drop it.

What muting does — and doesn't. setMuted is a per-member flag, private to you: nobody else can see it. It changes nothing server-side. Messages still arrive over the socket, still count toward unread_count, and still trigger the offline webhook. It exists so you can act on it: skip the sound or badge in your UI, and skip the push in your webhook handler. If you mute a conversation and keep sending pushes, the user still gets notified.

Broadcast channels

A channel is an announcement room: your admins post, everyone else reads. It behaves like any other conversation for reading — it appears in listConversations(), carries unread_count and history, and new posts arrive on the message event — but subscribers can't post, and can't see each other.

// End users opt themselves in / out.
await chat.subscribe(channelId);
await chat.unsubscribe(channelId);

// Reading is identical to any conversation.
const { items } = await chat.getMessages(channelId, { limit: 50 });

Channels are created and populated by your backend, server-to-server with your API key — an end user can't spin one up and mass-subscribe people:

POST /api/v1/channels                       { title, admin_external_ids }
POST /api/v1/channels/{id}/subscribers      { external_user_ids }   # bulk
DELETE /api/v1/channels/{id}/subscribers/{external_user_id}
GET  /api/v1/channels/{id}/subscribers?page=1                       # paginated

What differs from a group, and why:

| | | |---|---| | conversation.type | "channel" | | subscriber_count | how many subscribe (groups: null) | | members | the admins only — subscribers are not enumerable from a conversation payload | | Posting | admins only; anyone else gets 403 | | seen_by_count / seen_by_all | always 0 / false — "seen by 8,432" isn't a thing a broadcast UI shows | | typing / read events | not emitted | | unread_count | works normally |

Reliability (handled for you)

  • Send is idempotent — pass a clientMsgId; a retry returns the original message.
  • Dedup — the live socket and a post-reconnect backfill overlap; the SDK drops duplicates by (conversation_id, seq).
  • Reconnect + backfill — on reconnect the SDK refetches after_seq per conversation, so a dropped socket never loses messages.
  • Token refresh — call chat.setToken(newJwt) before expiry; it re-auths the live socket in place (listen for auth.expired as a backstop).

Seen status (read it once)

Every Message carries seen_by_count (how many other members have read it — the sender is never counted) and seen_by_all. They're derived from each member's read cursor rather than stored per message, which is what keeps read state O(members) instead of O(members × messages).

Two consequences worth knowing:

  • They're a snapshot at fetch time, kept live by the read event. That event carries an absolute last_read_seq, so re-applying it is harmless. It is sent to every member including the reader, so your own other tabs/devices stay in sync — and it's suppressed entirely when a cursor doesn't actually move, so replying to it with markRead() can't loop.
  • They are not replayed by changedSinceEvent. After a long disconnect, refetch the recent page (or the conversation) to resync counts.

Need to know who saw a message, not just how many? Each conversation.members[] entry carries last_read_seq, so you can derive it yourself — and unlike the read event (which only reports cursor moves), this gives you the state at load time:

const readers = convo.members
  .filter((mem) => mem.user_id !== m.sender_user_id && mem.last_read_seq >= m.seq)
  .map((mem) => mem.external_user_id);          // → ["ali", "sara"]

Keep it current by applying read events to your local copy of members:

chat.on("read", (e) => {
  const mem = convo.members.find((x) => x.external_user_id === e.user_id);
  if (mem) mem.last_read_seq = e.last_read_seq;   // absolute, so this is idempotent
});

read.user_id is your external user id; Message.sender_user_id is the platform's internal UUID. conversation.members[] carries both, if you need to map between them.

API surface

me() · listConversations() · getConversation() · createConversation() · getMessages(id, {afterSeq,beforeSeq,changedSinceEvent,limit}) · sendMessage(id, {body,attachmentId,clientMsgId,replyToMessageId}) · editMessage() · deleteMessage() · markRead() · addMembers(id, ids) · removeMember(id, ext) · leave(id, myExt) · setMuted(id, bool) · subscribe(channelId) · unsubscribe(channelId) · upload(file) · getDownloadUrl() · getPresence(ids) · connect() / disconnect() / setToken() / sendTyping().

Events: message, message.updated, message.deleted, typing, presence, read, open, reconnect, close, auth.expired.

Offline push

For users who are offline (no live socket), the chat service calls your backend's registered webhook (HMAC-signed) so you can send a push notification — the chat service never touches APNs/FCM. See backend/CLAUDE.md → webhooks.

The full wire contract (envelope, cursors, event shapes) lives in backend/CLAUDE.md — this SDK is a faithful mirror of it.