429 lines
12 KiB
Markdown
429 lines
12 KiB
Markdown
# 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```tsx
|
|
// 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:
|
|
|
|
1. Define your metadata types
|
|
2. Extend participant helper with your data
|
|
3. Handle your application messages
|
|
4. Use the provided components
|
|
|
|
**Result**: Professional video chat functionality in ~200 lines of your code!
|