Skip to content

Add an RT WebSocket Event

Canonical example: blackjack events at libs/shared/src/events/bj.events.ts:9-38.

Architecture overview

The RT app (apps/rt/, port 4001) runs a single Socket.IO namespace /events in websocket-only mode (client.gateway.ts:48-53). Clients connect, authenticate via a socket_token cookie, then exchange events through a wildcard handler (@SubscribeMessage('*') at client.gateway.ts:164).

Events flow through three channels defined in libs/gateway/src/events.ts:1-5:

Channel Prefix Direction Example
Server server_channel_event Server → Client BlackjackTableUpdated
Client client_channel_event Client → Server BlackjackTableJoin
Private private_channel_event Internal only AuthSocket

1. Define the event constants

Create or extend a game-events file. Follow bj.events.ts:9-38:

// libs/shared/src/events/your-game.events.ts

import { GATEWAY_CHANNEL, GATEWAY_EVENTS, GatewayEvents } from '@app/gateway/events';

export const GATEWAY_YOUR_GAME_EVENTS = {
  Server: {
    YourGameStateChanged: `${GATEWAY_CHANNEL.Server}.YourGameStateChanged`,
    YourGameResult: `${GATEWAY_CHANNEL.Server}.YourGameResult`,
  },
  Client: {
    YourGameJoin: `${GATEWAY_CHANNEL.Client}.YourGameJoin`,
    YourGameLeave: `${GATEWAY_CHANNEL.Client}.YourGameLeave`,
    YourGameAction: `${GATEWAY_CHANNEL.Client}.YourGameAction`,
  },
  Private: {
    ...GATEWAY_EVENTS.Private,
  },
} satisfies GatewayEvents;

The satisfies GatewayEvents constraint (bj.events.ts:38) ensures the shape matches { Server, Client, Private } with string values.

2. Register client-listened events

The RT gateway maintains a whitelist of allowed client events (client.gateway.ts:57). Unknown events are rejected at client.gateway.ts:178.

Your game's microservice must respond to RequestClientEvents by sending back its Client event keys:

@Controller()
export class GatewayController {
  @EventPattern(GATEWAY_EVENTS.Private.RequestClientEvents)
  onRequestClientEvents() {
    return {
      event: GATEWAY_EVENTS.Private.ClientEvents,
      data: Object.values(GATEWAY_YOUR_GAME_EVENTS.Client),
    };
  }
}

3. Handle client → server events

The RT gateway's wildcard handler (client.gateway.ts:164-220) forwards all client events to the backend microservice via Redis transport. Your game service handles them with @MessagePattern:

// apps/your-game/src/gateway/gateway.controller.ts

@MessagePattern(GATEWAY_YOUR_GAME_EVENTS.Client.YourGameJoin)
async onJoin(@Payload() message: EventMessage) {
  const { userId } = message.user;
  const { roomId } = message.data;

  // Return a room-join instruction — the RT gateway
  // calls socket.join(room) automatically (client.gateway.ts:229-248)
  return {
    type: GatewayMessageType.ClientRoom,
    data: { action: 'join', rooms: [`your-game:${roomId}`] },
  };
}

@MessagePattern(GATEWAY_YOUR_GAME_EVENTS.Client.YourGameAction)
async onAction(@Payload() message: EventMessage) {
  const result = await this.gameService.handleAction(message.data);

  // Return data directly to the calling client via ack
  return {
    type: GatewayMessageType.ClientMessage,
    data: result,
  };
}

Return types from libs/gateway/src/dto/message.dto.ts: - ClientRoom — instructs the gateway to join/leave socket.io rooms. - ClientMessage — sends data back to the requesting client.

4. Push server → client events

For broadcasting state changes to connected clients without a client request, emit through the Server channel. The RT gateway routes based on the message metadata (client.gateway.ts:308-321):

// Inside your game service

import { ClientProxyFactory } from '@nestjs/microservices';
import { GATEWAY_YOUR_GAME_EVENTS } from '@app/shared/events/your-game.events';

// Emit to a specific user
this.gatewayProxy.emit(
  GATEWAY_YOUR_GAME_EVENTS.Server.YourGameResult,
  {
    user: { id: userId },
    data: { won: true, payout: '1.50' },
  },
);

// Emit to a socket.io room (all clients who joined)
this.gatewayProxy.emit(
  GATEWAY_YOUR_GAME_EVENTS.Server.YourGameStateChanged,
  {
    room: `your-game:${roomId}`,
    data: { state: 'dealing', round: 5 },
  },
);

// Broadcast to all connected clients (omit user + room)
this.gatewayProxy.emit(
  GATEWAY_YOUR_GAME_EVENTS.Server.YourGameStateChanged,
  { data: { announcement: 'New round starting' } },
);

5. Subscribe on the frontend

Follow the pattern from ebit-fe/src/providers/socketsWithAuth.tsx:

// React hook pattern
import { useEffect } from 'react';
import { useSocket } from '@/providers/socketsWithAuth';

export function useYourGameEvents(roomId: string) {
  const { publicChatSocket } = useSocket();

  useEffect(() => {
    if (!publicChatSocket) return;

    // Join the game room (with ack)
    publicChatSocket.emitWithAck('YourGameJoin', { roomId });

    const onStateChanged = (data: YourGameState) => {
      if (data.__m?.windowId === window.__windowId) return; // dedup
      setGameState(data);
    };
    publicChatSocket.on('YourGameStateChanged', onStateChanged);

    return () => {
      publicChatSocket.off('YourGameStateChanged', onStateChanged);
      publicChatSocket.emit('YourGameLeave', { roomId });
    };
  }, [publicChatSocket, roomId]);
}

Client-side event names omit the channel prefix — the gateway strips it. server_channel_event.YourGameStateChanged arrives as YourGameStateChanged.

6. Auth and throttling

  • Auth on connect: The gateway extracts socket_token from the handshake and calls authorizeClient() (client.gateway.ts:94-114). Sets client.authorized and client.user for subsequent events.

  • WsThrottlerGuard: Lives at libs/ws-throttler/src/ws-throttler.guard.ts. Rate-limits by IP, emits error event and disconnects if blocked. Apply via @UseGuards(WsThrottlerGuard) if needed.

You're done — test by...

cd ebit-api
npm run start:dev      # api on :4000
npm run start:dev:rt   # rt on :4001
# In a separate terminal, connect via wscat or socket.io-client:
# npx wscat -c ws://localhost:4001/events -H 'Cookie: socket_token=YOUR_TOKEN'
# Or write an E2E test — see add-e2e-test.md