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

@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

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_id instead of poolse uuids — userResolver, useUser, member operations all flip.

Install

npm install @poolse/sdk

The 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 channel

Configuration

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/me

chat.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/read

Messages 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/:emoji

Thread 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 swap

Concurrent 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 handle

RealtimeStatus: '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 spam

The 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 fetch and WebSocket. Upload progress is browser-only (silently no-ops in Node).
  • React Native — works on RN ≥ 0.72 (fetch + WebSocket are built in). The phoenix client is bundled.
  • Bun / Deno — works; same caveat on upload progress.
  • Web Workers — works for REST; sockets work where WebSocket is 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

License

MIT