SavvySolve Docs

Ably Real-Time Integration

WebSocket-based real-time communication for chat, presence, and queue updates using Ably

Ably Real-Time Integration

SavvySolve uses Ably for real-time WebSocket communication between customers and solvers. When a support session begins, both parties connect to shared channels for instant messaging, typing indicators, and presence tracking. Solvers also receive live queue updates when new tickets arrive.

Why Ably?

The architecture originally planned for Cloudflare Durable Objects, but Ably offers several advantages:

  1. Managed infrastructure - No need to deploy and maintain WebSocket servers
  2. Built-in presence - Track who's online without custom implementation
  3. Token authentication - Secure client connections via server-issued tokens
  4. Channel-based messaging - Natural fit for session-based communication
  5. Horizontal scaling - Handles thousands of concurrent connections automatically

Environment Configuration

The integration requires one environment variable:

.env.local
# API key from Ably dashboard (has both publish and subscribe capabilities)
ABLY_API_KEY=your-api-key-here

Obtain this from the Ably Dashboard under your app's API Keys section.

Architecture Overview

The Ably integration consists of three layers:

Server-Side Client

The server uses Ably's REST client for token generation and server-to-client publishing:

lib/ably/server.ts
import Ably from "ably";

let ablyClient: Ably.Rest | null = null;

export function getAblyClient(): Ably.Rest {
  if (!ablyClient) {
    const apiKey = process.env.ABLY_API_KEY;
    if (!apiKey) {
      throw new Error("ABLY_API_KEY environment variable is not set");
    }
    ablyClient = new Ably.Rest({ key: apiKey });
  }
  return ablyClient;
}

The client is a singleton to avoid creating multiple connections. Server-side publishing is useful for broadcasting events that originate from backend processes, such as ticket status changes.

Channel Naming Conventions

Channels follow a consistent naming pattern for easy identification:

lib/ably/server.ts
export const CHANNEL_NAMES = {
  sessionChat: (sessionId: string) => `session:${sessionId}:chat`,
  sessionPresence: (sessionId: string) => `session:${sessionId}:presence`,
  queue: () => "queue:updates",
} as const;

Each session gets its own chat and presence channels, ensuring messages stay isolated. The queue channel is shared among all solvers for live ticket updates.

Message Event Types

Standardized event names ensure consistency across the codebase:

lib/ably/server.ts
export const MESSAGE_EVENTS = {
  CHAT_MESSAGE: "chat:message",
  QUEUE_UPDATE: "queue:update",
  QUEUE_CLAIMED: "queue:claimed",
  SESSION_STATUS: "session:status",
  TYPING_START: "typing:start",
  TYPING_STOP: "typing:stop",
} as const;

Token Authentication

Clients authenticate via API routes that issue time-limited tokens. This prevents exposing the API key to browsers.

Customer Authentication

Regular users get tokens with limited capabilities:

app/api/ably/auth/route.ts
export async function GET() {
  const { userId } = await auth();

  if (!userId) {
    return NextResponse.json(
      { error: "Unauthorized - Please sign in" },
      { status: 401 }
    );
  }

  const ably = getAblyClient();
  const tokenRequest = await ably.auth.createTokenRequest({
    clientId: userId,
    ttl: 3600 * 1000, // 1 hour
    capability: {
      "session:*": ["publish", "subscribe", "presence"],
      "queue:*": ["subscribe"], // Can only listen to queue
    },
  });

  return NextResponse.json(tokenRequest);
}

Solver Authentication

Solvers get elevated permissions to publish queue updates:

app/api/ably/auth-solver/route.ts
export async function GET() {
  // ... authentication and solver verification ...

  const tokenRequest = await ably.auth.createTokenRequest({
    clientId: clerkId,
    ttl: 3600 * 1000,
    capability: {
      "session:*": ["publish", "subscribe", "presence"],
      "queue:*": ["publish", "subscribe"], // Can publish queue updates
    },
  });

  return NextResponse.json(tokenRequest);
}

React Hooks

AblyProvider

Wrap authenticated sections with the provider to enable real-time features:

app/dashboard/layout.tsx
import { AblyProvider } from "@/hooks";

export default function DashboardLayout({ children }) {
  return (
    <AblyProvider authUrl="/api/ably/auth">
      {children}
    </AblyProvider>
  );
}

The provider creates a singleton Ably Realtime client and manages connection state.

useChannel

Subscribe to channel messages with automatic cleanup:

import { useChannel } from "@/hooks";

function MyComponent() {
  const { channel, publish } = useChannel(
    "session:123:chat",
    "chat:message",
    (message) => {
      console.log("Received:", message.data);
    }
  );

  const sendMessage = async () => {
    await publish("chat:message", { text: "Hello!" });
  };
}

usePresence

Track who's online in a channel:

import { usePresence } from "@/hooks";

function SessionMembers({ sessionId }) {
  const { members, isPresent } = usePresence(
    `session:${sessionId}:presence`,
    {
      enterData: {
        oderId: oderId,
        name: "John Doe",
        role: "customer",
        joinedAt: new Date().toISOString(),
      },
    }
  );

  return (
    <div>
      {members.map((member) => (
        <span key={member.clientId}>{member.data.name}</span>
      ))}
    </div>
  );
}

useChat

High-level hook for session chat with typing indicators:

import { useChat } from "@/hooks";

function ChatComponent({ sessionId, user }) {
  const { messages, sendMessage, typingUsers, isConnected } = useChat({
    sessionId,
    userId: user.id,
    userName: user.name,
    userRole: "customer",
  });

  return (
    <div>
      {messages.map((msg) => (
        <div key={msg.id}>{msg.content}</div>
      ))}
      {typingUsers.length > 0 && <span>Someone is typing...</span>}
    </div>
  );
}

useQueue

Subscribe to queue updates for solvers:

import { useQueue } from "@/hooks";

function SolverDashboard() {
  const { refetch } = trpc.tickets.listPending.useQuery();

  useQueue({
    onTicketCreated: () => refetch(),
    onTicketClaimed: () => refetch(),
  });
}

UI Components

ChatWindow

A complete chat interface for support sessions:

components/chat/ChatWindow.tsx
<ChatWindow
  sessionId="session_123"
  userId={user.id}
  userName={user.name}
  userRole="customer"
/>

Features:

  • Real-time message delivery
  • Typing indicators
  • Presence display (shows who's online)
  • Connection status indicator
  • Auto-scroll to latest messages

RealTimeQueueList

Queue list with live updates:

components/queue/RealTimeQueueList.tsx
<RealTimeQueueList
  tickets={tickets}
  onClaim={handleClaim}
  onRefetch={refetch}
/>

Shows a connection indicator and automatically refetches when queue events occur.

Message Payloads

Chat Message

interface ChatMessage {
  id: string;
  sessionId: string;
  senderId: string;
  senderName: string;
  senderRole: "customer" | "solver";
  content: string;
  timestamp: string;
}

Queue Update

interface QueueUpdate {
  ticketId: string;
  action: "created" | "claimed" | "completed" | "cancelled";
  timestamp: string;
}

Presence Data

interface PresenceData {
  oderId: string;
  name: string;
  role: "customer" | "solver";
  joinedAt: string;
}

Testing

The Ably integration includes unit tests for the server module and auth routes:

bun run test -- --run lib/ably/server.test.ts app/api/ably/auth/route.test.ts

Tests mock the Ably SDK to avoid hitting the real API. For integration testing, Ably provides a sandbox environment that can be configured via environment variables.

Ably Setup Checklist

To configure Ably for SavvySolve:

  1. Create account at ably.com
  2. Create an app in the Ably dashboard
  3. Copy API key with full capabilities
  4. Add to environment as ABLY_API_KEY
  5. Wrap authenticated sections with AblyProvider

On this page