├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── img └── home.jpg ├── nest-cli.json ├── package.json ├── src ├── app.module.ts ├── chat │ ├── chat.dto.ts │ ├── chat.module.ts │ ├── chat.service.ts │ ├── chat.websocket.gateway.ts │ └── rooms.controller.ts └── main.ts ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Chat server with nestJs and websocket 2 | 3 | ![home](img/home.jpg) 4 | 5 | Chat server using WebSockets with [Nest](https://github.com/nestjs/nest) framework. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | npm install 11 | ``` 12 | 13 | ## Running the app 14 | 15 | ```bash 16 | # development 17 | npm run start 18 | 19 | # watch mode 20 | npm run start:dev 21 | ``` 22 | 23 | ## Required packages 24 | 25 | ```shell 26 | npm i --save @nestjs/websockets @nestjs/platform-socket.io ngx-socket-io 27 | npm i --save-dev @types/socket.io 28 | ``` 29 | 30 | ## Web Socket Gateway 31 | 32 | - ### Init: 33 | We add `@WebSocketServer() server;` inside of our [ChatWebsocketGateway](/src/chat/chat.websocket.gateway.ts) to 34 | attaches a native Web Socket Server to our property `server` and then we use the decorator 35 | `@WebSocketGateway(port?: number)`to mark our **WebsocketGateway** class as a **Nest gateway** that enables 36 | **real-time**, **bidirectional** and **event-based communication** between the browser and the server 37 | 38 | - ### Handlers: 39 | In order de hand the connection and disconnection at our websocket server we need to implement interfaces 40 | `OnGatewayConnection` and `OnGatewayDisconnect`. 41 | 42 | - ### Subscribers: 43 | We use decorator `@SubscribeMessage('exmple-channel')` on the method that handles our business rules on `exmple-channel` events, 44 | for example we use `@SubscribeMessage('chat')` to cap and handle the chat events. You can implement a custom subscriber as: 45 | `@SubscribeMessage({channel: 'messages', type: 'group'})` in order to hand messages event of groups only. 46 | 47 | ## APIs 48 | 49 | [RoomsController](/src/chat/rooms.controller.ts) 50 | 51 | - ### Create room: 52 | 53 | #### Resource: 54 | /api/v1/rooms 55 | 56 | #### Body: 57 | roomId: the room id (room name) 58 | creatorUsername: the username with creats the room 59 | 60 | #### Example: 61 | ```shell 62 | curl -X POST 'http://localhost:3000/api/v1/rooms' \ 63 | --data-raw '{ 64 | "roomId": "3XX", 65 | "creatorUsername": "idirnaitali" 66 | }' 67 | ``` 68 | #### Error cases: 69 | 70 | - Invalid body: 71 | ````json 72 | { 73 | "statusCode": 400, 74 | "message": [ 75 | "roomId should not be empty", 76 | "creatorUsername should not be empty" 77 | ], 78 | "error": "Bad Request" 79 | } 80 | ```` 81 | 82 | - Existing room id: 83 | ````json 84 | { 85 | "code": "room.conflict", 86 | "message": "Room with 'exmple-room' already exists" 87 | } 88 | ```` 89 | 90 | - ### Get room messages: 91 | 92 | #### Resource: 93 | /api/v1/rooms/{roomId}/messages?fromIndex={fromIndex}&toIndex={fromIndex} 94 | 95 | #### Params: 96 | roomId: the room id 97 | fromIndex: the index of the first message to get 98 | fromIndex: the index of the last message to get 99 | 100 | #### Example: 101 | ```shell 102 | curl -X GET 'http://localhost:3000/api/v1/rooms/3XX/messages?fromIndex=1&toIndex=20' 103 | ``` 104 | 105 | #### Error cases: 106 | 107 | - Invalid room id (ex: not found or closed): 108 | ````json 109 | { 110 | "code": "access-forbidden", 111 | "message": "The access is forbidden" 112 | } 113 | ```` 114 | 115 | - Missing `fromIndex` / `toIndex`: 116 | ````json 117 | { 118 | "statusCode": 400, 119 | "message": "Validation failed (numeric string is expected)", 120 | "error": "Bad Request" 121 | } 122 | ```` 123 | 124 | - Invalid `fromIndex` / `toIndex`: 125 | ````json 126 | { 127 | "code": "req-params.validation", 128 | "message": "Invalid parameters, 'fromIndex' and 'toIndex' must be positive" 129 | } 130 | ```` 131 | 132 | - Invalid `fromIndex` / `toIndex`: 133 | ````json 134 | { 135 | "code": "req-params.validation", 136 | "message": "Invalid parameters, 'toIndex' must no not be less than 'fromIndex'" 137 | } 138 | ```` 139 | 140 | - ### Close room (Delete room): 141 | 142 | ### Resource: 143 | /api/v1/rooms/{roomId} 144 | 145 | ### Params: 146 | roomId: the room id 147 | 148 | ### Example: 149 | 150 | ```shell 151 | curl -X DELETE http://localhost:3000/api/v1/rooms/3XX 152 | ``` 153 | 154 | #### Error cases: 155 | - Invalid room id (ex: not found or closed): 156 | ````json 157 | { 158 | "code": "room.not-fond", 159 | "message": "Room with '3XX' not found" 160 | } 161 | ```` 162 | -------------------------------------------------------------------------------- /img/home.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/idirnaitali/chat-server-with-nestjs-and-websocket/cc7970e5187e6bbd7cd4e19a05136c77debb386c/img/home.jpg -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-server-with-nestjs-and-websocket", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "Idir NAIT ALI", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" 16 | }, 17 | "dependencies": { 18 | "@nestjs/common": "^7.0.0", 19 | "@nestjs/core": "^7.0.0", 20 | "@nestjs/platform-express": "^7.0.0", 21 | "@nestjs/platform-socket.io": "^7.6.12", 22 | "@nestjs/websockets": "^7.6.12", 23 | "class-transformer": "^0.4.0", 24 | "class-validator": "^0.13.1", 25 | "ngx-toastr": "^13.2.0", 26 | "reflect-metadata": "^0.1.13", 27 | "rimraf": "^3.0.2", 28 | "rxjs": "^6.5.4" 29 | }, 30 | "devDependencies": { 31 | "@nestjs/cli": "^7.0.0", 32 | "@nestjs/schematics": "^7.0.0", 33 | "@nestjs/testing": "^7.0.0", 34 | "@types/express": "^4.17.3", 35 | "@types/node": "^13.9.1", 36 | "@types/socket.io": "^2.1.13", 37 | "@types/supertest": "^2.0.8", 38 | "@typescript-eslint/eslint-plugin": "3.0.2", 39 | "@typescript-eslint/parser": "3.0.2", 40 | "eslint": "7.1.0", 41 | "eslint-config-prettier": "^6.10.0", 42 | "eslint-plugin-import": "^2.20.1", 43 | "prettier": "^1.19.1", 44 | "supertest": "^4.0.2", 45 | "ts-loader": "^6.2.1", 46 | "ts-node": "^8.6.2", 47 | "tsconfig-paths": "^3.9.0", 48 | "typescript": "^3.7.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ChatModule } from './chat/chat.module'; 3 | import {RoomsController} from "./chat/rooms.controller"; 4 | import {ChatWebsocketGateway} from "./chat/chat.websocket.gateway"; 5 | 6 | @Module({ 7 | imports: [ChatModule], 8 | controllers: [RoomsController], 9 | providers: [ChatWebsocketGateway], 10 | }) 11 | export class AppModule {} 12 | -------------------------------------------------------------------------------- /src/chat/chat.dto.ts: -------------------------------------------------------------------------------- 1 | import {IsNotEmpty} from 'class-validator'; 2 | 3 | export interface MessageEventDto extends MessageDto { 4 | socketId?: string; 5 | roomId: string; 6 | avatar: string; 7 | } 8 | 9 | export interface MessageDto { 10 | order: number 11 | username: string; 12 | content: string; 13 | createdAt: Date; 14 | } 15 | 16 | export interface ChatDto extends MessageDto { 17 | socketId?: string; 18 | roomId: string; 19 | avatar: string; 20 | } 21 | 22 | export interface Participant { 23 | roomId: string; 24 | username: string; 25 | avatar: string; 26 | connected: boolean; 27 | } 28 | 29 | export class RoomData { 30 | createdBy: string; 31 | createdDate: Date; 32 | messages: Array; 33 | participants: Map; // sockedId => Participant 34 | 35 | constructor(createdBy: string) { 36 | this.createdBy = createdBy; 37 | this.createdDate = new Date(); 38 | this.messages = new Array(); 39 | this.participants = new Map(); 40 | } 41 | } 42 | 43 | export class RoomDto { 44 | @IsNotEmpty() 45 | roomId: string; 46 | @IsNotEmpty() 47 | creatorUsername: string; 48 | } 49 | 50 | export function toMessageDto(value: MessageEventDto) { 51 | const {order, username, content, createdAt} = value; 52 | return {order, username, content, createdAt} 53 | } 54 | -------------------------------------------------------------------------------- /src/chat/chat.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | @Module({}) 4 | export class ChatModule {} 5 | -------------------------------------------------------------------------------- /src/chat/chat.service.ts: -------------------------------------------------------------------------------- 1 | import {ConflictException, NotFoundException} from '@nestjs/common'; 2 | import {MessageDto, toMessageDto} from "./chat.dto"; 3 | import {ChatWebsocketGateway} from "./chat.websocket.gateway"; 4 | 5 | export class ChatService { 6 | 7 | constructor() { 8 | } 9 | 10 | getMessages(roomId: string, fromIndex: number, toIndex: number): MessageDto[] { 11 | const room = ChatWebsocketGateway.get(roomId) 12 | if (!room) { 13 | throw new NotFoundException({code: 'room.not-fond', message: 'Room not found'}) 14 | } 15 | return room.messages 16 | .filter((value, index) => index >= fromIndex - 1 && index < toIndex) 17 | .map(toMessageDto); 18 | } 19 | 20 | } 21 | 22 | export const chatService = new ChatService(); 23 | -------------------------------------------------------------------------------- /src/chat/chat.websocket.gateway.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnGatewayConnection, 3 | OnGatewayDisconnect, 4 | SubscribeMessage, 5 | WebSocketGateway, 6 | WebSocketServer, 7 | } from '@nestjs/websockets'; 8 | import {Socket} from 'socket.io'; 9 | import {ConflictException, ForbiddenException, NotFoundException} from '@nestjs/common'; 10 | import {Participant, ChatDto, toMessageDto, RoomData, RoomDto} from "./chat.dto"; 11 | 12 | @WebSocketGateway() 13 | export class ChatWebsocketGateway implements OnGatewayConnection, OnGatewayDisconnect { 14 | 15 | @WebSocketServer() server; 16 | 17 | private static rooms: Map = new Map(); 18 | private static participants: Map = new Map(); // sockedId => roomId 19 | 20 | handleConnection(socket: Socket): void { 21 | const socketId = socket.id; 22 | console.log(`New connecting... socket id:`, socketId); 23 | ChatWebsocketGateway.participants.set(socketId, ''); 24 | } 25 | 26 | handleDisconnect(socket: Socket): void { 27 | const socketId = socket.id; 28 | console.log(`Disconnection... socket id:`, socketId); 29 | const roomId = ChatWebsocketGateway.participants.get(socketId); 30 | const room = ChatWebsocketGateway.rooms.get(roomId); 31 | if (room) { 32 | room.participants.get(socketId).connected = false; 33 | this.server.emit( 34 | `participants/${roomId}`, 35 | Array.from(room.participants.values()), 36 | ); 37 | } 38 | } 39 | 40 | @SubscribeMessage('participants') 41 | async onParticipate(socket: Socket, participant: Participant) { 42 | const socketId = socket.id; 43 | console.log( 44 | `Registering new participant... socket id: %s and participant: `, 45 | socketId, 46 | participant, 47 | ); 48 | 49 | const roomId = participant.roomId; 50 | if (!ChatWebsocketGateway.rooms.has(roomId)) { 51 | console.error('Room with id: %s was not found, disconnecting the participant', roomId); 52 | socket.disconnect(); 53 | throw new ForbiddenException('The access is forbidden'); 54 | } 55 | 56 | const room = ChatWebsocketGateway.rooms.get(roomId); 57 | ChatWebsocketGateway.participants.set(socketId, roomId); 58 | participant.connected = true; 59 | room.participants.set(socketId, participant); 60 | // when received new participant we notify the chatter by room 61 | this.server.emit( 62 | `participants/${roomId}`, 63 | Array.from(room.participants.values()), 64 | ); 65 | } 66 | 67 | @SubscribeMessage('exchanges') 68 | async onMessage(socket: Socket, message: ChatDto) { 69 | const socketId = socket.id; 70 | message.socketId = socketId; 71 | console.log( 72 | 'Received new message... socketId: %s, message: ', 73 | socketId, 74 | message, 75 | ); 76 | const roomId = message.roomId; 77 | const roomData = ChatWebsocketGateway.rooms.get(roomId); 78 | message.order = roomData.messages.length + 1; 79 | roomData.messages.push(message); 80 | ChatWebsocketGateway.rooms.set(roomId, roomData); 81 | // when received message we notify the chatter by room 82 | this.server.emit(roomId, toMessageDto(message)); 83 | } 84 | 85 | static get(roomId: string): RoomData { 86 | return this.rooms.get(roomId); 87 | } 88 | 89 | static createRoom(roomDto: RoomDto): void { 90 | const roomId = roomDto.roomId; 91 | if (this.rooms.has(roomId)) { 92 | throw new ConflictException({code: 'room.conflict', message: `Room with '${roomId}' already exists`}) 93 | } 94 | this.rooms.set(roomId, new RoomData(roomDto.creatorUsername)); 95 | } 96 | 97 | static close(roomId: string) { 98 | if (!this.rooms.has(roomId)) { 99 | throw new NotFoundException({code: 'room.not-fond', message: `Room with '${roomId}' not found`}) 100 | } 101 | this.rooms.delete(roomId); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/chat/rooms.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Controller, 4 | DefaultValuePipe, 5 | Delete, 6 | Get, 7 | Param, 8 | ParseIntPipe, 9 | Query, 10 | HttpCode, Post, ForbiddenException, Body 11 | } from "@nestjs/common"; 12 | 13 | import {chatService} from "./chat.service"; 14 | import {ChatWebsocketGateway} from "./chat.websocket.gateway"; 15 | import {RoomDto} from "./chat.dto"; 16 | 17 | interface Status { 18 | status: string; 19 | message: string; 20 | } 21 | 22 | export interface MessageDto { 23 | username: string; 24 | content: string; 25 | createdAt: Date; 26 | } 27 | 28 | @Controller('/api/v1/rooms') 29 | export class RoomsController { 30 | 31 | @Post() 32 | @HttpCode(201) 33 | createRoom(@Body() roomDto: RoomDto): void { 34 | console.log("Creating chat room...", roomDto); 35 | try { 36 | return ChatWebsocketGateway.createRoom(roomDto); 37 | } catch (e) { 38 | console.error('Failed to initiate room', e); 39 | throw e; 40 | } 41 | } 42 | 43 | @Get('/:roomId/messages') 44 | getRoomMessages(@Param('roomId') roomId: string, 45 | @Query('fromIndex', new ParseIntPipe(), new DefaultValuePipe(0)) fromIndex: number, 46 | @Query('toIndex', new ParseIntPipe(), new DefaultValuePipe(0)) toIndex: number): MessageDto[] { 47 | console.log("Retrieving room messages with roomId: %s and indexes from: %s to %s", roomId, fromIndex, toIndex); 48 | 49 | if (fromIndex <= 0 || toIndex <= 0) { 50 | this.throwBadRequestException('req-params.validation', "Invalid parameters, 'fromIndex' and 'toIndex' must be positive"); 51 | } 52 | if (fromIndex > toIndex) { 53 | this.throwBadRequestException('req-params.validation', "Invalid parameters, 'toIndex' must no not be less than 'fromIndex'"); 54 | } 55 | 56 | try { 57 | return chatService.getMessages(roomId, fromIndex, toIndex); 58 | } catch (e) { 59 | console.error('Failed to get room messages', e); 60 | throw new ForbiddenException({code: 'access-forbidden', message: 'The access is forbidden'}); 61 | } 62 | } 63 | 64 | @Delete('/:roomId') 65 | @HttpCode(204) 66 | closeRoom(@Param('roomId') roomId: string): void { 67 | console.log("Deleting room with roomId:", roomId); 68 | try { 69 | ChatWebsocketGateway.close(roomId); 70 | } catch (e) { 71 | console.error('Failed to close room', e); 72 | throw e; 73 | } 74 | } 75 | 76 | private throwBadRequestException(code: string, message: string) { 77 | throw new BadRequestException({code, message}); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {NestFactory} from '@nestjs/core'; 2 | import {AppModule} from './app.module'; 3 | import {ValidationPipe} from "@nestjs/common"; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.useGlobalPipes(new ValidationPipe()); 8 | await app.listen(3000); 9 | } 10 | bootstrap(); 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------