Skip to main content

TypeScript SDK (@converger/client)

@converger/client is a headless TypeScript client for Converger Protocol v1. It runs in browsers, in Node 20+ and in React Native, and has no runtime dependencies. The source is in packages/client-ts. The design is recorded in ADR-0051.

Which client to use:

you are buildinguse
a web chat widgetchativa: @chativa/connector-mekik (connecting chat clients), or @chativa/connector-converger for the hub extras (receipts, upload, history, multi-conversation)
a bot or an agent console that serves a whole channel@converger/agent (channel-scoped sockets, #67)
a Node service, a React Native app, a custom UI@converger/client (this page)

Status: implemented in #46. The package is built and tested in CI and published on npm from 0.1.0. New versions are published by the client-ts-publish workflow when a client-ts-v* tag is pushed (Releasing the SDKs).

Install​

npm install @converger/client
# pnpm add @converger/client

The package ships ESM and CommonJS builds with type declarations. It uses the platform's WebSocket, fetch and, in browsers, localStorage. On Node 20 (which has no global WebSocket), pass one from the ws package as the WebSocket option.

Usage​

import { ConvergerClient } from "@converger/client";

const client = await ConvergerClient.connect({
baseUrl: "https://converger.example.com",
// Your backend calls POST /api/v1/converger/tokens/generate with the
// channel secret and returns the token. Never ship a channel secret.
tokenProvider: async ({ reason }) => {
const response = await fetch("/api/converger-token", { method: "POST" });
const { token, expires_in } = await response.json();
return { token, expiresIn: expires_in };
},
});

const conversation = client.conversation();
conversation.on("activity", (frame) => render(frame));
conversation.on("connectionState", ({ state }) => showStatus(state));

await conversation.start(); // resolves on `welcome`
const ack = await conversation.send("Where is my order?"); // resolves on `ack`: {clientId, id, seq, duplicate}

// Later, or after a reload:
const resumed = client.conversation(conversationId);
await resumed.resume(); // from the stored watermark (0 = the whole transcript)
await resumed.resume(17); // or from an explicit one

ConvergerClient.connect fetches the first token, so a broken tokenProvider fails right away. It opens no socket yet. Each conversation has its own connection: the native endpoint serves one conversation per connection.

start() opens a new conversation, or the conversation a token is restricted to. With a channel-level token that carries user.id, the server creates the conversation in the handshake (section 3.2). resume() needs the conversation id.

Events​

eventpayloadwhen
activityActivityFrame: text, event, conversationUpdate, rich and mekik framesa persistent frame, live or replayed. Each seq is emitted once; own turns acknowledged in this session are not repeated
deliveryStatusDeliveryStatusFramedelivery progress or a read receipt (section 8.1)
typingTypingFrameanother participant typing
presencePresenceFramea participant online or offline
connectionState{state, previous, attempt, delayMs?, transport, close?, error?}connecting, open, reconnecting, closed
welcomewelcome.dataevery handshake, with limits, capabilities and expiresAt
sent{clientId, id, seq, duplicate}a send was acknowledged, including sends restored from storage
sendFailed{clientId, error}a send was rejected for good
replayTruncated{watermark, headSeq}more than replayMax frames were missing; the client keeps syncing
errorConvergerErrornon-fatal: protocol errors, storage failures, token refresh failures

Other calls: conversation.typing(true), conversation.markRead(seq?), conversation.history({limit}) (an async iterator over REST pages), conversation.upload(file, {activity}), conversation.closeConversation() and reopenConversation(), conversation.disconnect() and client.close().

Reconnect and resume​

  • On every close except 4400 (unsupported protocol), 4403 (channel inactive, forced disconnect) and 1008 (conversation not found), the client reconnects with jittered exponential backoff. The delay is min(maxDelayMs, initialDelayMs * factor^(attempt-1)) with 50 % "equal jitter", by default 1 s, 2 s, 4 s and so on up to 10 s (30 s before #198). A retryAfterMs from the close reason (1012 draining, 4503 slow consumer) or from an error frame is a floor. After 4401 the client asks the tokenProvider for a new token (reason: "rejected") before reconnecting; it gives up after three rejections in a row. reconnect: false turns reconnects off, and maxAttempts bounds them.
  • The watermark is the highest seq up to which the client has seen every persistent frame. Own turns count through their ack. The reconnect hello carries the conversation id, the watermark and the user id the server assigned in the first welcome, so a session without a user_id claim keeps its identity and its clientId namespace. The server replays everything above the watermark (section 6.2). Frames at or below it, or seen twice, are dropped.
  • Gaps (a seq that never arrived, for example a PubSub message lost between nodes) are filled with sync after 1.5 s, also when a heartbeat's headSeq is ahead of the watermark. replayTruncated and replay_unavailable lead to a sync from the right position. invalid_watermark (the client is ahead of the server, for example after a database restore) resets the watermark to 0.
  • The native transport sends ping after idleTimeoutMs / 2 of its own silence and drops a connection that has been silent for idleTimeoutMs plus 15 s, so a half-open TCP connection is noticed. A connection attempt that has not completed its handshake within connectTimeoutMs (15 s; nativeTransport({connectTimeoutMs}), phoenixTransport({connectTimeoutMs})) is abandoned and retried, so a blackholed host cannot stall the session.

Outbox: exactly-once sends​

Every send gets a clientId (a UUID unless you pass one). It is written to the outbox before it goes on the wire, and removed when its ack arrives.

  • After a reconnect the client first waits for the replay to reach welcome.data.watermark (the head), at most 5 s. A turn the server stored before the connection dropped shows up there with its clientId; the send is settled from it and not resent. Then each remaining send goes on the wire once per connection, in order, with its original clientId, and at most maxInFlight at a time.
  • The server stores a clientId under ws:<user>:<clientId> at most once, and answers a resend with the original ack plus duplicate: true (section 7). Each message is therefore persisted exactly once, however often the connection drops.
  • Retryable errors (rate_limited, too_many_in_flight, unavailable, internal) are retried with the same clientId after retryAfterMs. Any other error rejects the send() promise, removes the send from the outbox and emits sendFailed.
  • Storage is pluggable (KeyValueStorage: getItem, setItem, removeItem, sync or async). The default is localStorage in browsers and memory in Node and React Native; for React Native, wrap AsyncStorage. The outbox and the resume state (conversation id, user id, watermark) are stored per conversation under <storageKey>:conversation:<id>. After a reload, resume() restores both, resends what is pending and reports the results as sent events (the old promises are gone). disconnect() keeps the outbox; discardOutbox() drops it.

Tokens​

The tokenProvider is called once at connect (reason: "initial"), before the token expires ("refresh") and after the server rejected one ("rejected"). It returns a token string or {token, expiresAt?, expiresIn?}. Without an expiry, the client reads the JWT's exp. The refresh runs refreshSkewMs (default 60 s, at most half the lifetime) before welcome.data.expiresAt. On the native endpoint the new token is sent as auth and the server confirms with tokenRefreshed, so the connection stays open (section 3.3). On the other transports it is used for the next connection.

createRefreshingTokenProvider({baseUrl, token}) renews a token you already have with POST /api/v1/converger/tokens/refresh. Renewal only works while that token is still valid. generateToken({baseUrl, secret, userId}) is for servers only.

Transports​

transportendpointnotes
nativeTransport() (default)wss://HOST/socket/converger/v1, subprotocol converger.v1the full protocol. The token goes in hello.token, not in the URL
phoenixTransport()/socket/converger/websocket, topic converger:conversation:IDactivities arrive as pre-v1 activitySets, are mapped to v1 frames, and get their seq from the set's watermark. Sends use the frame event (#24). No mid-session auth (#22). Needs a conversation: start() creates one over REST and joins with the conversation token
sseTransport()GET /api/v1/converger/conversations/ID/events + POST .../activitiesfor networks without WebSockets. Read with fetch, so the token travels in the Authorization header. Sends carry X-Idempotency-Key: ws:<user>:<clientId>, so a resend across transports is still stored once. No typing or read

The default is ["native", "sse"]: if the WebSocket never connects fallbackAfterAttempts times in a row (default 2), the client switches to SSE. Custom transports implement the Transport interface (connect, send, close, plus onOpen, onFrame and onClose callbacks). The client does not offer MessagePack (converger.v1+msgpack). Channel-scoped sockets belong to @converger/agent (#67).

REST helpers and errors​

client.rest (a RestClient) wraps the client API: createConversation, getConversation, closeConversation, reopenConversation, postActivity (with an idempotencyKey), listActivities and history (paged by has_more), allActivities, upload (multipart) and downloadAttachment.

A page's watermark is typed number | string | null: the integer seq from v3.0 servers, an opaque string from older ones (migration). history passes it back unchanged; parseWatermark turns either form into a seq.

Failed requests throw ConvergerApiError with status, code, message, details, requestId and retryAfterMs (from Retry-After). It understands the standard error envelope {"error": {"code", "message", "details", "request_id"}} (planned in #45) and the shapes the API returns today: {"error": "..."}, {"error": "conversation_closed", "detail": "..."}, {"errors": {"detail": "..."}} and changeset errors keyed by field. Protocol errors are ConvergerProtocolError (code, number, retryable, retryAfterMs, details, clientId).

Types​

The protocol frame types are hand-written from this specification and the JSON Schemas in priv/protocol/v1/. The REST types live in src/rest/openapi.ts in the shape openapi-typescript generates. When the OpenAPI document priv/static/openapi.json exists (#45), pnpm --filter @converger/client generate:types regenerates that file, and the public names in src/rest/types.ts stay the same. API reference: pnpm --filter @converger/client run docs (typedoc).

Tests and CI​

  • Unit tests (vitest) drive the client against an in-process mock of the native endpoint: handshake, replay, acks and clientId de-duplication, crashes before and after commit, close codes, retryAfterMs, gap fill, token refresh, storage. The Phoenix and SSE transports and the REST client have their own tests.
  • An end-to-end test (test/e2e) starts the real server with mix phx.server. It sends a message, kills the server process tree, sends while the server is down, restarts it, and posts two activities the client has not seen. It then checks that the client reconnects, receives both through its watermark, puts the unacknowledged message on the wire exactly once, and that the server stored every message once. It also runs a send and receive over the Phoenix and SSE transports.
  • CI: the client-ts workflow (.github/workflows/client-ts.yml) runs typecheck, unit tests, the build, typedoc and the e2e test against PostgreSQL. client-ts-publish.yml publishes to npm with provenance when a client-ts-v* tag is pushed, with npm trusted publishing: no token is stored.

Examples​