@cariosan/client
TypeScript client SDK for browsers, Node, and React Native.
The TypeScript SDK for browsers, Node 20+, and React Native. Zero runtime dependencies — uses native fetch and WebSocket.
Setup
Create the client once per signed-in user and keep it: token renewals happen inside it, so channels, listeners and pending messages survive them.
Never persist the JWT — the token is the JWT your backend issues with the Go SDK's IssueUserToken (see the token endpoint). The SDK keeps it in memory only. With a token provider there is nothing to persist: the SDK asks for a new token whenever it needs one. Persisting JWTs to localStorage exposes them to XSS.
Tokens
token takes either a function (a token provider) or a plain string.
With a provider (recommended), the SDK calls your function:
- on
connect(), when it has no usable token; - shortly before the current token expires: 45 seconds before by default (
tokenRenewLeadMs), but never earlier than 20 % of the token's lifetime; - when the server rejects the token: a REST
401, the socket closed with4401, or a reconnect the server refuses.
Concurrent needs share one call. A renewed token reaches REST at once and moves the open WebSocket to it without reconnecting (the auth.renew frame). On a server too old for auth.renew, the SDK reconnects instead. That disconnected event carries planned: true, and client.connected stays true throughout, so status indicators do not flicker.
- Return a freshly issued token. Renewal is timed from the token's lifetime (
exp - iat), counted from when it arrives. That keeps working on devices whose clock is wrong, but a token your backend cached for minutes renews late. For a token without aniatclaim, return{ token, expiresAt }.expiresAttakes aDate, an ISO string, or epoch seconds or milliseconds. - Failed requests are retried. A REST request that gets a
401renews the token once and retries, so it usually succeeds without your code noticing. Messages in the outbox that failed for want of a valid token are re-sent when the next token arrives. They keep theirclient_message_id, so they cannot duplicate. - When the provider throws, the SDK emits
errorwith codeTOKEN_PROVIDER_FAILED(your error is itscause) and retries with backoff from 1 s up to 30 s. It keeps using the current token while that has not expired. - When the server keeps refusing new tokens, the SDK stops. Two renewals in a row without the socket opening count as refusing. It then emits
errorwith codeUNAUTHORIZED; callconnect()to try again.
With a plain string, the SDK uses the token until the server rejects it, then stops with UNAUTHORIZED. Recover with client.setToken(newToken), which also reconnects, or with a new client. If the token expires while the socket is down, the SDK stops reconnecting once a reconnect fails, rather than retrying forever with a token the server refuses.
Channels
Get a channel handle (cached — the same externalId always returns the same object):
Send
sendMessage resolves when the server has stored the message and rejects otherwise. For a chat UI that should show the message at once, use the outbox below.
Sending states
channel.outbox holds the messages you sent that the server has not confirmed. It gives you the two states the server never reports: pending (🕒, the request is in flight) and failed (it needs a retry or a delete).
Messages are sent one at a time, in the order they were queued; a failure does not block the ones behind it. Each entry has status ("pending" or "failed"), the original input, createdAt, attempts, and, once failed, the error.
An entry leaves the outbox as soon as its message comes back. Every message the server stores returns the sender's key as client_message_id (in the send response, the live message.new frame, and history), and the channel retires the matching entry whichever of those arrives first. So a message whose live frame beats the REST response replaces its 🕒 at once instead of appearing twice for a moment, and a message the server stored although the response was lost stops showing as "Not sent" as soon as it arrives (live, or when history is reloaded after a reconnect).
Retrying is safe. The outbox gives every message one idempotency key (input.clientMessageId, a UUID) when it is queued and sends the same key on every retry. If the first attempt did reach the server (the connection dropped before the answer came back), the retry returns the message the server already stored (Idempotent-Replayed: true) instead of creating a second one, and the message then leaves the outbox as sent. So you never need to warn the user that retrying "might send it twice".
maybeDelivered still tells you what the failure revealed: false when the server definitely did not store the message (a 4xx, or 503 SERVICE_UNAVAILABLE), true when you got no answer or a 500/502/504. It is informational; the retry is safe either way.
To retry a plain sendMessage yourself, pass the same key each time: channel.sendMessage({ text, clientMessageId }).
Not every failure is worth a retry. Each failed entry has canRetry: false for failures that will repeat on every attempt (a 4xx such as a muted user, a frozen channel, a message rejected by moderation, not a member, or a reused key), true for network errors, timeouts, 5xx, 408 and 429. Offer Retry only when canRetry is true, show error.message for the others, and use retryAfterSeconds (the server's Retry-After, set for a 429 or 503) to tell the user how long to wait. The React and React Native kits do this for you.
A send that never answers. Messages go out one at a time, so a request that hangs (a half-open connection) must not block the queue: each attempt is given 30 seconds, then fails with code TIMEOUT (retryable, maybeDelivered true) and the next message proceeds. If the slow request answers after all, the failed entry is retired as sent. Change the limit with new Outbox(send, { attemptTimeoutMs }); 0 disables it.
Where a confirmation came from. The sent event carries source: "response" (the REST answer to the send), "live" (the message.new frame) or "history" (a page of history). A "history" message can be old; do not append it to the end of a conversation, it already has its place there.
Server version
Deduplication needs a server that includes the client_message_id support (it ignores the field otherwise, and a retry could then store the message twice). Self-hosters: deploy the server before, or together with, the SDK.
Reply (quote)
Quote a message by passing its id as parentId:
The reply carries parent_id and a quoted_message preview ({ id, user_id, user_name, content, type, deleted }) on the send response, in history, and on the live message event. The parent must be in the same channel or the call rejects with VALIDATION_ERROR. See Quoted replies.
Mention
Pass channel members' external_ids in mentions:
The message gains a resolved mentions array ([{ user_id, external_id, name }]). Each mentioned member is pinged on their own connection, surfaced as a mention event on both the Cariosan instance (for a global badge) and the relevant Channel:
Mentioning a non-member rejects the send with VALIDATION_ERROR. See Mentions.
Receive
Channel events you can listen for:
message— fires for every message in the channelmessage.updated— a message was edited ({ id, content, edited_at })message.deleted— a message was unsent ({ id, deleted_at }) — render a tombstonereaction.add/reaction.remove— an emoji reaction changed ({ message_id, user_id, emoji })mention— the current user was @-mentioned here ({ channel_id, message_id, by_user_id }); also emitted on theCariosaninstanceresync— the server may have lost live events for this channel while you stayed connected ({ reason? }); re-read recent history. Handled for you byuseMessages; see Staying in synctyping— typing indicators (start / stop)presence— member joins and leaves (channel-scoped; global presence comes fromCariosan#on("presence.update", ...))
Tip
Listeners are automatically unsubscribed when the channel is closed.
You do not need to call client.listChannels() before listening. Live frames name a channel by its server id, so the first frame for a channel the SDK has not seen triggers one lookup of your channels; frames that arrive meanwhile are held and delivered in order.
History
before alone still works (the older, timestamp-only cursor) but can skip messages that share the boundary timestamp. See History.
Search
Relevance-ranked full-text search. Scope to one channel, or search across every channel you belong to via the top-level client:
Every match is returned exactly once across pages. There is no before option on search: continue only with pageToken. Continue only while has_more is true; the last page has no next_page_token.
Supports websearch syntax ("quoted", OR, -exclude). Cross-channel search is member-scoped and excludes deleted messages. See Search.
Reactions, editing & unsending
Messages from getMessages carry their current state: reactions ([{ emoji, count, reacted_by_me }]), edited_at, and deleted_at. A non-author edit/delete returns 403 NOT_AUTHOR; past an optional server-configured window, 422 EDIT_WINDOW_EXPIRED / UNSEND_WINDOW_EXPIRED.
Typing and read markers
Read & delivery receipts
WhatsApp-style ticks. The SDK auto-acks delivery (sends channel.delivered on every inbound message) and markRead() advances the read cursor; both broadcast so senders see ticks flip live. Seed the member cursors once with getReceipts(), then read per-message state with receiptState():
receiptState(message) compares every other member's cursor against the message's created_at: all delivered → delivered, all read → read, else sent. Read receipts can be disabled per workspace (read_receipts_enabled), in which case channel.read is never broadcast — delivery still is. See Read & delivery receipts.
Push notifications
Register a device's FCM token to receive push for new messages (delivered when the workspace's server has Firebase configured — see Push):
Registration is idempotent on the token. unregisterDevice only removes a token registered to the signed-in user (anything else is a silent no-op), so call it before discarding the user's chat token. Payloads are minimal (title only) — fetch content after the app opens using the channel_id/message_id in the notification's data.
Blocking
Hide another user's messages from your history + search (workspace-wide):
History and search are filtered server-side; use listBlocked() to also drop live WebSocket messages from blocked users client-side. See Blocking & muting.
Presence
Live updates arrive via the top-level Cariosan#on("presence.update", ...) handler.
Group admin management
Group channels expose channel.avatarUrl, a channel.lastMessage preview (on listChannels()), and admin-only management. The acting user must be a channel admin (created_by at create time makes someone admin) — non-admins get a 403. Removing yourself is allowed for anyone (leave group).
See Roles & group admins for the full hybrid (backend + client) model.
Attachments
The SDK returns a stable private object reference after uploading. Request a download grant before displaying it.
Lifecycle events
The events only announce changes. To learn the state right now (for example in a component that mounts after the socket has already opened), read client.connected. It stays true during a planned reconnect. The React useConnectionStatus() hook reads it for you.
Error handling
Every thrown error is a CariosanError with a stable code, an optional status, and a retryable: boolean hint:
Sending while the server's Redis is unreachable fails with status 503, code "SERVICE_UNAVAILABLE" and retryable: true; nothing was stored, so the same call can be retried.
Content Security Policy — the SDK uses no eval and no HTML string sinks, so it runs under a strict policy without 'unsafe-eval'. Add your Cariosan server (https:// and wss://) and your attachment storage origin to connect-src. Uploads go straight from the browser to storage. See Security headers.
Bundle size — ESM min+gzip is about 8 KB. No external runtime dependencies; tree-shakes cleanly. Pair with the React peer (@cariosan/react, ~9.4 KB) for drop-in UI.
Private attachment downloads
public_url returned by upload presigning and attachment_url on a message are stable references. They no longer authorize anonymous reads. Request a fresh download grant using the message ID and channel external ID:
Response: { "url": "https://storage.example/...signed...", "expires_at": "..." }.
Only current members of the matching workspace and channel can request a grant. Deleted messages are unavailable. Grants expire after five minutes and remain usable until expiry even if membership is later revoked. Do not persist signed URLs or include them in analytics. Refresh a grant after expiry.
Upload references are restricted to the authenticated uploader's workspace/user prefix. Reusing another user's raw object reference as a new attachment is rejected. Existing messages retain their references and can be resolved through the authorized endpoint while the configured bucket and URL base remain unchanged.
Was this page helpful?