13 KiB
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
- Using the Adapter for Backward Compatibility
- Writing New Code with Metadata Pattern
- Creating a New Application Type
- 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
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:
// 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)
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
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
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
// 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>;
// 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,
});
}
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
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:
// 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:
// 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
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
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
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.