@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.
Maintainers
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.
setMutedis 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 towardunread_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 # paginatedWhat 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_seqper 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 forauth.expiredas 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
readevent. That event carries an absolutelast_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 withmarkRead()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.
