With WebSockets, we can perform a two-way communication in real-time between the user and the server. Thanks to that, the browser can send messages to the server and listen to information from the other side.
The principles of the WebSocket handshake
WebSocket is a protocol that operates in a different way than HTTP. Even though that’s the case, establishing the connection begins with the client sending an HTTP call that we call a handshake.
The server listens for incoming socket connections using a regular TCP socket. The client sends a GET request to the URL of our socket.
1Request URL: ws://localhost:8080/
2Request Method: GET1Headers: Connection: Upgrade
2Upgrade: websocket
3Sec-WebSocket-Key: 2GruKa/C487njkWNw2HKxQ==Above, we can see the Connection: Upgrade and Upgrade: websocket headers. The server understands that the client requests to upgrade the protocol from HTTP to WebSocket. After receiving the above request, the server responds with an indication that the protocol will change from HTTP to WebSocket. The status code of the response is Status Code: 101 Switching Protocols.
1Headers: Connection: Upgrade
2Upgrade: websocket
3Sec-WebSocket-Accept: aue6dyRHSJ/yBtny+BQRe0lHOu0=In the request, we can also see the Sec-WebSocket-Key header that contains random bytes. The browser adds it to prevent the cache proxy from responding with a previous WebSocket connection. The server hashes the value of the Sec-WebSocket-Key and sends the value through the Sec-WebSocket-Accept. Thanks to that, the client can make sure that it got the correct response.
Implementing the chat functionality in NestJS
In the Node.js world, there are two major solutions to implementing WebSockets. The first of them is called was, and it uses bare WebSockets protocol. The other one is socket.io that provides more features through an additional abstraction.
Currently, the implementation of socket.io for NestJS seems to be more popular than the implementation of ws. Therefore, in this article, we use socket.io.
1npm install @nestjs/websockets @nestjs/platform-socket.io @types/socket.ioCurrently, NestJS does not use the version 3.x of socket.io. Therefore, you need to use the version 2.x of the socket.io-client library on your frontend
The first step in working with WebSockets in NestJS is creating a gateway. Its job is to receive and send messages.
1import {
2 MessageBody,
3 SubscribeMessage,
4 WebSocketGateway,
5 WebSocketServer,
6} from '@nestjs/websockets';
7import { Server } from 'socket.io';
8
9@WebSocketGateway()
10export class ChatGateway {
11 @WebSocketServer()
12 server: Server;
13
14 @SubscribeMessage('send_message')
15 listenForMessages(@MessageBody() data: string) {
16 this.server.sockets.emit('receive_message', data);
17 }
18}In this simple example above, we listen to any incoming send_message events. When that happens, we populate this message to all connected clients. Doing that already gives us a straightforward chat functionality.
In the 24th part of this series, we’ve learned how to use a cluster to run multiple instances of our application. If you implement that approach, you might have trouble when using Socket.IO. To deal with it, you would have to use socket.io-redis, as explained in the official documentation.
Authenticating users
The first thing that we would want to add above is authentication. The most straightforward way of approaching it in our current architecture would be to get the authentication token from the cookies.
If you want to know how we implemented the authentication with cookies, check out API with NestJS #3. Authenticating users with bcrypt, Passport, JWT, and cookies
From the first paragraph of this article, we know that the initial handshake is a regular HTTP request. We can access it along with its headers. To parse the cookie, we use the cookie library.
1npm install cookie @types/cookie1import { Injectable } from '@nestjs/common';
2import { AuthenticationService } from '../authentication/authentication.service';
3import { Socket } from 'socket.io';
4import { parse } from 'cookie';
5import { WsException } from '@nestjs/websockets';
6
7@Injectable()
8export class ChatService {
9 constructor(
10 private readonly authenticationService: AuthenticationService,
11 ) {
12 }
13
14 async getUserFromSocket(socket: Socket) {
15 const cookie = socket.handshake.headers.cookie;
16 const { Authentication: authenticationToken } = parse(cookie);
17 const user = await this.authenticationService.getUserFromAuthenticationToken(authenticationToken);
18 if (!user) {
19 throw new WsException('Invalid credentials.');
20 }
21 return user;
22 }
23}Above, we use the authenticationService.getUserFromAuthenticationToken method. Let’s implement it also.
1import { Injectable } from '@nestjs/common';
2import { UsersService } from '../users/users.service';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import TokenPayload from './tokenPayload.interface';
6
7@Injectable()
8export class AuthenticationService {
9 constructor(
10 private readonly usersService: UsersService,
11 private readonly jwtService: JwtService,
12 private readonly configService: ConfigService
13 ) {}
14
15 public async getUserFromAuthenticationToken(token: string) {
16 const payload: TokenPayload = this.jwtService.verify(token, {
17 secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET')
18 });
19 if (payload.userId) {
20 return this.usersService.getById(payload.userId);
21 }
22 }
23
24 // ...
25}To use the getUserFromSocket method, we need to provide it with the current socket. We can do that in the handleConnection method of our ChatGateway if it implements the OnGatewayConnection interface.
1import {
2 MessageBody, OnGatewayConnection,
3 SubscribeMessage,
4 WebSocketGateway,
5 WebSocketServer,
6} from '@nestjs/websockets';
7import { Server, Socket } from 'socket.io';
8import { ChatService } from './chat.service';
9
10@WebSocketGateway()
11export class ChatGateway implements OnGatewayConnection {
12 @WebSocketServer()
13 server: Server;
14
15 constructor(
16 private readonly chatService: ChatService
17 ) {
18 }
19
20 async handleConnection(socket: Socket) {
21 await this.chatService.getUserFromSocket(socket);
22 }
23
24 @SubscribeMessage('send_message')
25 listenForMessages(@MessageBody() data: string) {
26 this.server.sockets.emit('receive_message', data);
27 }
28}We can also use the above to authenticate users when they post messages. To do that, let’s modify our listenForMessages method.
1import {
2 ConnectedSocket,
3 MessageBody, OnGatewayConnection,
4 SubscribeMessage,
5 WebSocketGateway,
6 WebSocketServer,
7} from '@nestjs/websockets';
8import { Server, Socket } from 'socket.io';
9import { ChatService } from './chat.service';
10
11@WebSocketGateway()
12export class ChatGateway implements OnGatewayConnection {
13 @WebSocketServer()
14 server: Server;
15
16 constructor(
17 private readonly chatService: ChatService
18 ) {
19 }
20
21 async handleConnection(socket: Socket) {
22 await this.chatService.getUserFromSocket(socket);
23 }
24
25 @SubscribeMessage('send_message')
26 async listenForMessages(
27 @MessageBody() content: string,
28 @ConnectedSocket() socket: Socket,
29 ) {
30 const author = await this.chatService.getUserFromSocket(socket);
31
32 this.server.sockets.emit('receive_message', {
33 content,
34 author
35 });
36 }
37}Now, our users receive both the content of the messages in the chat and the information about the author.
Persisting the messages in the database
So far, we’ve only forwarded incoming messages to all of the connected users. Any new users that join the conversation wouldn’t be able to view its history. To improve that, we need to save all of the messages in the database.
1import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
2import User from '../users/user.entity';
3
4@Entity()
5class Message {
6 @PrimaryGeneratedColumn()
7 public id: number;
8
9 @Column()
10 public content: string;
11
12 @ManyToOne(() => User)
13 public author: User;
14}
15
16export default Message;We also need to implement the logic of saving and retrieving messages. Let’s do that in our ChatService:
1import { Injectable } from '@nestjs/common';
2import { AuthenticationService } from '../authentication/authentication.service';
3import { InjectRepository } from '@nestjs/typeorm';
4import Message from './message.entity';
5import User from '../users/user.entity';
6import { Repository } from 'typeorm';
7
8@Injectable()
9export class ChatService {
10 constructor(
11 private readonly authenticationService: AuthenticationService,
12 @InjectRepository(Message)
13 private messagesRepository: Repository<Message>,
14 ) {
15 }
16
17 async saveMessage(content: string, author: User) {
18 const newMessage = await this.messagesRepository.create({
19 content,
20 author
21 });
22 await this.messagesRepository.save(newMessage);
23 return newMessage;
24 }
25
26 async getAllMessages() {
27 return this.messagesRepository.find({
28 relations: ['author']
29 });
30 }
31
32 // ...
33}The last thing is to use the above functionalities in our ChatGateway:
1import {
2 ConnectedSocket,
3 MessageBody, OnGatewayConnection,
4 SubscribeMessage,
5 WebSocketGateway,
6 WebSocketServer,
7} from '@nestjs/websockets';
8import { Server, Socket } from 'socket.io';
9import { ChatService } from './chat.service';
10
11@WebSocketGateway()
12export class ChatGateway implements OnGatewayConnection {
13 @WebSocketServer()
14 server: Server;
15
16 constructor(
17 private readonly chatService: ChatService
18 ) {
19 }
20
21 async handleConnection(socket: Socket) {
22 await this.chatService.getUserFromSocket(socket);
23 }
24
25 @SubscribeMessage('send_message')
26 async listenForMessages(
27 @MessageBody() content: string,
28 @ConnectedSocket() socket: Socket,
29 ) {
30 const author = await this.chatService.getUserFromSocket(socket);
31 const message = await this.chatService.saveMessage(content, author);
32
33 this.server.sockets.emit('receive_message', message);
34
35 return message;
36 }
37
38 @SubscribeMessage('request_all_messages')
39 async requestAllMessages(
40 @ConnectedSocket() socket: Socket,
41 ) {
42 await this.chatService.getUserFromSocket(socket);
43 const messages = await this.chatService.getAllMessages();
44
45 socket.emit('send_all_messages', messages);
46 }
47}Our clients need to emit the request_all_messages event as soon as they connect for the above to work.
By returning the message object from the listenForMessages method we send and acknowledgment stating that we’ve receive a message correctly.
Summary
In this article, we’ve implemented a chat functionality. To do that, we’ve also learned how WebSockets work and what is a handshake. Although our chat is working, it is still quite basic. For example, it could be improved by adding information about the time of the message. Feel free to experiment and add your own features.