Skip to main content

NestJS: websocket and gateway basics

· 8 min read
Vũ Anh Tú
Share to be shared

🎯 Mục tiêu tuần này: Hiểu sâu WebSocket protocol, Socket.io, và NestJS Gateway pattern


1. CHỨC NĂNG CẦN HOÀN THÀNH

STTChức năngMô tảOutput
3.1Setup Socket.ioCài đặt và config WebSocket GatewayConnection works
3.2GameGatewayTạo gateway cho game moduleEvents có thể emit/receive
3.3Room ConceptJoin/Leave room logicClient vào được room
3.4Event BroadcastingGửi message cho room membersReal-time message delivery
3.5Simple Chat DemoChat room để verify concepts2 clients chat được

2. KIẾN THỨC CẦN NẮM VỮNG

2.1 HTTP vs WebSocket

📖 So sánh Protocol

Đặc điểmHTTPWebSocket
ConnectionRequest → Response → ClosePersistent (giữ mở)
CommunicationHalf-duplex (1 chiều tại 1 thời điểm)Full-duplex (2 chiều đồng thời)
InitiationClient luôn là bên bắt đầuCả 2 bên có thể gửi bất kỳ lúc nào
OverheadHeader lớn mỗi requestHandshake 1 lần, sau đó minimal
Use caseREST APIs, web pagesReal-time: chat, games, notifications

Flow comparison:

HTTP:
Client ──Request──> Server
Client <──Response── Server
(Connection closed)

WebSocket:
Client ══════════════════════════ Server
←── Message ───→
←── Message ───→
(Connection stays open)

Tại sao Quiz App cần WebSocket?

  • Host gửi câu hỏi → Tất cả Players nhận ngay lập tức
  • Players trả lời → Host thấy real-time
  • Leaderboard update → Mọi người thấy đồng thời
  • HTTP polling = lag + waste resources

2.2 Socket.io Concepts

📖 Core Terms

TermMô tảVí dụ
Socket1 connection từ clientMỗi browser tab = 1 socket
ServerSocket.io server instanceNestJS Gateway
RoomNhóm sockets1 phòng quiz
NamespaceIsolated channel/admin, /game
EventMessage type'joinRoom', 'newQuestion'

📖 Room Concept

Khái niệm:

  • Room = logical grouping của sockets
  • 1 socket có thể join nhiều rooms
  • Gửi message cho cả room: server.to('room').emit()
// Join room
socket.join('room:123456'); // PIN = 123456

// Leave room
socket.leave('room:123456');

// Rooms của 1 socket
console.log(socket.rooms); // Set { socket.id, 'room:123456' }

📖 Namespace vs Room

Namespace (/game)
├── Room A (quiz:123)
│ ├── Socket 1
│ ├── Socket 2
│ └── Socket 3
└── Room B (quiz:456)
├── Socket 4
└── Socket 5

Khi nào dùng?

  • Namespace: Tách biệt logic hoàn toàn (admin panel vs game)
  • Room: Group sockets trong cùng namespace (players trong 1 quiz)

2.3 NestJS Gateway Pattern

📖 Gateway

Khái niệm:

  • Gateway = WebSocket equivalent của Controller
  • Handle WebSocket connections và events
  • Decorate với @WebSocketGateway()

Core Component:

import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
ConnectedSocket,
MessageBody,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({
cors: { origin: '*' }, // CORS config
namespace: '/game', // Optional namespace
})
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {

@WebSocketServer()
server: Server; // Socket.io server instance

// Called when client connects
handleConnection(client: Socket) {
console.log(`Client connected: ${client.id}`);
}

// Called when client disconnects
handleDisconnect(client: Socket) {
console.log(`Client disconnected: ${client.id}`);
}

// Handle custom event
@SubscribeMessage('joinRoom')
handleJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() data: { pin: string; nickname: string },
) {
const roomId = `room:${data.pin}`;
client.join(roomId);

// Notify others in room
client.to(roomId).emit('playerJoined', {
nickname: data.nickname,
});

return { success: true, roomId };
}
}

2.4 Event Patterns

📖 Emit Types

// 1. Emit to sender only
client.emit('event', data);

// 2. Broadcast to everyone EXCEPT sender
client.broadcast.emit('event', data);

// 3. Emit to everyone in a room
this.server.to('room:123').emit('event', data);

// 4. Emit to everyone (including sender)
this.server.emit('event', data);

// 5. Emit to specific socket
this.server.to(socketId).emit('event', data);

Visual:

Room: quiz:123
┌─────────────────────────────────────────┐
│ Socket A (sender) Socket B Socket C │
└─────────────────────────────────────────┘

client.emit() → A only
client.broadcast → B, C
server.to('quiz:123') → A, B, C
client.to('quiz:123') → B, C (not A)

📖 Acknowledgement

// Server side
@SubscribeMessage('checkAnswer')
handleCheckAnswer(@MessageBody() data: any) {
const isCorrect = this.checkAnswer(data);
return { correct: isCorrect, score: 100 }; // Automatically acknowledged
}

// Client side (JS)
socket.emit('checkAnswer', { questionId: 1, answer: 2 }, (response) => {
console.log(response); // { correct: true, score: 100 }
});

2.5 Gateway Lifecycle Hooks

@WebSocketGateway()
export class GameGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {

// After gateway is initialized
afterInit(server: Server) {
console.log('Gateway initialized');
}

// When new client connects
handleConnection(client: Socket, ...args: any[]) {
console.log(`Connected: ${client.id}`);
console.log(`Total connections: ${this.server.engine.clientsCount}`);
}

// When client disconnects
handleDisconnect(client: Socket) {
console.log(`Disconnected: ${client.id}`);
// Cleanup: remove from rooms, notify others
}
}

3. STEP BY STEP TRIỂN KHAI

Bước 1: Cài đặt Dependencies

npm install @nestjs/websockets @nestjs/platform-socket.io socket.io

Bước 2: Tạo GameModule và Gateway

nest generate module game
nest generate gateway game/game

Bước 3: Setup Basic Gateway

// src/game/game.gateway.ts
@WebSocketGateway({
cors: {
origin: '*',
},
})
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;

private logger = new Logger('GameGateway');

handleConnection(client: Socket) {
this.logger.log(`Client connected: ${client.id}`);
}

handleDisconnect(client: Socket) {
this.logger.log(`Client disconnected: ${client.id}`);
}

@SubscribeMessage('ping')
handlePing(@ConnectedSocket() client: Socket) {
return { event: 'pong', data: { timestamp: Date.now() } };
}
}

Bước 4: Implement Room Logic

// src/game/game.gateway.ts
@SubscribeMessage('createRoom')
handleCreateRoom(
@ConnectedSocket() client: Socket,
@MessageBody() data: { quizId: string },
) {
// Generate random PIN
const pin = Math.random().toString().substring(2, 8);
const roomId = `room:${pin}`;

client.join(roomId);
client.data.isHost = true;
client.data.roomId = roomId;

this.logger.log(`Room created: ${pin}`);

return { success: true, pin };
}

@SubscribeMessage('joinRoom')
handleJoinRoom(
@ConnectedSocket() client: Socket,
@MessageBody() data: { pin: string; nickname: string },
) {
const roomId = `room:${data.pin}`;

// Check if room exists
const room = this.server.sockets.adapter.rooms.get(roomId);
if (!room) {
return { success: false, error: 'Room not found' };
}

client.join(roomId);
client.data.nickname = data.nickname;
client.data.roomId = roomId;

// Notify host and other players
client.to(roomId).emit('playerJoined', {
nickname: data.nickname,
playersCount: room.size,
});

return { success: true };
}

@SubscribeMessage('sendMessage')
handleMessage(
@ConnectedSocket() client: Socket,
@MessageBody() data: { message: string },
) {
const roomId = client.data.roomId;
if (!roomId) {
return { success: false, error: 'Not in a room' };
}

// Broadcast to room (including sender)
this.server.to(roomId).emit('newMessage', {
from: client.data.nickname || client.id,
message: data.message,
timestamp: Date.now(),
});

return { success: true };
}

Bước 5: Tạo Simple Test Client

<!-- test-client.html (để test trong browser) -->
<!DOCTYPE html>
<html>
<head>
<title>Socket.io Test</title>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
</head>
<body>
<h1>WebSocket Test Client</h1>

<div>
<input id="nickname" placeholder="Nickname">
<input id="pin" placeholder="Room PIN">
<button onclick="joinRoom()">Join Room</button>
<button onclick="createRoom()">Create Room</button>
</div>

<div>
<input id="message" placeholder="Message">
<button onclick="sendMessage()">Send</button>
</div>

<div id="log"></div>

<script>
const socket = io('http://localhost:3000');

socket.on('connect', () => {
log('Connected: ' + socket.id);
});

socket.on('playerJoined', (data) => {
log('Player joined: ' + data.nickname);
});

socket.on('newMessage', (data) => {
log(`${data.from}: ${data.message}`);
});

function createRoom() {
socket.emit('createRoom', { quizId: '123' }, (res) => {
log('Room created: PIN = ' + res.pin);
document.getElementById('pin').value = res.pin;
});
}

function joinRoom() {
const pin = document.getElementById('pin').value;
const nickname = document.getElementById('nickname').value;
socket.emit('joinRoom', { pin, nickname }, (res) => {
log('Joined: ' + JSON.stringify(res));
});
}

function sendMessage() {
const message = document.getElementById('message').value;
socket.emit('sendMessage', { message });
}

function log(msg) {
document.getElementById('log').innerHTML += msg + '<br>';
}
</script>
</body>
</html>

4. VERIFY CHECKLIST

📋 Câu hỏi LÝ THUYẾT

#Câu hỏiĐáp án mong đợi
1WebSocket khác HTTP ở điểm nào quan trọng nhất?Persistent connection, full-duplex, server có thể push
2Room trong Socket.io dùng để làm gì?Group sockets, gửi message cho nhóm cụ thể
3client.emit() vs client.broadcast.emit() khác gì?emit → sender only, broadcast → tất cả trừ sender
4Namespace khác Room như thế nào?Namespace tách biệt hoàn toàn, Room group trong namespace
5@SubscribeMessage() decorator làm gì?Đăng ký handler cho event cụ thể
6Lifecycle hooks nào có trong Gateway?OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect

📋 Câu hỏi THỰC HÀNH

#Kiểm traCách verify
1Gateway hoạt động?Connect socket client, thấy log "Connected"
2Create room được?Nhận được PIN, client join room thành công
3Join room được?Client khác nhận event 'playerJoined'
4Broadcast hoạt động?Send message, tất cả trong room nhận được
5Disconnect hoạt động?Close tab, server log "Disconnected"

5. MỞ RỘNG - BEST PRACTICES

5.1 Error Handling trong WebSocket

import { WsException } from '@nestjs/websockets';

@SubscribeMessage('joinRoom')
handleJoinRoom(@MessageBody() data: any) {
if (!data.pin) {
throw new WsException('PIN is required');
}
// ...
}

5.2 Authentication cho WebSocket

@WebSocketGateway()
export class GameGateway {

async handleConnection(client: Socket) {
try {
const token = client.handshake.auth.token;
const user = await this.authService.verifyToken(token);
client.data.user = user;
} catch (e) {
client.emit('error', { message: 'Unauthorized' });
client.disconnect();
}
}
}

// Client side
const socket = io('http://localhost:3000', {
auth: { token: 'jwt-token-here' }
});

5.3 Typing với DTOs

// dto/join-room.dto.ts
export class JoinRoomDto {
@IsString()
pin: string;

@IsString()
@MinLength(2)
nickname: string;
}

// Gateway
@SubscribeMessage('joinRoom')
@UsePipes(new ValidationPipe())
handleJoinRoom(@MessageBody() dto: JoinRoomDto) {
// dto is validated
}

5.4 Testing Gateway

// game.gateway.spec.ts
describe('GameGateway', () => {
let gateway: GameGateway;
let mockSocket: Partial<Socket>;

beforeEach(() => {
mockSocket = {
id: 'test-socket-id',
join: jest.fn(),
data: {},
};
});

it('should create room with PIN', () => {
const result = gateway.handleCreateRoom(
mockSocket as Socket,
{ quizId: '123' }
);
expect(result.success).toBe(true);
expect(result.pin).toMatch(/^\d{6}$/);
});
});

5.5 Common Mistakes

❌ Mistake✅ Solution
Không cleanup khi disconnectImplement handleDisconnect properly
Gửi sensitive data qua socketValidate và sanitize tất cả data
Memory leak (event listeners)Remove listeners khi socket disconnect
Không handle reconnectionLưu state, cho phép rejoin

📅 Timeline Tuần 3

NgàyHoạt độngThời gian
Ngày 1-2Đọc docs WebSocket, Socket.io2-3 giờ
Ngày 3-4Setup Gateway + Room logic3-4 giờ
Ngày 5-6Test với browser client2-3 giờ
Ngày 7Review + Practice1-2 giờ

💡 Tip: Mở 2 browser tabs để test room joining và message broadcasting. Console log là cách tốt nhất để hiểu event flow.