Skip to content

Real-time Chat

Build a scalable real-time chat application with WebSocket support using Cloudflare Durable Objects.

  • Real-time messaging with WebSockets
  • Multiple chat rooms
  • User presence indicators
  • Message history persistence
  • Typing indicators
  • Directoryapp/
    • page.tsx # Lobby
    • layout.tsx # Root layout
    • Directoryrooms/
      • page.tsx # Room list
      • Directorynew/
        • page.tsx # Create room
      • Directory[id]/
        • page.tsx # Chat room UI
    • Directoryapi/
      • Directoryrooms/
        • route.ts
        • Directory[id]/
          • route.ts
          • Directorywebsocket/
            • route.ts # WebSocket upgrade
  • Directoryworkers/
    • chat-room.ts # Durable Object
// workers/chat-room.ts
import { DurableObject } from 'cloudflare:workers';
interface User {
id: string;
name: string;
color: string;
}
interface Message {
id: string;
userId: string;
userName: string;
content: string;
timestamp: number;
}
export class ChatRoom extends DurableObject {
private sessions: Map<WebSocket, User> = new Map();
private messages: Message[] = [];
private typingUsers: Map<string, number> = new Map();
constructor(state: DurableObjectState, env: Env) {
super(state, env);
this.loadMessages();
}
private async loadMessages() {
const stored = await this.ctx.storage.get<Message[]>('messages');
if (stored) {
this.messages = stored;
}
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === '/websocket') {
return this.handleWebSocket(request);
}
if (url.pathname === '/messages') {
return new Response(JSON.stringify(this.messages.slice(-100)), {
headers: { 'Content-Type': 'application/json' },
});
}
if (url.pathname === '/users') {
const users = Array.from(this.sessions.values());
return new Response(JSON.stringify(users), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Not found', { status: 404 });
}
private async handleWebSocket(request: Request): Promise<Response> {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 400 });
}
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
const userName = url.searchParams.get('userName') ?? 'Anonymous';
if (!userId) {
return new Response('userId required', { status: 400 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
const user: User = {
id: userId,
name: userName,
color: this.generateColor(userId),
};
this.ctx.acceptWebSocket(server);
this.sessions.set(server, user);
// Send initial state
server.send(JSON.stringify({
type: 'init',
users: Array.from(this.sessions.values()),
messages: this.messages.slice(-50),
}));
// Notify others of join
this.broadcast({
type: 'user_joined',
user,
}, server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
const user = this.sessions.get(ws);
if (!user) return;
const data = JSON.parse(message as string);
switch (data.type) {
case 'message':
await this.handleMessage(user, data.content);
break;
case 'typing':
this.handleTyping(user, data.isTyping);
break;
}
}
async webSocketClose(ws: WebSocket) {
const user = this.sessions.get(ws);
if (!user) return;
this.sessions.delete(ws);
this.typingUsers.delete(user.id);
this.broadcast({
type: 'user_left',
userId: user.id,
});
}
private async handleMessage(user: User, content: string) {
const message: Message = {
id: crypto.randomUUID(),
userId: user.id,
userName: user.name,
content,
timestamp: Date.now(),
};
this.messages.push(message);
// Keep only last 1000 messages
if (this.messages.length > 1000) {
this.messages = this.messages.slice(-1000);
}
// Persist messages
await this.ctx.storage.put('messages', this.messages);
// Clear typing indicator
this.typingUsers.delete(user.id);
// Broadcast message
this.broadcast({ type: 'message', message });
}
private handleTyping(user: User, isTyping: boolean) {
if (isTyping) {
this.typingUsers.set(user.id, Date.now());
} else {
this.typingUsers.delete(user.id);
}
this.broadcast({
type: 'typing',
typingUsers: Array.from(this.typingUsers.keys()),
});
}
private broadcast(data: unknown, exclude?: WebSocket) {
const message = JSON.stringify(data);
for (const ws of this.sessions.keys()) {
if (ws !== exclude && ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
}
}
private generateColor(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
}
// app/api/rooms/[id]/websocket/route.ts
import type { CloudwerkHandlerContext } from '@cloudwerk/core';
export async function GET(request: Request, { params, context }: CloudwerkHandlerContext) {
// Get the Durable Object for this room
const roomId = context.env.CHAT_ROOM.idFromName(params.id);
const room = context.env.CHAT_ROOM.get(roomId);
// Get user info from auth
const user = await context.auth.getUser();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Forward WebSocket request to Durable Object
const url = new URL(request.url);
url.pathname = '/websocket';
url.searchParams.set('userId', user.id);
url.searchParams.set('userName', user.name);
return room.fetch(new Request(url.toString(), request));
}

The chat room page loads the WebSocket configuration and renders the chat interface. Client-side JavaScript handles real-time updates using safe DOM manipulation methods.

// app/rooms/[id]/page.tsx
import type { PageProps, LoaderArgs } from '@cloudwerk/core';
import { NotFoundError } from '@cloudwerk/core';
export async function loader({ params, context }: LoaderArgs) {
const room = await context.db
.selectFrom('rooms')
.where('id', '=', params.id)
.executeTakeFirst();
if (!room) {
throw new NotFoundError('Room not found');
}
const user = await context.auth.requireUser();
return {
room,
user,
wsUrl: `wss://${context.env.APP_DOMAIN}/api/rooms/${params.id}/websocket`,
};
}
export default function ChatRoomPage({ room, user, wsUrl }: PageProps & LoaderData) {
return (
<div className="chat-container">
<header className="chat-header">
<h1>{room.name}</h1>
<div id="users-list"></div>
</header>
<div className="chat-messages" id="messages">
{/* Messages rendered safely by client-side JavaScript */}
</div>
<div className="typing-indicator" id="typing"></div>
<form className="chat-input" id="chat-form">
<input
type="text"
id="message-input"
placeholder="Type a message..."
autoComplete="off"
/>
<button type="submit">Send</button>
</form>
{/* Config passed via data attributes for security */}
<div
id="chat-config"
data-ws-url={wsUrl}
data-user-id={user.id}
data-user-name={user.name}
style={{ display: 'none' }}
/>
<script src="/js/chat.js"></script>
</div>
);
}

The client-side JavaScript uses safe DOM methods (textContent, createElement) instead of innerHTML to prevent XSS attacks:

// public/js/chat.js - Key rendering functions use safe DOM manipulation
function createMessageElement(message, isOwn) {
const div = document.createElement('div');
div.className = 'message' + (isOwn ? ' own' : '');
const author = document.createElement('span');
author.className = 'author';
author.textContent = message.userName; // Safe: uses textContent
const content = document.createElement('span');
content.className = 'content';
content.textContent = message.content; // Safe: uses textContent
const time = document.createElement('span');
time.className = 'time';
time.textContent = formatTime(message.timestamp);
div.appendChild(author);
div.appendChild(content);
div.appendChild(time);
return div;
}
function appendMessage(message) {
const container = document.getElementById('messages');
const isOwn = message.userId === config.userId;
container.appendChild(createMessageElement(message, isOwn));
container.scrollTop = container.scrollHeight;
}
# wrangler.toml
[durable_objects]
bindings = [
{ name = "CHAT_ROOM", class_name = "ChatRoom" }
]
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]
[vars]
APP_DOMAIN = "chat.example.com"
  • Add message reactions
  • Implement private messaging
  • Add file/image sharing
  • Implement message search
  • Add room moderation tools