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:
- Managed infrastructure - No need to deploy and maintain WebSocket servers
- Built-in presence - Track who's online without custom implementation
- Token authentication - Secure client connections via server-issued tokens
- Channel-based messaging - Natural fit for session-based communication
- Horizontal scaling - Handles thousands of concurrent connections automatically
Environment Configuration
The integration requires one environment variable:
# API key from Ably dashboard (has both publish and subscribe capabilities)
ABLY_API_KEY=your-api-key-hereObtain 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:
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:
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:
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:
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:
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:
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:
<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:
<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.tsTests 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:
- Create account at ably.com
- Create an app in the Ably dashboard
- Copy API key with full capabilities
- Add to environment as
ABLY_API_KEY - Wrap authenticated sections with
AblyProvider
Related Documentation
- Queue Feature - Uses real-time queue updates
- Architecture Overview - System design context
- Clerk Authentication - User identity for token auth