@myatthirikhin/chat-core
v0.1.0
Published
Backend-agnostic chat types, the IMessagingProvider contract, and the ChatClient facade.
Readme
@myatthirikhin/chat-core
The backend-agnostic half of the chat SDK: domain types, the IMessagingProvider contract, and the
ChatClient facade.
Zero dependencies. No React, no React Native, no vendor SDK. That is what lets an adapter, a React Native UI, a Node server and a plain unit test all share one vocabulary.
pnpm add @myatthirikhin/chat-core @myatthirikhin/chat-supabaseQuickstart
import { ChatClient } from '@myatthirikhin/chat-core';
import { createSupabaseChatProvider } from '@myatthirikhin/chat-supabase';
const client = new ChatClient({
provider: createSupabaseChatProvider(supabase),
});
await client.connect({ id: userId, name: 'Ana' });
const conversation = await client.createConversation({ type: 'dm', memberIds: [peerId] });
await client.sendMessage(conversation.id, { text: 'Hello' });
const off = client.on('message.new', (message) => console.log(message.text));API
// connection
client.connect(user) client.disconnect()
client.user client.connectionState
// conversations
client.createConversation({ type: 'dm', memberIds })
client.createConversation({ type: 'group', name, memberIds })
client.getConversations({ limit }) client.getConversation(id)
client.openConversation(id, onReady) // returns an unsubscribe
// messages — newest first, keyset pagination
client.getMessages(id, { cursor, limit })
client.sendMessage(id, { text, attachments, replyToId, clientId? })
client.editMessage(id, messageId, text)
client.deleteMessage(id, messageId) // soft delete
client.markAsRead(id, messageId) // monotonic
client.uploadAttachment(id, file)
// typing + presence
client.startTyping(id) client.stopTyping(id)
client.getPresence(userIds)
// events
client.on('message.new' | 'message.updated' | 'conversation.updated'
| 'typing' | 'presence' | 'connection', cb)Three behaviours worth knowing:
sendMessagegenerates aclientId, so a retry after a timeout returns the original message rather than posting twice. Pass your own if you need idempotency to survive an app restart.createConversationadds you to the member list. Every backend requires the creator to be a member; there is no reason for each app to remember that.on()works beforeconnect(). The client wires provider subscriptions on connect and re-wires them across reconnects, so a listener registered at mount time keeps working.
Pagination
getMessages returns newest-first with a nextCursor. Stop when the cursor is null — never
when a page comes back shorter than limit, which backends are allowed to do at any time.
let cursor: string | null | undefined;
do {
const page = await client.getMessages(id, { cursor: cursor ?? undefined });
render(page.messages);
cursor = page.nextCursor;
} while (cursor);Reconnection
openConversation(id, onReady) fires onReady when the subscription goes live and again after
every reconnect. Most backends do not replay what was missed while the socket was down, so that
callback is where you re-read recent history.
Always open before fetching. The other order loses any message that lands between the query's snapshot and the subscription starting — permanently.
Writing an adapter
Implement IMessagingProvider, then prove it:
import { describeMessagingContract } from '@myatthirikhin/chat-core/testing';
describeMessagingContract('my backend', async () => ({
provider: createMyProvider(),
me: { id: 'user-a' },
peer: { id: 'user-b' },
}));The suite checks the behaviours that actually differ between vendors: DM dedupe ignoring member
order, groups NOT being deduped, a repeated clientId returning the original message, pagination
terminating without duplicates across page boundaries, delete being soft, markRead being
monotonic, and an unknown user being absent from presence rather than reported offline.
@myatthirikhin/chat-core/testing also exports createFakeMessagingProvider() — a complete in-memory
implementation, useful for building UI before a backend exists.
License
MIT
