Skip to content

Commit

Permalink
refactor: Improve code structure with error handling (#87)
Browse files Browse the repository at this point in the history
* refactor: Separate Gateway, Service, and Repository layers

* feat: Add WebSocket error handling

* fix: Remove test codes
  • Loading branch information
ssum1ra authored Nov 20, 2024
1 parent f397813 commit c7076c8
Show file tree
Hide file tree
Showing 15 changed files with 334 additions and 155 deletions.
22 changes: 10 additions & 12 deletions server/src/chat/chat.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { ConnectedSocket, MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { RedisService } from '../redis/redis.service';
import { ChatService } from './chat.service';
import { UseFilters } from '@nestjs/common';
import { WsExceptionFilter } from 'src/filters/ws-exception.filter';
import { BadRequestException } from 'src/exceptions/game.exception';

@WebSocketGateway({
cors: '*',
namespace: 'chat',
})
@UseFilters(WsExceptionFilter)
export class ChatGateway {
constructor(private readonly redisService: RedisService) {}
constructor(private readonly chatService: ChatService) {}

@WebSocketServer()
server: Server;
Expand All @@ -16,7 +20,7 @@ export class ChatGateway {
const roomId = client.handshake.auth.roomId;
const playerId = client.handshake.auth.playerId;

if (!roomId || !playerId) return;
if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required');

client.data.roomId = roomId;
client.data.playerId = playerId;
Expand All @@ -32,16 +36,10 @@ export class ChatGateway {
const roomId = client.data.roomId;
const playerId = client.data.playerId;

if (!roomId || !playerId) return;
if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required');

const player = await this.redisService.hgetall(`room:${roomId}:players:${playerId}`);
if (!player) return;
const chatResponse = await this.chatService.sendMessage(roomId, playerId, data.message);

client.to(roomId).emit('messageReceived', {
playerId: client.data.playerId,
nickname: player.nickname,
message: data.message,
createdAt: new Date(),
});
client.to(roomId).emit('messageReceived', chatResponse);
}
}
4 changes: 3 additions & 1 deletion server/src/chat/chat.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
import { RedisModule } from 'src/redis/redis.module';
import { ChatService } from './chat.service';
import { ChatRepository } from './chat.repository';

@Module({
imports: [RedisModule],
providers: [ChatGateway]
providers: [ChatGateway, ChatService, ChatRepository]
})
export class ChatModule {}
20 changes: 20 additions & 0 deletions server/src/chat/chat.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
import { RedisService } from "src/redis/redis.service";
import { Player } from "src/common/types/game.types";

@Injectable()
export class ChatRepository {
constructor(private readonly redisService: RedisService) {}

async getPlayer(roomId: string, playerId: string) {
const player = await this.redisService.hgetall(`room:${roomId}:players:${playerId}`);
if (!player || Object.keys(player).length === 0) return null;

return {
...player,
role: player.role === '' ? null : player.role,
profileImage: player.userImg === '' ? null : player.userImg,
score: parseInt(player.score, 10) || 0,
} as Player;
}
}
18 changes: 18 additions & 0 deletions server/src/chat/chat.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ChatService } from './chat.service';

describe('ChatService', () => {
let service: ChatService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ChatService],
}).compile();

service = module.get<ChatService>(ChatService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
24 changes: 24 additions & 0 deletions server/src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { ChatRepository } from './chat.repository';
import { BadRequestException, PlayerNotFoundException } from 'src/exceptions/game.exception';

@Injectable()
export class ChatService {
constructor(private readonly chatRepository: ChatRepository) {}

async sendMessage(roomId: string, playerId: string, message: string) {
if (!message?.trim()) {
throw new BadRequestException('Message cannot be empty');
}

const player = await this.chatRepository.getPlayer(roomId, playerId);
if (!player) throw new PlayerNotFoundException();

return {
playerId,
nickname: player.nickname,
message,
createdAt: new Date(),
};
}
}
17 changes: 17 additions & 0 deletions server/src/common/enums/game.status.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export enum PlayerStatus {
NOT_READY = 'NOT_READY',
READY = 'READY',
PLAYING = 'PLAYING',
}

export enum PlayerRole {
PAINTER = 'PAINTER',
DEVIL = 'DEVIL',
GUESSER = 'GUESSER',
}

export enum RoomStatus {
WAITING = 'WAITING',
IN_GAME = 'IN_GAME',
}

24 changes: 24 additions & 0 deletions server/src/common/enums/socket.error-code.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export enum SocketErrorCode {
BAD_REQUEST = 4000,
UNAUTHORIZED = 4001,
FORBIDDEN = 4003,
NOT_FOUND = 4004,
VALIDATION_ERROR = 4400,
RATE_LIMIT = 4429,

INTERNAL_ERROR = 5000,
NOT_IMPLEMENTED = 5001,
SERVICE_UNAVAILABLE = 5003,

GAME_NOT_STARTED = 6001,
GAME_ALREADY_STARTED = 6002,
INVALID_TURN = 6003,
ROOM_FULL = 6004,
ROOM_NOT_FOUND = 6005,
PLAYER_NOT_FOUND = 6006,
INSUFFICIENT_PLAYERS = 6007,

CONNECTION_ERROR = 7000,
CONNECTION_TIMEOUT = 7001,
CONNECTION_CLOSED = 7002,
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { PlayerRole, PlayerStatus, RoomStatus } from "../enums/game.status.enum";

export interface Player {
playerId: string;
role: PlayerRole | null;
Expand All @@ -22,19 +24,3 @@ export interface RoomSettings {
drawTime: number;
}

export enum PlayerStatus {
NOT_READY = 'NOT_READY',
READY = 'READY',
PLAYING = 'PLAYING',
}

export enum PlayerRole {
PAINTER = 'PAINTER',
DEVIL = 'DEVIL',
GUESSER = 'GUESSER',
}

export enum RoomStatus {
WAITING = 'WAITING',
IN_GAME = 'IN_GAME',
}
8 changes: 6 additions & 2 deletions server/src/draw/draw.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { UseFilters } from '@nestjs/common';
import { ConnectedSocket, MessageBody, OnGatewayConnection, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { BadRequestException } from 'src/exceptions/game.exception';
import { WsExceptionFilter } from 'src/filters/ws-exception.filter';

@WebSocketGateway({
cors: '*',
namespace: 'draw',
})
@UseFilters(WsExceptionFilter)
export class DrawGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
Expand All @@ -13,7 +17,7 @@ export class DrawGateway implements OnGatewayConnection {
const roomId = client.handshake.auth.roomId;
const playerId = client.handshake.auth.playerId;

if (!roomId || !playerId) return;
if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required');

client.data.roomId = roomId;
client.data.playerId = playerId;
Expand All @@ -27,7 +31,7 @@ export class DrawGateway implements OnGatewayConnection {
@MessageBody() data: { drawingData: any },
) {
const roomId = client.data.roomId;
if (!roomId) return;
if (!roomId) throw new BadRequestException('Room ID is required');

client.to(roomId).emit('drawUpdated', {
playerId: client.data.playerId,
Expand Down
34 changes: 34 additions & 0 deletions server/src/exceptions/game.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { SocketErrorCode } from "../common/enums/socket.error-code.enum";

export class GameException extends Error {
constructor(
public readonly code: SocketErrorCode,
message: string,
) {
super(message);
}
}

export class RoomFullException extends GameException {
constructor(message: string = 'Room is full') {
super(SocketErrorCode.ROOM_FULL, message);
}
}

export class RoomNotFoundException extends GameException {
constructor(message: string = 'Room not found') {
super(SocketErrorCode.ROOM_NOT_FOUND, message);
}
}

export class PlayerNotFoundException extends GameException {
constructor(message: string = 'Player not found') {
super(SocketErrorCode.PLAYER_NOT_FOUND, message);
}
}

export class BadRequestException extends GameException {
constructor(message: string = 'Bad request') {
super(SocketErrorCode.BAD_REQUEST, message);
}
}
25 changes: 25 additions & 0 deletions server/src/filters/ws-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseWsExceptionFilter } from '@nestjs/websockets';
import { Socket } from 'socket.io';
import { GameException } from '../exceptions/game.exception';

@Catch()
export class WsExceptionFilter extends BaseWsExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const client = host.switchToWs().getClient<Socket>();

if (exception instanceof GameException) {
client.emit('error', {
code: exception.code,
message: exception.message,
});
} else {
client.emit('error', {
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
}

console.error('WebSocket Error:', exception);
}
}
40 changes: 12 additions & 28 deletions server/src/game/game.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { GameService } from './game.service';
import { UseFilters } from '@nestjs/common';
import { WsExceptionFilter } from 'src/filters/ws-exception.filter';

@WebSocketGateway({
cors: '*',
namespace: 'game',
})
@UseFilters(WsExceptionFilter)
export class GameGateway implements OnGatewayDisconnect {
@WebSocketServer()
server: Server;
Expand All @@ -21,9 +24,7 @@ export class GameGateway implements OnGatewayDisconnect {

@SubscribeMessage('joinRoom')
async handleJoinRoom(@ConnectedSocket() client: Socket, @MessageBody() data: { roomId: string }) {
const { room, roomSettings, player } = await this.gameService.joinRoom(data.roomId);

const players = (await this.gameService.getRoomPlayers(data.roomId)).reverse();
const { room, roomSettings, player, players } = await this.gameService.joinRoom(data.roomId);

client.data.playerId = player.playerId;
client.data.roomId = room.roomId;
Expand All @@ -37,23 +38,9 @@ export class GameGateway implements OnGatewayDisconnect {

@SubscribeMessage('reconnect')
async handleReconnect(@ConnectedSocket() client: Socket, @MessageBody() data: { roomId: string; playerId: string; }) {
const { playerId, roomId } = data;

const room = await this.gameService.getRoom(roomId);
if (!room) {
client.emit('error', { message: 'Room not found' });
return;
}

const players = await this.gameService.getRoomPlayers(roomId);
const existingPlayer = players.find((p) => p.playerId === playerId);
const { roomId, playerId } = data;

const roomSettings = await this.gameService.getRoomSettings(roomId);

if (!existingPlayer) {
client.emit('error', { message: 'Player not found' });
return;
}
const { room, players, roomSettings } = await this.gameService.reconnect(roomId, playerId);

client.data.playerId = playerId;
client.data.roomId = roomId;
Expand All @@ -80,18 +67,15 @@ export class GameGateway implements OnGatewayDisconnect {

setTimeout(async () => {
const sockets = await this.server.fetchSockets();

const isReconnected = sockets.some((socket) => socket.data.playerId === playerId);

if (!isReconnected) {
const updatedRoom = await this.gameService.removePlayer(roomId, playerId);
if (updatedRoom) {
const players = await this.gameService.getRoomPlayers(roomId);
this.server.to(roomId).emit('playerLeft', {
leftPlayerId : playerId,
players,
});
}
const players = await this.gameService.leaveRoom(roomId, playerId);
if (!players) return;
this.server.to(roomId).emit('playerLeft', {
leftPlayerId : playerId,
players,
});
}
}, 10000);
}
Expand Down
3 changes: 2 additions & 1 deletion server/src/game/game.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { GameService } from './game.service';
import { GameController } from './game.controller';
import { GameGateway } from './game.gateway';
import { RedisModule } from 'src/redis/redis.module';
import { GameRepository } from './game.repository';

@Module({
imports: [RedisModule],
providers: [GameService, GameGateway],
providers: [GameService, GameGateway, GameRepository],
controllers: [GameController],
})
export class GameModule {}
Loading

0 comments on commit c7076c8

Please sign in to comment.