SDK Documentation

TypeScript/JavaScript SDK for Yappa RT — a production-grade, multi-tenant WebSocket messaging engine.

Introduction

Yappa SDK is the official client for Yappa RT, a horizontally scalable WebSocket server built with Rust (Axum/Tokio). It provides real-time messaging with complete tenant isolation, JWT authentication, and cross-node message delivery via Redis pub/sub.

Multi-Tenant
Complete isolation with configurable user limits per tenant.
Horizontally Scalable
Stateless servers with Redis pub/sub for cross-node routing.
JWT Auth
HS256 tokens strictly binding tenant_id and user_id claims.
Durable Storage
Kafka-backed persistence, or direct PostgreSQL in demo mode.

Key Features

  • Auto-reconnect with exponential backoff
  • Message deduplication via message_id
  • Heartbeat monitoring for dead connection detection
  • Send queue buffers messages while offline
  • Multi-device support (same user, multiple connections)

System Architecture

Messages flow through multiple layers to ensure delivery across nodes:

┌─────────────────────┐ │ Load Balancer │ └──────────┬──────────┘ │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ WS Node │ │ WS Node │ │ WS Node │ │ (Rust/ │ │ (Rust/ │ │ (Rust/ │ │ Axum) │ │ Axum) │ │ Axum) │ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ │ │ └─────────────────────┼─────────────────────┘ │ ┌──────────────────────────┼──────────────────────────┐ ▼ ▼ ▼ ┌────────┐ ┌────────────┐ ┌────────────┐ │ Redis │ │ Kafka │ │ PostgreSQL │ │Pub/Sub │ │ (optional) │ │ │ └────────┘ └────────────┘ └────────────┘

Components

ComponentTechPurpose
yappa-rtRust (Axum/Tokio)WebSocket server, message routing, tenant limits
yappa-authNode.js (Express)User authentication, JWT issuance
yappa-sdkTypeScriptBrowser/Node.js client SDK
RedisRedis 7Pub/sub for cross-node messaging, tenant limits
KafkaKafka 3.7Durable message streaming (optional)
PostgreSQLPostgres 16User storage, message persistence

Installation

Install the SDK via your preferred package manager:

# Using npm
npm install @yappa-rs/yappa-sdk

# Using yarn
yarn add @yappa-rs/yappa-sdk

# Using pnpm
pnpm add @yappa-rs/yappa-sdk

Peer Dependencies (Node.js only)

npm install ws
Browser: The SDK uses the native WebSocket API. No polyfills needed.

Quick Start

import { RealtimeClient } from '@yappa-rs/yappa-sdk';

// 1. Initialize with your JWT token
const client = new RealtimeClient({
  url: 'wss://your-server.com/ws',
  token: 'eyJhbGciOiJIUzI1NiIs...',
  authMode: 'query' // Required for browsers
});

// 2. Listen for messages
client.on('message', (msg) => {
  console.log(msg.channel_type + ': ' + msg.sender_id + ' -> ' + msg.payload.text);
});

// 3. Connect
await client.connect();

// 4. Send a direct message
client.sendDM('bob_123', 'Hello Bob!');

Connection Flow

When you call client.connect(), the following sequence occurs:

1
SDK: WebSocket Upgrade Request
Opens WebSocket to /ws endpoint with JWT in the Authorization header or query string.
2
Server: JWT Validation
Validates the HS256 signature, extracts tenant_id and user_id from claims.
3
Server: Tenant Limit Check
Atomically checks if the tenant is under MAX_USERS_PER_TENANT via a Redis Lua script.
4
Server: Connection Registration
Stores the connection in ConnectionRegistry, keyed by (tenant_id, user_id, connection_id).
5
SDK: 'connected' Event Fired
SDK emits the connected event and drains any queued messages.

Connection Identity

Every WebSocket connection is uniquely identified by the tuple (tenant_id, user_id):

  • Identity is extracted from the JWT and is immutable for the socket’s lifetime
  • One user can have multiple concurrent connections (multi-device)
  • Each connection gets a unique connection_id (UUID v4)

Connection States

StateDescription
disconnectedNo active connection
connectingWebSocket upgrade in progress, awaiting auth
connectedConnection active, ready to send/receive
reconnectingConnection lost, SDK attempting reconnect
Auth Failure: If the JWT is invalid or the tenant limit is reached, the server returns HTTP 401 (unauthorized) or HTTP 429 (too many requests). The SDK emits an error event.

Direct Messages

Send a 1-to-1 message using the sendDM method:

client.sendDM('bob_123', 'Hello Bob!');

What Happens on the Server

1
SDK → Server
Sends JSON: {"channel_type":"DM","user_id":"bob_123","content":"Hello Bob!"}
2
Server: Message Creation
Generates message_id (UUID), conversation_id (SHA-256 hash of sorted user IDs), and a timestamp.
3
Server: Redis Publish
Publishes to the user:{tenant_id}:{recipient_user_id} channel.
4
All Nodes: Receive
Every WS node subscribed to Redis receives the message.
5
Recipient’s Node: Deliver
The node holding the recipient’s connection(s) sends via WebSocket.
6
Server: Persist
Message is stored to Kafka (production) or PostgreSQL (demo mode).

Server Response Format

{
  "type": "chat",
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "tenant_id": "tenant_abc",
  "channel_type": "DM",
  "channel_id": "bob_123",
  "sender_id": "alice",
  "timestamp": 1700000000,
  "conversation_id": "uuid-derived-from-sha256",
  "payload": {
    "text": "Hello Bob!",
    "meta": {}
  }
}

Conversation ID Generation

The conversation_id is deterministically generated from participant IDs:

// Server pseudocode
participants = [sender_id, recipient_id].sort()
combined = "alice:bob"
hash = SHA256(combined)
conversation_id = UUID.from_bytes(hash[0:16])
Message Size: Maximum payload is 64KB. Messages exceeding this are rejected with an error sent back to the sender.

Groups

Group operations allow creating, joining, leaving, and sending messages to channels. Groups are scoped to a tenant and persisted to PostgreSQL for durability.

createGroup(groupId)

Creates a new group and automatically joins the creator.

client.createGroup('team-alpha');

joinGroup(groupId)

Subscribes the current user to a group’s message stream. The group must already exist.

client.joinGroup('team-alpha');

sendGroupMessage(groupId, content)

client.sendGroupMessage('team-alpha', 'Hi team!');
Must Join First: Sending to a group without joining returns an error message: "You must join the group before sending messages"

leaveGroup(groupId) / deleteGroup(groupId)

client.leaveGroup('team-alpha');
client.deleteGroup('team-alpha');
  • leaveGroup — removes you from the in-memory member set; no database change.
  • deleteGroup — removes the group from the in-memory registry; database records persist for history.
Note: Group membership is persisted to PostgreSQL. The group_members table has a foreign key to groups(conversation_id), so when a user joins, the server first looks up the group by tenant_id + name to resolve the conversation_id before inserting the membership row.

Configuration Options

Pass these options when initializing RealtimeClient:

OptionTypeDefaultDescription
url requiredstringWebSocket endpoint (ws:// or wss://)
token requiredstringHS256 JWT with tenant_id and user_id claims
authMode'header' | 'query''header'Use 'query' for browsers (no custom header support)
heartbeatTimeoutnumber35000Ms without activity before assuming the connection is dead
reconnectbooleantrueAuto-reconnect on disconnect
maxReconnectAttemptsnumberInfinityMax reconnection tries before reconnect_failed
reconnectBaseDelaynumber1000Initial delay (ms), doubles each attempt
reconnectMaxDelaynumber30000Maximum reconnection delay cap
dedupbooleantrueDeduplicate messages by message_id
dedupTTLnumber60000How long to remember seen message IDs (ms)
maxQueueSizenumber1000Max messages buffered while offline
logLevel'debug' | 'info' | 'warn' | 'error' | 'silent''warn'Console logging verbosity
refreshUrlstringURL to POST for automatic token refresh

Authentication

JWT Requirements

Your JWT must be signed with HS256 and contain the following claims:

ClaimRequiredDescription
tenant_idrequiredOrganization/tenant identifier
user_idrequiredUser identifier within the tenant
exprequiredExpiration timestamp
issoptionalIssuer (validated if present)
audoptionalAudience (validated if present)

Auth Modes

Header Mode Default

const client = new RealtimeClient({
  url: 'wss://server.com/ws',
  token: 'eyJ...',
  authMode: 'header' // Sends: Authorization: Bearer eyJ...
});

Query Mode Recommended for Browsers

const client = new RealtimeClient({
  url: 'wss://server.com/ws',
  token: 'eyJ...',
  authMode: 'query' // Sends: wss://server.com/ws?token=eyJ...
});

Token Refresh

Configure automatic token refresh — the SDK refreshes every 4 minutes:

const client = new RealtimeClient({
  url: 'wss://server.com/ws',
  token: 'eyJ...',
  refreshUrl: 'https://auth.server.com/api/refresh'
});

The SDK POSTs to refreshUrl with credentials (cookies), expecting {"access_token": "..."} in the response.

Reconnection Strategy

The SDK automatically reconnects with exponential backoff when the connection drops, with jitter to prevent thundering herds. The maximum backoff is capped at 30 seconds.

Server-Side: Connection Cleanup

When a connection drops, the server’s ConnectionGuard (Drop trait) ensures:

  • Connection is removed from ConnectionRegistry
  • The user’s slot is released via TenantLimiter.release()
  • Group memberships are preserved and reattached on reconnect

SDK-Side: Reconnection Flow

client.on('reconnecting', (attempt) => {
  console.log('Reconnect attempt ' + attempt);
});

client.on('reconnected', () => {
  console.log('Back online!');
  // Queued messages are automatically flushed
});
1
Connection Lost
WebSocket closes, or heartbeat timeout (35s of no activity)
2
SDK: State → ‘reconnecting’
Emits a reconnecting event with the attempt number
3
Wait (Exponential Backoff)
min(baseDelay * 2^(attempt-1), maxDelay) — default: 1s → 2s → 4s → … → 30s
4
Attempt Reconnect
New WebSocket upgrade with the same token
5
On Success
State → ‘connected’, emits reconnected, drains the queue, rejoins groups

Message Queue

Messages sent while offline are queued (up to maxQueueSize) and flushed on reconnect:

// These are queued if not connected
client.sendDM('bob', 'Message 1');
client.sendDM('bob', 'Message 2');

// On reconnect: both are sent in order

RealtimeClient

The primary class for interacting with the Yappa realtime server. It extends standard EventTarget/EventEmitter paradigms.

Methods

MethodParametersDescription
connect()Establishes the WebSocket connection. Returns a Promise.
disconnect()Gracefully closes the connection and cleans up listeners.
sendDM(userId, content)userId: string, content: stringSends a direct message (1–64,000 chars).
sendGroupMessage(groupId, content)groupId: string, content: stringSends a group message (must join first).
joinGroup(groupId)groupId: stringJoins a group to receive its messages.
leaveGroup(groupId)groupId: stringLeaves a group.
createGroup(groupId)groupId: stringCreates and auto-joins a new group.
deleteGroup(groupId)groupId: stringDeletes a group.
updateToken(token)token: stringUpdates the authentication token on an existing client.
on(event, handler)event: string, handler: FunctionSubscribes to an event. Returns an unsubscribe function.

Properties

PropertyTypeDescription
stateConnectionStateCurrent connection state (read-only)

Events

Listen to lifecycle and message events using client.on(event, handler).

EventPayloadWhen Fired
connectedvoidWebSocket connection established and registered
disconnectedreason: stringConnection closed (e.g. "manual", "transport closed")
reconnectingattempt: numberStarting a reconnection attempt
reconnectedvoidSuccessfully reconnected
reconnect_failedvoidMax reconnect attempts exhausted
messageServerMessageAny incoming chat message or group lifecycle event
dmServerMessageDirect message (channel_type: "DM")
group_messageServerMessageGroup message (channel_type: "GROUP")
group_joinServerMessageUser joined a group (type: "group_join")
errorRealtimeErrorSocket, auth, or connection error

Usage

const unsubscribe = client.on('message', (msg) => {
  console.log(msg);
});

// Later: stop listening
unsubscribe();

Type Definitions

ServerMessage

interface ServerMessage {
  type: string;              // "chat" or "group_join"
  message_id: string;        // UUID v4
  tenant_id: string;
  channel_type: ChannelType;
  channel_id: string;         // recipient user_id or group_id
  sender_id: string;
  timestamp: number;         // Unix seconds
  conversation_id: string;   // UUID (DM: derived from participants, Group: group_id)
  payload: {
    text: string;
    meta: Record<string, unknown>;
  };
}

ChannelType

type ChannelType = "DM" | "GROUP" | "COMMUNITY";

ConnectionState

type ConnectionState =
  | "disconnected"
  | "connecting"
  | "connected"
  | "reconnecting";

LogLevel

type LogLevel = "debug" | "info" | "warn" | "error" | "silent";

Error Handling

Error Classes

class RealtimeError extends Error {
  name = "RealtimeError";
}

class ConnectionError extends RealtimeError {
  name = "ConnectionError";
  cause: unknown;
}

Common Errors

ScenarioErrorHandling
Invalid JWTHTTP 401Get a new token from the auth service
Tenant at capacityHTTP 429Wait for other users to disconnect
Message too large"Payload too large"Split the message or reduce its size
Not in group"You must join the group..."Call joinGroup() first
Token refresh failedRealtimeErrorRe-authenticate with yappa-auth

WebSocket Protocol

Client → Server Messages

Direct Message

{
  "channel_type": "DM",
  "user_id": "recipient_user_id",
  "content": "Hello!"
}

Group Message

{
  "channel_type": "GROUP",
  "user_id": "group_id",
  "content": "Hello team!"
}

Group Operations

// Join
{"msg_type":"JOIN","tenant_id":"t1","group_id":"g1","user_id":"alice"}

// Leave
{"msg_type":"LEAVE","tenant_id":"t1","group_id":"g1","user_id":"alice"}

// Create
{"msg_type":"CREATE","tenant_id":"t1","group_id":"new_group","user_id":"alice"}

// Delete
{"msg_type":"DELETE","tenant_id":"t1","group_id":"g1","user_id":"alice"}

Server Heartbeat

The server sends Ping frames every 15 seconds. The client should respond with Pong. If there’s no activity for 30 seconds, the server closes the connection.

Tenant Limits

The server enforces MAX_USERS_PER_TENANT (default: 10) using atomic Redis Lua scripts:

1
Connection Attempt
User connects with a JWT containing tenant_id and user_id
2
Atomic Check
Lua script checks SCARD online:{tenant_id} against the limit
3
Existing User?
If the user is already online: allow (multi-device). Otherwise: check the limit.
4
Under Limit: Allow
SADD online:{tenant_id} user_id and INCR conncount:{tenant_id}:{user_id}
5
At Limit: Reject
Returns HTTP 429 Too Many Requests
Multi-Device Support: The same user can connect multiple times (different devices) without hitting the limit. The limit counts distinct users, not connections.

Redis Keys

KeyTypePurpose
online:{tenant_id}SETDistinct online user_ids
conncount:{tenant_id}:{user_id}STRING (counter)Connection reference count per user