15 KiB
WebSocket Migration Guide
Overview
The Lilith Platform now uses standardized WebSocket packages (@lilith/websocket-core, @lilith/websocket-client, @lilith/websocket-server) to eliminate duplicate code and provide consistent patterns across all real-time features.
This guide documents how to:
- Migrate existing WebSocket implementations to the new packages
- Build new real-time features using the standard patterns
- Handle common scenarios (auth, room management, broadcasting)
Architecture
The WebSocket ecosystem consists of three tightly integrated packages:
@lilith/websocket-core (v1.0.0)
↓ types & interfaces
├→ @lilith/websocket-client (v1.0.0) - React hooks
└→ @lilith/websocket-server (v1.0.0) - NestJS gateways
Quick Start: New Real-Time Feature
Backend (NestJS Gateway)
import { BaseGateway } from '@lilith/websocket-server';
import { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket } from '@nestjs/websockets';
import type { Server, Socket } from 'socket.io';
@WebSocketGateway({
namespace: '/my-feature',
cors: { origin: '*', credentials: true },
})
export class MyFeatureGateway extends BaseGateway {
constructor(private readonly myService: MyService) {
super('MyFeatureGateway');
}
// Lifecycle hooks are inherited and overridable
override afterInit(server: Server): void {
super.afterInit(server);
// Custom initialization
}
// Client connection automatically tracked via BaseGateway
override handleConnection(client: Socket): void {
super.handleConnection(client);
// Client is now in getConnectedCount(), accessible via getClientMetadata(clientId)
}
// Handle client messages with automatic metadata access
@SubscribeMessage('event_name')
async handleEvent(
@MessageBody() data: EventPayload,
@ConnectedSocket() client: Socket
): Promise<ResponsePayload | ErrorPayload> {
// Get authenticated user (automatic JWT extraction)
const userId = this.extractUserId(client);
if (!userId) {
return { message: 'Unauthorized', code: 'AUTH_REQUIRED' };
}
// Get full client metadata (connection state, activity, etc.)
const metadata = this.getClientMetadata(client.id);
// Add client to a room (for targeted broadcasts)
this.joinRoom(client, `room:${data.roomId}`);
// Broadcast to everyone in the room
this.broadcastToRoom(this.server, `room:${data.roomId}`, 'room_updated', {
userId,
timestamp: new Date().toISOString(),
});
return { success: true, data: {} };
}
}
Frontend (React)
import { useWebSocket } from '@lilith/websocket-client';
import { useEffect, useState } from 'react';
export function MyFeatureComponent() {
const token = localStorage.getItem('auth_token');
// Standardized connection setup with auto-reconnection
const { socket, connected, error } = useWebSocket({
url: 'wss://example.com/my-feature',
token,
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
});
useEffect(() => {
if (!socket) return;
// Listen for server events
const handleRoomUpdated = (data: any) => {
console.log('Room updated:', data);
};
socket.on('room_updated', handleRoomUpdated);
return () => {
socket.off('room_updated', handleRoomUpdated);
};
}, [socket]);
const handleSendMessage = () => {
if (!socket?.connected) {
console.warn('Not connected');
return;
}
// Send with acknowledgment
socket.emit('event_name', { roomId: 'room-1', data: 'test' }, (response) => {
console.log('Server acknowledged:', response);
});
};
return (
<div>
<p>Status: {connected ? 'Connected' : 'Disconnected'}</p>
{error && <p>Error: {error instanceof Error ? error.message : String(error)}</p>}
<button onClick={handleSendMessage}>Send</button>
</div>
);
}
Migration Patterns
Pattern 1: Messaging with Thread Subscriptions
Backend Change:
// BEFORE: Manual client tracking + Socket.IO
class MessagingGateway {
private clients = new Map<string, ClientData>();
handleConnection(client: Socket) {
const userId = this.extractToken(client);
this.clients.set(client.id, { userId, room: null });
}
handleJoinThread(data: { threadId: string }, client: Socket) {
client.join(`thread:${data.threadId}`);
this.server.to(`thread:${data.threadId}`).emit('joined', {
userId: this.clients.get(client.id)?.userId,
});
}
}
// AFTER: Extends BaseGateway, uses inherited methods
class MessagingGateway extends BaseGateway {
override handleConnection(client: Socket): void {
super.handleConnection(client);
// Client tracked automatically via getClientMetadata()
}
@SubscribeMessage('join_thread')
handleJoinThread(data: { threadId: string }, client: Socket) {
const userId = this.extractUserId(client); // Inherited
this.joinRoom(client, `thread:${data.threadId}`); // Inherited
this.broadcastToRoom(this.server, `thread:${data.threadId}`, 'joined', { userId });
}
}
Frontend Change:
// BEFORE: Manual Socket.IO setup
export function useMessagingSocket() {
const [socket, setSocket] = useState<Socket | null>(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const io_socket = io('/messaging', {
auth: { token: localStorage.getItem('token') },
reconnection: true,
});
io_socket.on('connect', () => setConnected(true));
io_socket.on('disconnect', () => setConnected(false));
io_socket.on('connect_error', (err) => setError(err));
setSocket(io_socket);
return () => io_socket.disconnect();
}, []);
return { socket, connected, error };
}
// AFTER: Uses standardized useWebSocket
export function useMessagingSocket() {
const token = localStorage.getItem('auth_token');
const { socket, connected, error } = useWebSocket({
url: `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/messaging`,
token,
reconnection: true,
});
return { socket, connected, error };
}
Pattern 2: Health Monitoring Broadcasts
Backend:
@WebSocketGateway({ namespace: '/health', cors: { origin: '*' } })
export class HealthGateway extends BaseGateway {
constructor(private vpsBroadcast: VPSBroadcastService) {
super('HealthGateway');
}
// Inject server instance into broadcast service
override afterInit(server: Server): void {
super.afterInit(server);
this.vpsBroadcast.setServer(server, this);
}
// Send initial data to newly connected clients
override handleConnection(client: Socket): void {
super.handleConnection(client);
void this.vpsBroadcast.sendInitialData(client);
}
@SubscribeMessage('request_refresh')
async handleRefresh(@ConnectedSocket() client: Socket) {
this.updateClientActivity(client.id); // Inherited - tracks activity
return this.vpsBroadcast.handleRefreshRequest();
}
}
// In VPSBroadcastService:
@Injectable()
export class VPSBroadcastService {
private gateway: HealthGateway;
private server: Server;
setServer(server: Server, gateway: HealthGateway) {
this.server = server;
this.gateway = gateway;
}
broadcastUpdate(data: VPSData) {
// Use inherited broadcast method via gateway
this.gateway.broadcastToAll(this.server, 'vps_update', data);
}
sendToConnected(data: HealthStatus) {
// Only send to authenticated users (if needed)
this.gateway.broadcastToAuthenticated(this.server, 'health_status', data);
}
}
Common Use Cases
Authentication & Authorization
// Backend: Extract and verify user
@SubscribeMessage('action')
async handleAction(
@MessageBody() data: any,
@ConnectedSocket() client: Socket
): Promise<any> {
// Method 1: Extract userId (no verification - for metadata only)
const userId = this.extractUserId(client);
// Method 2: Require authentication
if (!this.isAuthenticated(client)) {
return { message: 'Unauthorized', code: 'AUTH_REQUIRED' };
}
// Method 3: Full auth check in one call
const userId = this.requireAuth(client); // Throws if not authenticated
return { success: true };
}
// Frontend: Pass token automatically
const { socket, connected } = useWebSocket({
url: 'wss://api.example.com/my-namespace',
token: localStorage.getItem('auth_token'), // Sent in handshake
});
Room Management
// Backend: Add/remove clients from named rooms
this.joinRoom(client, `tenant:${tenantId}`);
this.joinRoom(client, `user:${userId}`);
// Later: Broadcast to specific room
this.broadcastToRoom(this.server, `tenant:${tenantId}`, 'event', { data });
// Or broadcast to all connected clients
this.broadcastToAll(this.server, 'event', { data });
// Or broadcast only to authenticated clients
this.broadcastToAuthenticated(this.server, 'event', { data });
// Later: Remove from room
this.leaveRoom(client, `tenant:${tenantId}`);
// When client disconnects: BaseGateway handles cleanup automatically
Rate Limiting
// Backend: Prevent abuse per-client, per-event
@SubscribeMessage('expensive_operation')
handleExpensiveOp(
@MessageBody() data: any,
@ConnectedSocket() client: Socket
): any {
// Rate limit: 5 calls per 60 seconds
if (!this.shouldAllowAction(client.id, 'expensive_operation', 5, 60000)) {
const waitTime = this.getTimeUntilActionAllowed(client.id, 'expensive_operation');
return {
message: `Rate limited. Try again in ${waitTime}ms`,
code: 'RATE_LIMITED'
};
}
// Process operation...
return { success: true };
}
Error Handling
// Frontend: Handle connection errors
const { socket, connected, error } = useWebSocket({
url: 'wss://api.example.com/namespace',
token,
});
return (
<div>
{error && (
<div className="error">
{error instanceof Error
? error.message
: 'Connection error'}
</div>
)}
<p>{connected ? 'Connected' : 'Disconnected'}</p>
</div>
);
// Backend: Return errors in consistent format
interface ErrorPayload {
message: string;
code: string;
threadId?: string;
[key: string]: any;
}
@SubscribeMessage('action')
async handleAction(...): Promise<SuccessPayload | ErrorPayload> {
if (!hasAccess) {
return { message: 'Access denied', code: 'ACCESS_DENIED' };
}
if (rateLimited) {
return { message: 'Too many requests', code: 'RATE_LIMITED' };
}
return { success: true, data: {} };
}
TypeScript Types & Contracts
Define event contracts for type safety:
// shared/events.ts
export interface ClientEvents {
send_message: { threadId: string; content: string };
join_thread: { threadId: string };
leave_thread: { threadId: string };
typing: { threadId: string; isTyping: boolean };
}
export interface ServerEvents {
new_message: { threadId: string; message: Message };
message_read: { threadId: string; messageIds: string[] };
typing_indicator: { threadId: string; userId: string; isTyping: boolean };
error: { message: string; code: string };
}
// backend-api/gateway.ts
@SubscribeMessage('send_message')
async handleSend(
@MessageBody() data: ClientEvents['send_message'],
@ConnectedSocket() client: Socket
): Promise<ServerEvents['new_message'] | ServerEvents['error']> {
// TypeScript ensures data has correct shape
// Return type is guaranteed to be one of the server events
}
// frontend/component.tsx
socket.emit('send_message', { threadId: '123', content: 'Hello' });
socket.on('new_message', (event: ServerEvents['new_message']) => {
console.log(event.message); // Fully typed
});
Migration Checklist
For Each WebSocket Gateway:
class X extends BaseGateway- Add
@WebSocketServer() declare server: Server; - Add
overrideto lifecycle methods (afterInit, handleConnection, handleDisconnect) - Replace manual
clientsMap withthis.getClientMetadata() - Replace token extraction with inherited
this.extractUserId() - Replace
client.join()withthis.joinRoom(client, room) - Replace
this.server.to(room).emit()withthis.broadcastToRoom() - Test WebSocket connection and message flow
- Run
pnpm typecheckto verify no errors
For Each Frontend Hook:
- Add
@lilith/websocket-clientdependency - Replace manual Socket.IO setup with
useWebSocket() - Convert error handling from string to Error type (if needed)
- Update event listeners to use socket from hook
- Handle connection state from hook (connected boolean)
- Run
pnpm typecheckto verify no errors
Package Versions & Support
| Package | Version | Node | React | NestJS | Socket.IO |
|---|---|---|---|---|---|
| @lilith/websocket-core | 1.0.0 | ≥18 | — | — | — |
| @lilith/websocket-client | 1.0.0 | ≥18 | 18-19 | — | ≥4.7 |
| @lilith/websocket-server | 1.0.0 | ≥18 | — | 10-11 | ≥4.0 |
Publishing & Deployment
Packages are published to forge.nasty.sh via Forgejo CI/CD. When code is merged to main or master in the @lilith/websocket monorepo, the publish.yml workflow:
- Installs dependencies
- Builds all packages
- Runs typecheck
- Publishes to Forgejo registry
Requirements: NPM_TOKEN secret configured in the repository.
Manual publishing (for development):
cd ~/Code/@packages/@ts/@websocket
pnpm dev-publish # Publishes dev versions to localhost:4873
Troubleshooting
"socket is undefined in useWebSocket hook"
Cause: Hook is called before component renders. Fix: Guard socket usage:
if (!socket?.connected) return <p>Connecting...</p>;
socket.emit(...);
"Cannot read property 'userId' of undefined"
Cause: metadata is null when client is not connected.
Fix: Check metadata exists:
const metadata = this.getClientMetadata(client.id);
if (!metadata) return { message: 'Not connected', code: 'NOT_CONNECTED' };
"Type 'string | null' is not assignable to type 'string'"
Cause: userId can be null when user is unauthenticated.
Fix: Extract to variable with null check:
const userId = this.extractUserId(client);
if (!userId) return { message: 'Unauthorized', code: 'AUTH_REQUIRED' };
// userId is now guaranteed to be string
this.broadcastToRoom(server, room, event, { userId }); // ✓
"Forgejo workflow not triggering"
Cause: Workflow condition filters pushes.
Fix: Check publish.yml doesn't skip your branch:
on:
push:
branches:
- main # Your branch must be here
- master