WebSocket

Realtime with WebSocket

Shared Socket.IO gateway, JWT handshake auth, and message handlers keyed by type.

Overview

The stack under src/common/websocket exposes a single Socket.IO gateway. Features (chat, notifications, etc.) do not create another gateway: they register MessageHandlers that respond to payload.type. The reference module is websocket-example.

Key ideas

  • • One shared gateway at path /ws
  • • Single Socket.IO event: message
  • • Routing by payload.type → MessageHandler
  • • JWT required on the connection handshake

Architecture and flow

AppModule imports WebsocketModule.forRoot(), which boots WebsocketGateway and WebsocketService. Each feature module imports WebsocketModule and registers its handlers.

Message path

Client connects to /ws with JWT

Client emits message with { type, ... }

WebsocketGateway @SubscribeMessage('message')

WebsocketService.processMessage finds the handler

Handler validates the DTO and calls the feature service

Gateway returns messageResponse { received: true }

TypeScriptAppModule
// app.module.ts
imports: [
  WebsocketModule.forRoot(),
  WebsocketExampleModule, // feature modules
  // MyFeatureModule,
]

JWT authentication

In AbstractWebsocketGateway.afterInit, a Socket.IO middleware requires a JWT. JwtAuthGuard and RolesGuard also work on the WS gateway. Handlers may declare roles for per-message authorization.

  • • Token in socket.handshake.auth.token
  • • Or Authorization: Bearer <token> header
  • • Verified with jwt.secret (ConfigService)
  • • decoded.id / email / roles → socket.data.user
  • • Optional handler.roles — e.g. websocket-example requires Role.SUPER
Clienthandshake
const socket = io('http://localhost:3000', {
  path: '/ws',
  auth: { token: accessToken },
  // or: extraHeaders: { Authorization: `Bearer ${accessToken}` }
});

Message contract

All feature traffic uses the message event. The type field selects the handler (first-match).

Interfaces

TypeScriptmessage-handler.interface.ts
export interface MessagePayload {
  type: string;
  [key: string]: any;
}

export interface MessageHandler {
  canHandle(type: string): boolean;
  handle(client: Socket, payload: MessagePayload): void;
}

Events

  • • message (client → server) — payload with type
  • • messageResponse (server → client) — gateway ack
  • • error — validation failed (example pattern)
  • • Feature events — you define them (e.g. websocket_example_ack)

WebsocketService API

The concrete service extends AbstractWebsocketService and is the registration/broadcast entry point.

  • registerMessageHandler(handler) — register a handler
  • addClient / removeClient / getClient / getAllClients
  • sendToClient(clientId, event, data)
  • broadcast(event, data, except?)
  • processMessage(client, payload) — first handler with canHandle(type)

Create a WebSocket module

Follow these steps for a full feature. You do not need a new @WebSocketGateway.

Folder structure

Filesystemsrc/modules/my-feature
src/modules/my-feature/
├── my-feature.module.ts
├── my-feature.service.ts
├── dtos/
│   └── my-feature.dto.ts
└── handlers/
    └── my-feature.handler.ts

1. DTO

Include a stable type and validate with class-validator.

TypeScriptDTO
export class MyFeatureDto {
  @IsString()
  @IsNotEmpty()
  type: string = 'my_feature';  // stable type — routing key

  @IsString()
  @IsNotEmpty()
  message!: string;
}

2. Handler

Implement MessageHandler: canHandle + validate + service.

TypeScriptHandler
@Injectable()
export class MyFeatureHandler implements MessageHandler {
  constructor(private readonly myFeatureService: MyFeatureService) {}

  canHandle(type: string): boolean {
    return type === 'my_feature';
  }

  async handle(client: Socket, payload: MessagePayload) {
    const dto = Object.assign(new MyFeatureDto(), payload);
    const errors = await validate(dto);
    if (errors.length > 0) {
      client.emit('error', { message: 'Validation failed', errors });
      return;
    }
    this.myFeatureService.processMessage(client, dto);
  }
}

3. Service

Business logic; use client.emit or WebsocketService.sendToClient / broadcast.

TypeScriptService
@Injectable()
export class MyFeatureService {
  processMessage(client: Socket, dto: MyFeatureDto): void {
    client.emit('my_feature_ack', {
      received: true,
      timestamp: new Date().toISOString(),
    });
  }
}

4. Module + registration

Import WebsocketModule and register the handler with the REGISTER_HANDLERS factory.

TypeScriptModule
@Module({
  imports: [WebsocketModule],
  providers: [
    MyFeatureService,
    MyFeatureHandler,
    {
      provide: 'REGISTER_HANDLERS',
      useFactory: (ws: WebsocketService, handler: MyFeatureHandler) => {
        // register the handler at module bootstrap
        ws.registerMessageHandler(handler);
        return true;
      },
      inject: [WebsocketService, MyFeatureHandler],
    },
  ],
  exports: [MyFeatureService],
})
export class MyFeatureModule {}

5. AppModule

Ensure WebsocketModule.forRoot() and import your feature module.

Multiple DTOs in the same module

Recommended pattern: one DTO and one Handler per type. Register all in the same factory. The example only has one DTO; the pattern below is what real features should use.

Pattern

  • • CreateXDto with type: 'x_create'
  • • UpdateXDto with type: 'x_update'
  • • CreateXHandler.canHandle === 'x_create'
  • • UpdateXHandler.canHandle === 'x_update'
  • • REGISTER_HANDLERS calls registerMessageHandler for each

DTOs

TypeScriptDTOs
export class ChatSendDto {
  @IsString() @IsNotEmpty()
  type: string = 'chat_send';

  @IsString() @IsNotEmpty()
  roomId!: string;

  @IsString() @IsNotEmpty()
  text!: string;
}

export class ChatTypingDto {
  @IsString() @IsNotEmpty()
  type: string = 'chat_typing';

  @IsString() @IsNotEmpty()
  roomId!: string;

  @IsBoolean()
  isTyping!: boolean;
}

Handlers

TypeScriptHandlers
@Injectable()
export class ChatSendHandler implements MessageHandler {
  canHandle(type: string) { return type === 'chat_send'; }
  async handle(client: Socket, payload: MessagePayload) {
    const dto = Object.assign(new ChatSendDto(), payload);
    // validate(dto) ...
    this.chatService.send(client, dto);
  }
}

@Injectable()
export class ChatTypingHandler implements MessageHandler {
  canHandle(type: string) { return type === 'chat_typing'; }
  async handle(client: Socket, payload: MessagePayload) {
    const dto = Object.assign(new ChatTypingDto(), payload);
    // validate(dto) ...
    this.chatService.typing(client, dto);
  }
}

Module

TypeScriptModule
{
  provide: 'REGISTER_HANDLERS',
  useFactory: (
    ws: WebsocketService,
    sendHandler: ChatSendHandler,
    typingHandler: ChatTypingHandler,
  ) => {
    // multiple handlers in the same module
    ws.registerMessageHandler(sendHandler);
    ws.registerMessageHandler(typingHandler);
    return true;
  },
  inject: [WebsocketService, ChatSendHandler, ChatTypingHandler],
}

websocket-example module

Minimal demo: one DTO type example, one handler, in-memory ack, and a REST endpoint for online clients.

  • • type: 'example' + required message field
  • • Emits websocket_example_ack after processing
  • • Emits error if validation fails
  • • GET /websocket-example/online — clients active in the last 5s (JWT + Role.SUPER)
Socketemit example
socket.emit('message', {
  type: 'example',
  message: 'hello from client',
});
HTTPREST
GET /api/v1/websocket-example/online
Authorization: Bearer 

Validation

Domain validation lives in the handler with class-validator. PayloadValidationPipe (requires type) exists under common/websocket/pipes but is not applied on the gateway today.

  • • Handler: Object.assign(new Dto(), payload) + validate()
  • • Failure → client.emit('error', { message, errors })
  • • Optional pipe on the gateway if you want early type checks

Socket.IO client

Connect with path /ws and a JWT. Then emit message and listen for feature events.

JavaScriptsocket.io-client
import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', {
  path: '/ws',
  auth: { token: accessToken },
});

socket.on('connect', () => {
  socket.emit('message', {
    type: 'example',
    message: 'hello',
  });
});

socket.on('messageResponse', (data) => {
  // { received: true }
});

socket.on('websocket_example_ack', (data) => {
  // { received: true, timestamp: '...' }
});

socket.on('error', (err) => {
  // { message: 'Validation failed', errors: [...] }
});

Open the interactive playground at /wsui to connect and exchange messages without writing a client.

/wsui

Notes

JWT required

Without a valid handshake token the connection will not start. Get an access token via Auth.

Async handlers

processMessage does not await handle(). Async errors do not reject the gateway Promise — handle errors inside the handler.

In-memory state

The example ack map is per process. Across multiple instances use a shared store (Redis, etc.).