489 lines
13 KiB
Markdown
489 lines
13 KiB
Markdown
# Migration Example: Using the Pluggable Architecture
|
|
|
|
This document provides concrete examples of how to use the new pluggable architecture, demonstrating both backward compatibility and new patterns.
|
|
|
|
## Table of Contents
|
|
|
|
1. [Using the Adapter for Backward Compatibility](#using-the-adapter-for-backward-compatibility)
|
|
2. [Writing New Code with Metadata Pattern](#writing-new-code-with-metadata-pattern)
|
|
3. [Creating a New Application Type](#creating-a-new-application-type)
|
|
4. [Migration Path](#migration-path)
|
|
|
|
## Using the Adapter for Backward Compatibility
|
|
|
|
The adapter layer allows existing code to work unchanged while the codebase gradually migrates to the new architecture.
|
|
|
|
### Example: Existing Code Continues to Work
|
|
|
|
```typescript
|
|
import { wrapGame, wrapSession } from './routes/games/gameAdapter';
|
|
import type { Game } from './routes/games/types';
|
|
|
|
// Load existing game from database/storage
|
|
const loadedGame: Game = loadGameFromDatabase(gameId);
|
|
|
|
// Wrap the game object to enable backward compatibility
|
|
const game = wrapGame(loadedGame);
|
|
|
|
// Old code continues to work unchanged!
|
|
game.turn = { color: "red", roll: 7 }; // Proxied to game.metadata.turn
|
|
game.players["red"].resources = 5; // Proxied to game.metadata.players["red"]
|
|
|
|
// Session operations also work
|
|
const session = game.sessions["session123"];
|
|
session.color = "blue"; // Proxied to session.metadata.color
|
|
session.player = game.players["blue"]; // Proxied to session.metadata.player
|
|
|
|
console.log("Player color:", session.color); // Reads from session.metadata.color
|
|
console.log("Turn data:", game.turn); // Reads from game.metadata.turn
|
|
```
|
|
|
|
### How the Proxy Works
|
|
|
|
The adapter uses JavaScript Proxy to intercept property access:
|
|
|
|
```typescript
|
|
// Reading game-specific properties
|
|
const color = session.color;
|
|
// Behind the scenes: session.metadata?.color
|
|
|
|
// Writing game-specific properties
|
|
session.color = "red";
|
|
// Behind the scenes: session.metadata = { ...session.metadata, color: "red" }
|
|
|
|
// Infrastructure properties work normally
|
|
const sessionId = session.id; // Direct access (not proxied)
|
|
const isLive = session.live; // Direct access (not proxied)
|
|
```
|
|
|
|
## Writing New Code with Metadata Pattern
|
|
|
|
New code should use the metadata pattern directly for clean separation of concerns.
|
|
|
|
### Example: Session Color Selection (New Pattern)
|
|
|
|
```typescript
|
|
import type { GameSession, GameSessionMetadata } from './routes/games/gameMetadata';
|
|
import { updateSessionMetadata, getSessionMetadata } from './routes/room/helpers';
|
|
|
|
function handleColorSelection(session: GameSession, color: string): void {
|
|
// Update game-specific metadata
|
|
updateSessionMetadata<GameSessionMetadata>(session, {
|
|
color: color
|
|
});
|
|
|
|
// Infrastructure fields remain separate
|
|
session.live = true;
|
|
session.lastActive = Date.now();
|
|
}
|
|
|
|
function getPlayerColor(session: GameSession): string | undefined {
|
|
const metadata = getSessionMetadata<GameSessionMetadata>(session);
|
|
return metadata?.color;
|
|
}
|
|
```
|
|
|
|
### Example: Getting Participants with Game Data
|
|
|
|
```typescript
|
|
import { getParticipants } from './routes/room/helpers';
|
|
import type { GameRoom, GameSessionMetadata } from './routes/games/gameMetadata';
|
|
|
|
function getGameParticipants(room: GameRoom) {
|
|
// Get base participant data (reusable)
|
|
const baseParticipants = getParticipants(room.sessions);
|
|
|
|
// Extend with game-specific data
|
|
return baseParticipants.map(p => {
|
|
const session = room.sessions[p.session_id];
|
|
const metadata = session.metadata as GameSessionMetadata | undefined;
|
|
|
|
return {
|
|
...p, // Base participant data
|
|
color: metadata?.color || null, // Game-specific data
|
|
};
|
|
});
|
|
}
|
|
```
|
|
|
|
### Example: Room State Management
|
|
|
|
```typescript
|
|
import type { GameRoom, GameRoomMetadata } from './routes/games/gameMetadata';
|
|
import { updateRoomMetadata, getRoomMetadata } from './routes/room/helpers';
|
|
|
|
function initializeGameRoom(room: GameRoom): void {
|
|
// Initialize game-specific metadata
|
|
updateRoomMetadata<GameRoomMetadata>(room, {
|
|
players: {},
|
|
turn: { color: undefined, actions: [] },
|
|
placements: { corners: [], roads: [] },
|
|
chat: [],
|
|
developmentCards: [],
|
|
});
|
|
|
|
// Infrastructure state remains separate
|
|
room.state = "lobby";
|
|
}
|
|
|
|
function updateTurn(room: GameRoom, color: string, roll: number): void {
|
|
const metadata = getRoomMetadata<GameRoomMetadata>(room);
|
|
if (!metadata) return;
|
|
|
|
// Update game state in metadata
|
|
metadata.turn = {
|
|
...metadata.turn,
|
|
color,
|
|
roll,
|
|
};
|
|
|
|
// Infrastructure state changes separately
|
|
room.state = "playing";
|
|
}
|
|
```
|
|
|
|
## Creating a New Application Type
|
|
|
|
The infrastructure can be reused for any multi-user WebRTC application.
|
|
|
|
### Example: Creating a Whiteboard Application
|
|
|
|
```typescript
|
|
// Step 1: Define your metadata types
|
|
// File: server/routes/whiteboard/whiteboardMetadata.ts
|
|
|
|
import type { Session, Room } from '../room/types';
|
|
import type { Player } from './whiteboardTypes';
|
|
|
|
export interface WhiteboardSessionMetadata {
|
|
tool: "pen" | "eraser" | "shape";
|
|
color: string;
|
|
brushSize: number;
|
|
selectedObject?: string;
|
|
}
|
|
|
|
export interface WhiteboardRoomMetadata {
|
|
canvas: CanvasObject[];
|
|
history: HistoryEntry[];
|
|
locked: boolean;
|
|
}
|
|
|
|
export type WhiteboardSession = Session<WhiteboardSessionMetadata>;
|
|
export type WhiteboardRoom = Room<WhiteboardRoomMetadata>;
|
|
```
|
|
|
|
```typescript
|
|
// Step 2: Use the reusable infrastructure
|
|
// File: server/routes/whiteboard/handlers.ts
|
|
|
|
import type { WhiteboardRoom, WhiteboardSession, WhiteboardSessionMetadata } from './whiteboardMetadata';
|
|
import {
|
|
getParticipants,
|
|
updateSessionMetadata,
|
|
updateRoomMetadata
|
|
} from '../room/helpers';
|
|
|
|
export function handleToolChange(
|
|
session: WhiteboardSession,
|
|
tool: "pen" | "eraser" | "shape"
|
|
): void {
|
|
updateSessionMetadata<WhiteboardSessionMetadata>(session, { tool });
|
|
}
|
|
|
|
export function getWhiteboardParticipants(room: WhiteboardRoom) {
|
|
const baseParticipants = getParticipants(room.sessions);
|
|
|
|
// Add whiteboard-specific data
|
|
return baseParticipants.map(p => {
|
|
const session = room.sessions[p.session_id];
|
|
const metadata = session.metadata;
|
|
|
|
return {
|
|
...p,
|
|
tool: metadata?.tool || "pen",
|
|
color: metadata?.color || "#000000",
|
|
brushSize: metadata?.brushSize || 5,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function addCanvasObject(room: WhiteboardRoom, object: CanvasObject): void {
|
|
if (!room.metadata) {
|
|
updateRoomMetadata(room, {
|
|
canvas: [],
|
|
history: [],
|
|
locked: false,
|
|
});
|
|
}
|
|
|
|
room.metadata!.canvas.push(object);
|
|
room.metadata!.history.push({
|
|
type: 'add',
|
|
timestamp: Date.now(),
|
|
object,
|
|
});
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// Step 3: Reuse MediaControl component (client-side)
|
|
// File: client/src/WhiteboardApp.tsx
|
|
|
|
import { MediaAgent, MediaControl } from './MediaControl';
|
|
import type { Session } from './GlobalContext';
|
|
|
|
function WhiteboardApp() {
|
|
const [session, setSession] = useState<Session | null>(null);
|
|
const [peers, setPeers] = useState<Record<string, Peer>>({});
|
|
|
|
return (
|
|
<>
|
|
{/* Reuse MediaAgent - no changes needed! */}
|
|
<MediaAgent
|
|
session={session}
|
|
socketUrl={socketUrl}
|
|
peers={peers}
|
|
setPeers={setPeers}
|
|
/>
|
|
|
|
{/* Reuse MediaControl - no changes needed! */}
|
|
{participants.map(p => (
|
|
<MediaControl
|
|
key={p.session_id}
|
|
peer={peers[p.session_id]}
|
|
isSelf={p.session_id === session?.id}
|
|
sendJsonMessage={sendJsonMessage}
|
|
/>
|
|
))}
|
|
|
|
{/* Your whiteboard-specific UI */}
|
|
<Canvas participants={participants} />
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
## Migration Path
|
|
|
|
### Phase 1: Add Metadata Layer (✅ Complete)
|
|
|
|
The infrastructure and metadata types are now defined. Code can use either pattern:
|
|
|
|
```typescript
|
|
// Old pattern (via adapter)
|
|
session.color = "red";
|
|
|
|
// New pattern (direct metadata access)
|
|
updateSessionMetadata(session, { color: "red" });
|
|
```
|
|
|
|
### Phase 2: Migrate Functions One by One
|
|
|
|
Gradually update functions to use metadata pattern:
|
|
|
|
```typescript
|
|
// Before: Mixed concerns
|
|
function setPlayerColor(session: any, color: string): void {
|
|
session.color = color; // Direct property access
|
|
session.live = true;
|
|
}
|
|
|
|
// After: Separated concerns
|
|
function setPlayerColor(session: GameSession, color: string): void {
|
|
updateSessionMetadata<GameSessionMetadata>(session, { color }); // Metadata
|
|
session.live = true; // Infrastructure
|
|
}
|
|
```
|
|
|
|
### Phase 3: Update Message Handlers
|
|
|
|
Update WebSocket message handlers to work with metadata:
|
|
|
|
```typescript
|
|
// Before
|
|
case "set": {
|
|
if (data.field === "color") {
|
|
session.color = data.value;
|
|
}
|
|
break;
|
|
}
|
|
|
|
// After
|
|
case "set": {
|
|
if (data.field === "color") {
|
|
updateSessionMetadata<GameSessionMetadata>(session, {
|
|
color: data.value
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
```
|
|
|
|
### Phase 4: Remove Adapters (Future)
|
|
|
|
Once all code is migrated, remove the adapter layer and use pure metadata:
|
|
|
|
```typescript
|
|
// Pure metadata architecture (no adapters)
|
|
const session: GameSession = {
|
|
// Infrastructure fields
|
|
id: "abc123",
|
|
name: "Alice",
|
|
live: true,
|
|
has_media: true,
|
|
lastActive: Date.now(),
|
|
|
|
// Application-specific data
|
|
metadata: {
|
|
color: "red",
|
|
player: {...},
|
|
resources: 5,
|
|
}
|
|
};
|
|
```
|
|
|
|
## Benefits of the New Architecture
|
|
|
|
### 1. Reusability
|
|
|
|
The same infrastructure works for different applications:
|
|
|
|
```typescript
|
|
// Game application
|
|
const gameRoom: Room<GameRoomMetadata> = {...};
|
|
|
|
// Whiteboard application
|
|
const whiteboardRoom: Room<WhiteboardRoomMetadata> = {...};
|
|
|
|
// Chat application
|
|
const chatRoom: Room<ChatRoomMetadata> = {...};
|
|
|
|
// All use the same getParticipants, session management, WebRTC signaling!
|
|
```
|
|
|
|
### 2. Type Safety
|
|
|
|
TypeScript ensures correct metadata usage:
|
|
|
|
```typescript
|
|
const session: GameSession = {...};
|
|
|
|
// ✅ Type-safe
|
|
updateSessionMetadata<GameSessionMetadata>(session, { color: "red" });
|
|
|
|
// ❌ TypeScript error: 'invalidField' doesn't exist
|
|
updateSessionMetadata<GameSessionMetadata>(session, { invalidField: "value" });
|
|
```
|
|
|
|
### 3. Clear Separation
|
|
|
|
Infrastructure and application logic are clearly separated:
|
|
|
|
```typescript
|
|
// Infrastructure (works for any app)
|
|
import { getParticipants, updateSessionActivity } from './room/helpers';
|
|
|
|
// Application-specific (game logic)
|
|
import { GameSession, GameRoom } from './games/gameMetadata';
|
|
import { calculatePoints, updateTurn } from './games/gameLogic';
|
|
```
|
|
|
|
### 4. Easy Testing
|
|
|
|
Each layer can be tested independently:
|
|
|
|
```typescript
|
|
// Test infrastructure
|
|
describe('getParticipants', () => {
|
|
it('returns participant list from any session type', () => {
|
|
const sessions = { /* any session structure */ };
|
|
const participants = getParticipants(sessions);
|
|
expect(participants).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// Test game logic
|
|
describe('calculatePoints', () => {
|
|
it('calculates game points from metadata', () => {
|
|
const room: GameRoom = { /* ... */ };
|
|
calculatePoints(room);
|
|
expect(room.metadata?.players['red'].points).toBe(10);
|
|
});
|
|
});
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Pattern 1: Extending Participant Lists
|
|
|
|
```typescript
|
|
function getExtendedParticipants<TMetadata extends Record<string, any>>(
|
|
room: Room<any>,
|
|
extender: (session: Session<any>) => TMetadata
|
|
) {
|
|
const baseParticipants = getParticipants(room.sessions);
|
|
|
|
return baseParticipants.map(p => ({
|
|
...p,
|
|
...extender(room.sessions[p.session_id])
|
|
}));
|
|
}
|
|
|
|
// Usage
|
|
const gameParticipants = getExtendedParticipants(gameRoom, (session) => ({
|
|
color: session.metadata?.color || null,
|
|
points: session.metadata?.player?.points || 0,
|
|
}));
|
|
```
|
|
|
|
### Pattern 2: Metadata Initialization
|
|
|
|
```typescript
|
|
function ensureSessionMetadata<TMetadata>(
|
|
session: Session<TMetadata>,
|
|
defaults: TMetadata
|
|
): void {
|
|
if (!session.metadata) {
|
|
session.metadata = defaults;
|
|
}
|
|
}
|
|
|
|
// Usage
|
|
ensureSessionMetadata<GameSessionMetadata>(session, {
|
|
color: undefined,
|
|
player: undefined,
|
|
resources: 0,
|
|
});
|
|
```
|
|
|
|
### Pattern 3: Safe Metadata Access
|
|
|
|
```typescript
|
|
function getMetadataField<TMetadata, K extends keyof TMetadata>(
|
|
session: Session<TMetadata>,
|
|
field: K,
|
|
defaultValue: TMetadata[K]
|
|
): TMetadata[K] {
|
|
return session.metadata?.[field] ?? defaultValue;
|
|
}
|
|
|
|
// Usage
|
|
const color = getMetadataField<GameSessionMetadata, 'color'>(
|
|
session,
|
|
'color',
|
|
'gray'
|
|
);
|
|
```
|
|
|
|
## Summary
|
|
|
|
The new architecture provides:
|
|
|
|
- **Clean separation** between infrastructure and application logic
|
|
- **Reusable components** (MediaControl, Room helpers, WebRTC signaling)
|
|
- **Type safety** with generic TypeScript types
|
|
- **Backward compatibility** via adapter pattern
|
|
- **Extensibility** for new application types
|
|
|
|
By following these patterns, you can create new applications that leverage the existing WebRTC and Room infrastructure without modification.
|