12 KiB
Example: Video Chat Room Using the Pluggable Architecture
This example shows how to create a simple video chat room application using the reusable Room/WebRTC infrastructure.
Step 1: Define Your Metadata Types
// chat-app/types.ts
import type { Session, Room } from '../server/routes/room/types';
/**
* Chat-specific session metadata
* Extends base Session with chat-specific data
*/
export interface ChatSessionMetadata {
status: 'online' | 'away' | 'busy';
customStatus?: string;
joinedAt: number;
messageCount: number;
}
/**
* Chat-specific room metadata
* Extends base Room with chat-specific data
*/
export interface ChatRoomMetadata {
topic: string;
messages: ChatMessage[];
pinnedMessages: string[];
createdBy: string;
maxParticipants: number;
}
export interface ChatMessage {
id: string;
senderId: string;
senderName: string;
text: string;
timestamp: number;
}
// Type aliases for convenience
export type ChatSession = Session<ChatSessionMetadata>;
export type ChatRoom = Room<ChatRoomMetadata>;
// Extended participant with chat-specific fields
export interface ChatParticipant {
session_id: string;
name: string | null;
live: boolean;
has_media: boolean;
status: 'online' | 'away' | 'busy';
customStatus?: string;
messageCount: number;
}
Step 2: Create Room Helpers
// chat-app/helpers.ts
import { getParticipants as getBaseParticipants } from '../server/routes/room/helpers';
import type { ChatRoom, ChatParticipant } from './types';
/**
* Get participants with chat-specific data
*/
export function getChatParticipants(room: ChatRoom): ChatParticipant[] {
// Get base participant data from reusable helper
const baseParticipants = getBaseParticipants(room.sessions);
// Extend with chat-specific metadata
return baseParticipants.map(p => {
const session = room.sessions[p.session_id];
const metadata = session.metadata;
return {
...p,
status: metadata?.status || 'online',
customStatus: metadata?.customStatus,
messageCount: metadata?.messageCount || 0,
};
});
}
/**
* Create a new chat room
*/
export function createChatRoom(roomId: string, roomName: string, creatorId: string): ChatRoom {
return {
id: roomId,
name: roomName,
sessions: {},
state: 'active',
created: Date.now(),
lastActivity: Date.now(),
metadata: {
topic: 'General Chat',
messages: [],
pinnedMessages: [],
createdBy: creatorId,
maxParticipants: 50,
},
};
}
/**
* Add a message to the chat room
*/
export function addMessage(room: ChatRoom, senderId: string, text: string): void {
const session = room.sessions[senderId];
if (!session) return;
const message = {
id: `${Date.now()}-${senderId}`,
senderId,
senderName: session.name || 'Anonymous',
text,
timestamp: Date.now(),
};
room.metadata.messages.push(message);
// Update sender's message count
if (session.metadata) {
session.metadata.messageCount++;
}
}
Step 3: Server WebSocket Handler
// chat-app/server.ts
import express from 'express';
import expressWs from 'express-ws';
import type { ChatRoom, ChatSession } from './types';
import { createBaseSession } from '../server/routes/room/helpers';
import { getChatParticipants, createChatRoom, addMessage } from './helpers';
const app = expressWs(express()).app;
const rooms: Record<string, ChatRoom> = {};
// WebSocket endpoint for chat room
app.ws('/chat/:roomId', async (ws, req) => {
const { roomId } = req.params;
const sessionId = req.cookies?.session || generateSessionId();
// Get or create room
let room = rooms[roomId];
if (!room) {
room = createChatRoom(roomId, `Chat Room ${roomId}`, sessionId);
rooms[roomId] = room;
}
// Create or get session
let session: ChatSession = room.sessions[sessionId];
if (!session) {
session = {
...createBaseSession(sessionId),
metadata: {
status: 'online',
joinedAt: Date.now(),
messageCount: 0,
},
};
room.sessions[sessionId] = session;
}
// Attach WebSocket
session.ws = ws;
session.live = true;
session.connected = true;
// Notify all participants
broadcastUpdate(room, {
type: 'participants',
participants: getChatParticipants(room),
});
// Handle incoming messages
ws.on('message', (msg: string) => {
const data = JSON.parse(msg);
switch (data.type) {
case 'set-name':
session.name = data.name;
broadcastUpdate(room, {
type: 'participants',
participants: getChatParticipants(room),
});
break;
case 'set-status':
if (session.metadata) {
session.metadata.status = data.status;
session.metadata.customStatus = data.customStatus;
}
broadcastUpdate(room, {
type: 'participants',
participants: getChatParticipants(room),
});
break;
case 'send-message':
addMessage(room, sessionId, data.text);
broadcastUpdate(room, {
type: 'new-message',
messages: room.metadata.messages.slice(-50), // Last 50 messages
});
break;
case 'get-messages':
ws.send(JSON.stringify({
type: 'messages',
messages: room.metadata.messages.slice(-50),
}));
break;
case 'get-participants':
ws.send(JSON.stringify({
type: 'participants',
participants: getChatParticipants(room),
}));
break;
// WebRTC signaling messages (handled by reusable code)
case 'join':
case 'part':
case 'relayICECandidate':
case 'relaySessionDescription':
// Use the same WebRTC handlers as the game
// (This code is application-agnostic)
handleWebRTCMessage(room, session, data);
break;
}
});
ws.on('close', () => {
session.live = false;
session.connected = false;
// Clean up after timeout
setTimeout(() => {
if (!session.live) {
delete room.sessions[sessionId];
broadcastUpdate(room, {
type: 'participants',
participants: getChatParticipants(room),
});
}
}, 60000); // 1 minute grace period
});
});
function broadcastUpdate(room: ChatRoom, update: any) {
const message = JSON.stringify(update);
Object.values(room.sessions).forEach(session => {
if (session.ws && session.connected) {
session.ws.send(message);
}
});
}
// WebRTC handlers (reusable from game implementation)
function handleWebRTCMessage(room: ChatRoom, session: ChatSession, data: any) {
// Same join/part/ICE/SDP handling as in games.ts
// This code doesn't care about chat vs. game - it's pure WebRTC
}
Step 4: Client Component
// chat-app/client/ChatRoom.tsx
import React, { useState, useEffect } from 'react';
import { MediaAgent, MediaControl, Peer } from './MediaControl';
import useWebSocket from 'react-use-websocket';
import type { ChatParticipant, ChatMessage } from './types';
interface ChatRoomProps {
roomId: string;
session: { id: string; name: string | null; has_media: boolean };
}
export function ChatRoom({ roomId, session }: ChatRoomProps) {
const [participants, setParticipants] = useState<ChatParticipant[]>([]);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [messageText, setMessageText] = useState('');
const [peers, setPeers] = useState<Record<string, Peer>>({});
const socketUrl = `ws://localhost:3000/chat/${roomId}`;
const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(socketUrl);
// Handle WebSocket messages
useEffect(() => {
if (!lastJsonMessage) return;
const data: any = lastJsonMessage;
switch (data.type) {
case 'participants':
setParticipants(data.participants);
break;
case 'messages':
case 'new-message':
setMessages(data.messages);
break;
}
}, [lastJsonMessage]);
// Send message
const handleSendMessage = () => {
if (!messageText.trim()) return;
sendJsonMessage({
type: 'send-message',
text: messageText,
});
setMessageText('');
};
return (
<div className="chat-room">
{/* MediaAgent handles WebRTC (reusable component) */}
<MediaAgent
socketUrl={socketUrl}
session={session}
peers={peers}
setPeers={setPeers}
/>
<div className="layout">
{/* Participant list with video feeds */}
<aside className="participants">
<h3>Participants ({participants.length})</h3>
{participants.map(p => (
<div key={p.session_id} className="participant">
<div className="info">
<strong>{p.name || p.session_id}</strong>
<span className={`status ${p.status}`}>{p.status}</span>
{p.customStatus && <div>{p.customStatus}</div>}
</div>
{/* MediaControl for video (reusable component) */}
{peers[p.session_id] && (
<MediaControl
peer={peers[p.session_id]}
isSelf={p.session_id === session.id}
sendJsonMessage={p.session_id === session.id ? sendJsonMessage : undefined}
remoteAudioMuted={peers[p.session_id].muted}
remoteVideoOff={!peers[p.session_id].video_on}
/>
)}
</div>
))}
</aside>
{/* Chat messages */}
<main className="messages">
{messages.map(msg => (
<div key={msg.id} className="message">
<strong>{msg.senderName}</strong>
<span>{new Date(msg.timestamp).toLocaleTimeString()}</span>
<p>{msg.text}</p>
</div>
))}
<div className="input">
<input
value={messageText}
onChange={e => setMessageText(e.target.value)}
onKeyPress={e => e.key === 'Enter' && handleSendMessage()}
placeholder="Type a message..."
/>
<button onClick={handleSendMessage}>Send</button>
</div>
</main>
</div>
</div>
);
}
What's Reused (No Changes Needed)
✅ MediaControl.tsx - Entire component works as-is ✅ MediaAgent - All WebRTC signaling logic ✅ Room helpers - Session management, participant lists ✅ WebSocket infrastructure - Connection handling, reconnection ✅ Type definitions - Base Session, Room, Participant types
What's Application-Specific (Your Code)
🎯 ChatSessionMetadata - Your session data (status, message count) 🎯 ChatRoomMetadata - Your room data (messages, topic) 🎯 getChatParticipants() - Extends base participants with chat data 🎯 Message handlers - Your business logic 🎯 UI - Your specific interface design
Benefits
- ~90% code reuse from the game infrastructure
- Video chat works immediately with zero WebRTC code
- Type-safe metadata for your application
- Well-tested infrastructure from the game
- Clean separation between framework and app
Summary
By separating infrastructure from application logic, the entire Room/WebRTC system becomes a reusable framework that provides:
- WebSocket room management
- Session/participant tracking
- WebRTC video/audio signaling
- Peer connection management
- UI components (MediaControl)
All you need to do is:
- Define your metadata types
- Extend participant helper with your data
- Handle your application messages
- Use the provided components
Result: Professional video chat functionality in ~200 lines of your code!