Real-time Chat
Build a scalable real-time chat application with WebSocket support using Cloudflare Durable Objects.
Features
Section titled “Features”- Real-time messaging with WebSockets
- Multiple chat rooms
- User presence indicators
- Message history persistence
- Typing indicators
Project Structure
Section titled “Project Structure”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
Durable Object: Chat Room
Section titled “Durable Object: Chat Room”// workers/chat-room.tsimport { 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%)`; }}WebSocket Route
Section titled “WebSocket Route”// app/api/rooms/[id]/websocket/route.tsimport 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));}Chat Room Page
Section titled “Chat Room Page”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.tsximport 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> );}Client-Side Implementation
Section titled “Client-Side Implementation”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;}Configuration
Section titled “Configuration”# wrangler.toml[durable_objects]bindings = [ { name = "CHAT_ROOM", class_name = "ChatRoom" }]
[[migrations]]tag = "v1"new_classes = ["ChatRoom"]
[vars]APP_DOMAIN = "chat.example.com"Next Steps
Section titled “Next Steps”- Add message reactions
- Implement private messaging
- Add file/image sharing
- Implement message search
- Add room moderation tools
Related Examples
Section titled “Related Examples”- Blog Example - Content with comments
- API Backend - RESTful API patterns
- SaaS Starter - Team collaboration features