platform-docs/WEBSOCKET_MIGRATION_GUIDE.md

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:

  1. Migrate existing WebSocket implementations to the new packages
  2. Build new real-time features using the standard patterns
  3. 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 override to lifecycle methods (afterInit, handleConnection, handleDisconnect)
  • Replace manual clients Map with this.getClientMetadata()
  • Replace token extraction with inherited this.extractUserId()
  • Replace client.join() with this.joinRoom(client, room)
  • Replace this.server.to(room).emit() with this.broadcastToRoom()
  • Test WebSocket connection and message flow
  • Run pnpm typecheck to verify no errors

For Each Frontend Hook:

  • Add @lilith/websocket-client dependency
  • 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 typecheck to 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:

  1. Installs dependencies
  2. Builds all packages
  3. Runs typecheck
  4. 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