@deepak.pfnp/chat-sdk
v0.1.2
Published
Headless chat SDK with socket transport and server-side bot support. Works on React web and React Native.
Maintainers
Readme
@deepak.pfnp/chat-sdk
Headless chat SDK — WebSocket transport, optimistic sends, offline queue, and a server-side rules bot. One install, one import path, works on React web and React Native.
You bring the UI. The SDK owns the data.
npm install @deepak.pfnp/chat-sdkQuick start
1. Initialise once, at app startup.
import { initChat } from '@deepak.pfnp/chat-sdk'
initChat({
url: 'wss://chat.example.com/socket',
user: { id: 'u_1', name: 'Deepak' },
token: authToken,
})2. Use it anywhere. No provider, no context, nothing to thread through props.
import { useChat } from '@deepak.pfnp/chat-sdk'
function Conversation() {
const { messages, send, choices, selectChoice, typingUserIds, isConnected } =
useChat('thread_1')
return (
<>
{messages.map(m => (
<div key={m.id} data-pending={m.status === 'pending'}>
<b>{m.author.name ?? m.author.id}</b>: {m.body}
</div>
))}
{typingUserIds.length > 0 && <em>typing…</em>}
{choices?.map(c => (
<button key={c.id} onClick={() => selectChoice(c.id)}>{c.label}</button>
))}
<button disabled={!isConnected} onClick={() => send('hello')}>Send</button>
</>
)
}3. Or drive it imperatively, outside React entirely.
import { chat } from '@deepak.pfnp/chat-sdk'
chat.send('thread_1', 'hello')
chat.on('message', m => showNotification(m))
chat.setTyping('thread_1', true)
chat.disconnect()Drop-in web widget
Don't want to build the UI? Mount one component and you have a working "Need help?" support popover:
import { ChatWidget } from '@deepak.pfnp/chat-sdk/ui'
<ChatWidget threadId="support:u_1" />Open it from anywhere — including outside React. No refs, no context, no prop drilling:
import { openChat, closeChat, toggleChat } from '@deepak.pfnp/chat-sdk/ui'
<button onClick={openChat}>Contact support</button>
// or from a plain module, an error handler, a router guard…
window.addEventListener('unhandledrejection', () => openChat())| Prop | Default | |
|---|---|---|
| threadId | — | required |
| title / subtitle | "Need help?" | header copy |
| launcherLabel | "Need help?" | floating button text |
| placeholder | "Type your message…" | composer |
| position | bottom-right | or bottom-left |
| theme | — | Partial<Theme> — see below |
| defaultOpen | false | open on mount |
| renderMessage | — | replace the bubble entirely |
| onOpenChange | — | fires on every open/close |
Theming is a plain object — override only what you want:
<ChatWidget threadId="t1" theme={{ primary: '#7c3aed', radius: 8 }} />Want your own trigger or placement? ChatPanel is the popover without the
launcher:
import { ChatPanel } from '@deepak.pfnp/chat-sdk/ui'
{isOpen && <ChatPanel threadId="t1" onClose={() => setIsOpen(false)} />}Included: unread badge, typing indicator, quick-reply chips, optimistic
send with pending/failed states, auto-scroll, Escape-to-close, focus
management, prefers-reduced-motion, and a full-screen mobile layout.
No CSS import needed. Styling is inline React styles plus a ~40-line
stylesheet injected once for the things inline styles can't express (:hover,
:focus-visible, keyframes, media queries).
Web only today. The React Native widget is next —
tokens.ts,types.tsanduseChatView.tsare already shared, so it's one more file.
What you get for free
| | |
|---|---|
| Optimistic sends | Messages appear instantly as status: 'pending', then collapse into the authoritative server copy on ack — never duplicated |
| Offline queue | Sends made while disconnected persist and replay on reconnect |
| Reconnection | Exponential backoff with jitter, so a server restart doesn't get stampeded |
| Network awareness | Reconnects immediately when the device comes back online instead of waiting out the backoff |
| Stable ordering | Sorted by (sentAt, id) — equal timestamps won't make messages swap places between renders |
| Typing indicators | Auto-expire, so the flag can't stick on when someone stops typing without sending |
React Native
Everything works out of the box, but the default storage is in-memory — the offline queue won't survive an app restart. Pass a real adapter:
import AsyncStorage from '@react-native-async-storage/async-storage'
import { initChat } from '@deepak.pfnp/chat-sdk'
initChat({
url: 'wss://chat.example.com/socket',
user: { id: 'u_1' },
storage: {
get: (k) => AsyncStorage.getItem(k),
set: (k, v) => AsyncStorage.setItem(k, v),
remove: (k) => AsyncStorage.removeItem(k),
},
})Core never imports a platform API directly — storage and network arrive through these adapters. That's what keeps one build running on both targets.
Server
The server lives at a separate subpath, so a Node install never pulls React in:
import { createChatServer, defineBot } from '@deepak.pfnp/chat-sdk/server'
const bot = defineBot({
start: 'greeting',
steps: {
greeting: {
say: 'Hi! What do you need help with?',
choices: [
{ id: 'billing', label: 'Billing' },
{ id: 'tech', label: 'Tech support' },
],
next: { billing: 'billing', tech: 'tech' },
match: [{ pattern: /price|invoice/i, next: 'billing' }],
fallback: 'unknown',
},
billing: { say: 'Billing it is. What is your invoice number?', end: true },
tech: { say: 'Tech support here. What is broken?', end: true },
unknown: { say: "Sorry, I didn't catch that." },
},
})
createChatServer({
port: 8787, // 8080 is often already taken — nginx, dev servers, Docker
bot,
authenticate: async (token) => {
const user = await verifyJwt(token)
return user ? { id: user.id, name: user.name } : null // null rejects
},
})Bot logic is server-side by design. The client only renders messages flagged
author.type === 'bot' and sends the selected choice back. That means you can
change a flow and redeploy without shipping a new app build.
Both sides import their event types from the same protocol.ts, so if you
change an event shape, whichever side is stale fails to compile.
The reference server keeps history in memory — restart and it's gone. Swap the
historyandbotSessionsmaps for your database when you productionise; the socket handling doesn't change.
Config
initChat({
url: 'wss://…', // required
user: { id, name?, avatarUrl? }, // required
token: string, // sent as ?token=… by the default transport
transport: TransportFactory, // swap in Socket.IO, or a fake for tests
storage: StorageAdapter, // default: localStorage on web, memory elsewhere
network: NetworkAdapter, // default: navigator.onLine on web
backoff: { initialMs: 500, maxMs: 30_000, factor: 2, jitter: 0.3 },
autoConnect: true,
maxOutboxEntries: 200,
maxSendAttempts: 5,
typingTimeoutMs: 3000,
debug: false,
})Next.js / SSR
initChat() deliberately does not connect when it runs on a server. The
singleton is module state, which is shared across requests — a live socket there
would leak one user's chat into another user's render.
Call it from a client component or an effect. Hooks are safe to render before initialisation; they return empty state until the client exists.
API
| Export | |
|---|---|
| initChat(config) | Bootstrap the singleton. Call once. |
| chat | Imperative facade — send, join, on, setTyping, disconnect… |
| useChat(threadId) | { messages, send, choices, typingUserIds, status, isConnected, … } |
| useConnectionStatus() | 'idle' \| 'connecting' \| 'connected' \| 'reconnecting' \| 'closed' |
| useChatError() | Latest error string, or null |
| resetChat() | Tear down the singleton — use on logout and between tests |
| createChatClient(config) | Unmanaged instance, if you need more than one |
Try it locally
npm install
npm run build
# terminal 1
npm run example:server
# terminal 2
npm run example:client -- aliceThen type. billing, tech, and human pick a quick reply; anything else is
routed by the bot's regex rules. /quit exits.
[connected]
bot: Hi! What do you need help with?
choices: [billing] Billing [tech] Tech support [human] Talk to a human
> alice: my invoice is wrong
bot: Billing it is. What is your invoice number?Each client gets its own thread (support:<name>) by default, because a bot
session belongs to a thread and ends when the flow reaches a step marked end.
Pass a thread name to put two users in the same room:
npm run example:client -- alice general
npm run example:client -- bob generalDevelopment
npm run typecheck
npm run build
npm test # spins up a real server and talks to it over a socketLicense
MIT
