@poolse/sdk
v2.1.6
Published
Headless TypeScript chat SDK for the poolse realtime backend. REST + WebSocket client for Node, Bun, Deno, browsers, and React Native. Framework-agnostic, no UI. MIT — alternative to Sendbird, Stream, Pusher, TalkJS, CometChat.
Downloads
5,573
Maintainers
Readme
@poolse/sdk
Headless TypeScript chat SDK — the REST + WebSocket client for the poolse realtime backend. Drop-in for any runtime with fetch + WebSocket: browsers, Node ≥ 18, Bun, Deno, React Native. No UI, no framework dependency, fully typed surface.
MIT-licensed alternative to Sendbird, Stream Chat, Pusher Chat, TalkJS, and CometChat. SDK is open source; the hosted backend is paid (free tier available, no time limit).
If you're using React, you'll usually want @poolse/react (hooks) or @poolse/react-ui (prebuilt chat surface). Both sit on top of this package; you can drop down to it whenever you outgrow them.
⚠️ Upgrading from 1.x? See MIGRATING.md. 2.0 is a breaking change: identity APIs are now keyed by your
external_idinstead of poolse uuids —userResolver,useUser, member operations all flip.
Install
npm install @poolse/sdkThe Phoenix Channels client (phoenix@^1.8) is bundled into the dist — you don't need to install it separately.
Authentication model
poolse separates API keys (server-side, full tenant scope) from End User JWTs (client-side, short-lived, single-user). The SDK is designed for the JWT side: your backend exchanges its API key for an End User JWT (POST /v1/users/:user_id/tokens), and the SDK uses that JWT for every REST call and the WebSocket handshake.
┌────────────┐ API key ┌─────────┐ user JWT ┌─────────────┐
│ your │ ────────▶ │ poolse │ ─────────▶ │ your │
│ backend │ │ REST │ │ frontend │
│ (mints │ │ │ │ (this SDK) │
│ JWTs) │ ◀──────── │ │ ◀───────── │ │
└────────────┘ └─────────┘ └─────────────┘Never embed an API key in a client bundle. The SDK calls your getToken whenever it needs the JWT; cache + refresh happen inside the SDK.
Quick start
import { Poolse } from '@poolse/sdk';
const chat = new Poolse({
apiUrl: 'https://api.poolse.dev', // optional; this is the default
getToken: async () => {
const res = await fetch('/api/chat-token', { method: 'POST' });
const { token } = await res.json();
return token; // string, or null for an anonymous request
},
});
// REST
const me = await chat.me.show();
const { data: conversations } = await chat.conversations.list();
const conv = chat.conversations.one('00000000-0000-0000-0000-000000000000');
const { data: messages } = await conv.messages.list({ limit: 50 });
await conv.messages.send({ body: 'Hello' });
// Realtime — socket opens lazily on the first conversation() / user() call.
const live = chat.realtime.conversation(conv.id);
const off = live.onMessage((msg) => console.log('new', msg));
// Clean up when you're done.
off();
chat.destroy(); // closes WebSocket, leaves every joined channelConfiguration
new Poolse(config) — every field but getToken has a default.
| Field | Type | Default | Notes |
| ------------------------ | ----------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| getToken | () => Promise<string \| null> \| string \| null | — required | Called when the SDK needs a JWT. Return null to make an unauthenticated request (rare — the server requires a JWT for most routes). |
| apiUrl | string | https://api.poolse.dev | Base URL without /v1. Strip the trailing slash if present (the SDK does this too). |
| wsUrl | string | apiUrl with http(s) → ws(s) | Override for split-host deployments where the WebSocket gateway is on a different origin. |
| socketPath | string | /socket | Phoenix Channels mount point. |
| fetch | typeof fetch | globalThis.fetch | Inject a fetch implementation (tests, restricted environments). Must be bound to globalThis if you pass the global one explicitly. |
| maxRetries | number | 3 | Retry budget for transient failures, per request. |
| baseBackoffMs | number | 250 | Base of the exponential backoff. |
| maxBackoffMs | number | 30000 | Hard ceiling on a single retry delay. |
| generateIdempotencyKey | () => string | crypto.randomUUID() | Override the key generator. Defaults throws at construction time if no crypto.randomUUID is available. |
| onSocketError | (err: Error) => void | — | Fired on non-fatal socket errors (Phoenix handles reconnect internally; this is for surface-level banners). |
| userResolver | (externalId: string) => Promise<PoolseUserProfile \| null> \| PoolseUserProfile \| null | — | Optional. Resolve the tenant's own user identifier (external_id — same string you pass when minting JWTs) to { displayName, avatarUrl } from your app's user data. The UI packages pick this up automatically. |
REST surface
All methods accept an optional AbortSignal (or { signal }) and return parsed JSON. Errors throw typed exceptions — see Errors below.
chat.me
chat.me.show(signal?): Promise<Me>; // GET /v1/mechat.conversations
chat.conversations.list(signal?): Promise<{ data: Conversation[] }>;
chat.conversations.create(attrs, signal?): Promise<Conversation>;
chat.conversations.one(id): ConversationHandle;ConversationCreateRequest:
{
type: 'direct' | 'group';
name?: string | null;
avatar_url?: string | null;
member_limit?: number | null;
member_external_ids?: string[];
custom_data?: Record<string, unknown>;
settings?: Record<string, unknown>;
}member_external_ids is your tenant's own ids — poolse looks them up by tenant and lazily provisions any that don't exist yet. The returned Conversation carries member_external_ids (the array of external_ids for every member) and last_message_preview (server-denormalized first ~80 chars of the most recent message body, used to render inbox rows without a per-row decrypt round-trip).
ConversationHandle (returned by chat.conversations.one(id)):
handle.show(signal?): Promise<Conversation>; // GET /v1/conversations/:id
handle.update(attrs, signal?): Promise<Conversation>; // PATCH /v1/conversations/:id
handle.listMembers(signal?): Promise<{ data: Membership[] }>; // GET /v1/conversations/:id/members
handle.addMembers(externalIds, { role?, signal? }?): Promise<{ data: Membership[] }>;
handle.addMember(externalId, { role?, signal? }?): Promise<Membership>;
handle.removeMember(userId, signal?): Promise<void>;
handle.messages: ConversationMessages;handle.messages:
handle.messages.list({ limit?, before? }?, signal?): Promise<{ data: Message[] }>;
handle.messages.send(attrs, signal?): Promise<Message>;
handle.messages.markRead(messageId, signal?): Promise<void>; // POST /v1/conversations/:id/readMessages are paginated newest-first by per-conversation sequence. To load older messages: list({ limit: 50, before: oldestSequence }).
MessageCreateRequest:
{
id?: Uuid; // optional client-supplied id for retry-safe sends
body?: string | null; // optional ONLY when attachment_ids is non-empty
type?: 'text' | 'system' | 'custom';
reply_to_id?: Uuid; // setting this promotes the message to a thread reply
quoted_message_id?: Uuid; // WhatsApp-style quote — stays in main feed
mentions?: Uuid[]; // user_ids to notify
attachment_ids?: Uuid[]; // attach pre-uploaded files; max 10
}If you omit id, send auto-generates one via crypto.randomUUID() so retries are idempotent and the realtime echo can be deduped against your optimistic insert.
chat.messages
The message handle is keyed by message id (not conversation), because some operations need the message id but not the conversation id.
chat.messages.one(id): MessageHandle;MessageHandle:
handle.update({ body }, signal?): Promise<Message>; // PATCH /v1/messages/:id
handle.delete(signal?): Promise<void>; // DELETE /v1/messages/:id (soft-delete)
handle.replies({ limit?, after? }?, signal?): Promise<{ data: Message[] }>; // GET /v1/messages/:id/replies
handle.addReaction(emoji, signal?): Promise<Message>; // POST /v1/messages/:id/reactions
handle.removeReaction(emoji, signal?): Promise<Message>; // DELETE /v1/messages/:id/reactions/:emojiThread replies are paginated oldest-first with an after cursor (opposite of the main feed). The default limit for replies is 500 — most threads load in one request.
chat.attachments
Two-step upload: presign → PUT. The SDK has a one-call helper too.
chat.attachments.requestUpload(attrs, opts?): Promise<AttachmentUploadResponse>; // POST /v1/attachments/upload-url
chat.attachments.upload(input, opts?): Promise<Attachment>; // presign + PUT, returns the row
chat.attachments.one(id): AttachmentHandle;AttachmentUploadInput:
{
body: BodyInit; // Blob, File, ArrayBufferView, string, etc.
contentType: string; // matches what you'll PUT
byteSize: number; // matches Content-Length of `body`
filename?: string; // shown in the download UI
}AttachmentOptions:
{
signal?: AbortSignal;
onProgress?: (event: { loaded: number; total: number }) => void;
}onProgress switches the PUT to XMLHttpRequest (the only browser API that exposes upload progress). When you don't pass onProgress, the SDK uses fetch and you get no progress events. XMLHttpRequest is browser-only, so progress in Node will silently no-op.
To attach to a message, take the returned attachment.id and pass it as part of attachment_ids on the next send. The server links + flips status to ready in the same transaction.
AttachmentHandle:
handle.downloadUrl(opts?): Promise<{ url: string; method: 'get' }>; // ~1h TTL
handle.delete(opts?): Promise<void>;chat.users
A small read-through cache for the customer-supplied profile (display name + avatar). The SDK doesn't store these — it asks your config.userResolver when it doesn't have them.
chat.users.peek(userId): PoolseUserProfile | null | undefined;
// undefined → not yet fetched (or no resolver)
// null → resolver ran, returned null
// profile → cached hit
chat.users.get(userId): Promise<PoolseUserProfile | null>;
chat.users.subscribe(userId, listener): () => void; // fires when an entry changes
chat.users.invalidate(userId): void;
chat.users.invalidateAll(): void; // sign-out, tenant swapConcurrent get(userId) calls for the same id share one in-flight resolver promise — a busy chat with 50 messages from 5 senders calls your resolver 5 times.
chat.rest
Escape hatch for endpoints not yet covered by the typed resources (e.g., admin routes). Same retry + auth + idempotency behavior as the typed methods.
const result = await chat.rest.request<MyShape>({
method: 'POST',
path: '/v1/something',
body: { foo: 'bar' },
query: { limit: 10 },
idempotencyKey: 'my-custom-key', // or null to omit; defaults to a fresh UUID for non-GETs
signal,
});Realtime
The WebSocket is lazily opened on the first conversation() or user() call — until then the realtime layer is dormant and won't try to authenticate.
chat.realtime.connect(): void; // idempotent; usually you don't call this directly
chat.realtime.disconnect(): void;
chat.realtime.getStatus(): RealtimeStatus;
chat.realtime.onStatus(fn): Unsubscribe;
chat.realtime.conversation(id): ConversationChannel;
chat.realtime.user(id): UserChannel;
chat.realtime.leave(id): void; // drop a conversation handleRealtimeStatus: 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed'.
Reconnect with backoff is handled by the underlying Phoenix Socket. On reconnect the JWT is re-read via getToken, so a refreshed token lands on the next handshake without manual intervention.
ConversationChannel
Returned by chat.realtime.conversation(id). Reusing the same id returns the same handle (no second channel join).
channel.onMessage(fn): Unsubscribe; // message:new
channel.onMessageUpdated(fn): Unsubscribe; // message:updated
channel.onMessageDeleted(fn): Unsubscribe; // message:deleted
channel.onReactionAdded(fn): Unsubscribe; // reaction:added
channel.onReactionRemoved(fn): Unsubscribe; // reaction:removed
channel.onMemberRead(fn): Unsubscribe; // member:read (read receipts)
channel.onTypingStart(fn): Unsubscribe;
channel.onTypingStop(fn): Unsubscribe;
channel.onPresenceState(fn): Unsubscribe; // initial snapshot (replayed to late subscribers)
channel.onPresenceDiff(fn): Unsubscribe; // joins/leaves deltas
channel.getPresenceState(): PresenceSnapshot; // synchronous read
channel.sendTyping(): void; // server is rate-limited; safe to spamThe server filters typing to peers — you won't receive your own typing event back.
Payload shapes are exact:
| Event | Payload |
| ------------------------------------- | ------------------------------------------------------------------------------------------- |
| message:new / message:updated | Message (full row, with attachments preloaded and quoted_message set when applicable) |
| message:deleted | { id: Uuid; conversation_id: Uuid; deleted_at: string \| null } |
| reaction:added / reaction:removed | { message_id, conversation_id, emoji, user_id } |
| member:read | { user_id, conversation_id, last_read_message_id, last_read_at } |
| typing:start / typing:stop | { user_id } |
| presence_state | Record<user_id, { metas: Array<{ phx_ref, online_at, external_id? }> }> |
| presence_diff | Same shape, partitioned into { joins, leaves } |
UserChannel
Returned by chat.realtime.user(id). Only the user matching the JWT can join their own channel — the server rejects other ids.
channel.onMention(fn): Unsubscribe;
channel.onConversationCreated(fn): Unsubscribe;
channel.onConversationUpdated(fn): Unsubscribe;onMention corresponds to the mention:new push and carries { message_id, conversation_id, sender_id } — fetch the full message via REST if you need the body.
onConversationCreated corresponds to the conversation:created push, which fires whenever the user is added to a conversation (including ones they created themselves) and carries the full Conversation row with member_external_ids preloaded.
onConversationUpdated corresponds to the conversation:updated push and fires after every message sent in any of the user's conversations. Payload shape is { conversation: Conversation, by_user_id: Uuid | null } — by_user_id is the sender's id, so a client can avoid bumping its own unread when the server already advanced its read cursor in the same transaction. The conversation carries the freshly written last_message_preview, last_message_at, and last_sequence so an inbox UI can update without a refetch.
Token caching
config.getToken is called once per JWT lifetime, not per request. The cache decodes the JWT's exp claim and refreshes 30s before expiry. Concurrent callers share the in-flight refresh promise.
On a 401, the SDK invalidates the cache and retries the request once with a fresh token. If the second attempt also 401s, AuthError is thrown.
If you sign the user out or rotate tenants, drop the current Poolse instance (or, in @poolse/react, give <PoolseProvider> a new key) — there's no in-place invalidate API at the SDK level because mutating connection-shaped config after construction doesn't reach the open socket anyway.
Idempotency
Every non-GET request carries an auto-generated Idempotency-Key header. The server caches 2xx responses per (tenant, method, path, key) for 24 hours, so a retry after a flaky network drop returns the same row instead of creating a duplicate. Non-2xx responses are never cached — you can retry with the same key after fixing a validation error.
To override the key (e.g., to make a deliberate retry intentional), pass idempotencyKey on chat.rest.request. Pass null to omit the header entirely. Resource methods auto-generate a fresh key per call.
Retry behavior
Transient failures (network errors, 5xx, 429) are retried with exponential backoff + full jitter, capped by maxBackoffMs. On a 429 the server's Retry-After (seconds) is honored if present and larger than the computed backoff. Aborts (DOMException('AbortError')) are propagated, never wrapped.
The retry budget is maxRetries (default 3) attempts after the initial request — up to 4 attempts total per logical call.
Errors
All errors extend PoolseError and are instanceof-checkable:
import { ApiError, AuthError, RateLimitedError, NetworkError, PoolseError } from '@poolse/sdk';
try {
await chat.messages.one(id).update({ body: 'edit' });
} catch (err) {
if (err instanceof AuthError) {
// 401: the JWT is invalid even after one refresh attempt. Re-auth the user.
} else if (err instanceof RateLimitedError) {
// 429: err.retryAfterMs is parsed from the Retry-After header (0 if absent).
} else if (err instanceof ApiError) {
// Other 4xx/5xx with a canonical envelope: err.status, err.code, err.docUrl, err.details
} else if (err instanceof NetworkError) {
// fetch() rejected; err.cause is the original failure.
}
}The server's error envelope is exposed via the code / docUrl / details fields on ApiError. Codes are stable strings like validation_failed, forbidden, message_deleted, attachment_too_large — see the docs at err.docUrl for the full list per endpoint.
Sequence numbers
Every message has a monotonic per-conversation sequence. It's assigned in the same transaction as the insert, so server order is unambiguous regardless of clock skew. The SDK uses it for pagination (before / after cursors) and for sorting optimistic-vs-server-echo races: the optimistic local insert can sort to the very end until the server-assigned sequence arrives, at which point the dedup-by-id upsert puts it in the right place.
Cancellation
Every method that touches the network accepts an AbortSignal. The SDK propagates abort to the underlying fetch (and to XMLHttpRequest on the progress-enabled upload path). Aborted requests throw DOMException('AbortError') — they are not retried.
const controller = new AbortController();
const promise = chat.messages.one(id).replies({ limit: 100 }, controller.signal);
// later:
controller.abort();Environments
- Browsers (modern) — works out of the box.
- Node ≥ 18 — uses the global
fetchandWebSocket. Upload progress is browser-only (silently no-ops in Node). - React Native — works on RN ≥ 0.72 (
fetch+WebSocketare built in). Thephoenixclient is bundled. - Bun / Deno — works; same caveat on upload progress.
- Web Workers — works for REST; sockets work where
WebSocketis available.
Exports
The package exports the Poolse class, the resource and channel classes (ConversationsResource, ConversationHandle, MessagesResource, MessageHandle, AttachmentsResource, AttachmentHandle, MeResource, UsersResource, PoolseRealtime, ConversationChannel, UserChannel), all error classes, every interface from the wire protocol (Message, Conversation, Membership, User, Attachment, etc.), the RealtimeStatus union, the POOLSE_API_URL default, and a version constant for diagnostics.
Links
- Full docs — https://poolse.dev/docs
- Source — https://github.com/poolse-hq/js-sdk
- Issues — https://github.com/poolse-hq/js-sdk/issues
License
MIT
