Skip to main content

Socket Connections

In the first lab, we saw how the various pieces of Socket.io and Nest fit together to establish a real-time connection to the Angular client. In this lab, we will take it a step further and properly organize the code into services that will separate areas of concern.

Let's start by building out the server side.

Nest​

On the server side, the Nest ChatService will be responsible for maintaining the state and the ChatGateway will be responsible for communicating with the client.

ChatService​

To start building out the ChatService, we'll be adding a few methods to keep track of users when they connect and disconnect from the app.

Open the ChatService file and replace its contents with:

./server/src/chat/chat.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class ChatService {
users: Record<string, string> = {};

identify(user: string, clientId: string) {
this.users[user] = clientId;
}

disconnect(clientId: string) {
//look up user by clientId:
const users = Object.keys(this.users);
let userToRemove = '';
users.forEach((user) => {
if (this.users[user] === clientId) {
userToRemove = user;
return;
}
});
if (userToRemove) {
delete this.users[userToRemove];
}
return userToRemove;
}
}

The identify method will get called immediately after the user makes a websocket connection. Its purpose is to keep track of the user's clientId, which is randomly generated each time a client makes a new connection. In your applications, you will want a way to associate a clientId with a user, as you will need the clientId to send individual messages to users. Our chat app uses a plain JavaScript object where the user's name will be the key and the clientId the value.

The gateway will call the disconnect method when a websocket loses its connection. When the disconnect happens, we only get the clientId, which we then search through the users map to locate the user by the clientId, and if we find one, we remove the association.

ChatGateway​

Update the ChatGateway to the following:

./server/src/chat.gateway.ts
import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
import { ChatService } from './chat/chat.service';
import { Socket } from 'socket.io';

@WebSocketGateway({ namespace: 'chat', cors: true })
export class ChatGateway {
constructor(private chatService: ChatService) {}

@SubscribeMessage('identify')
async handleIdentify(client: Socket, user: string) {
this.chatService.identify(user, client.id);
}

@SubscribeMessage('disconnect')
async handleDisconnect(client: Socket) {
this.chatService.disconnect(client.id);
}
}

We learned how to set up a gateway using the @WebsocketGateway and @SubscribeMessage decorators in the last lab. One subtle difference, though, is we are specifying a namespace for our connection. Namespaces are a lot like paths in the URL for HTTP connections. When we specify a namespace, this gateway will only be responsible for messages that get passed to this namespace.

The handleIdentify method will be called by the client immediately after making a connection. We grab the clientId off the socket and pass it to the service to keep track of the user.

The handleDisconnect method is a lifecycle event of Socket.IO and automatically gets called when the client disconnects. In this instance, we collect the clientId and pass it to the ChatService to remove the user from our list of currently connected users.

With the Nest files set up, let's shift our focus to the client side.

Angular​

On the client side, the Angular ChatService will be responsible for setting up the Socket.IO connection with the backend and maintain client-side state of our app.

ChatService​

The ChatService will be handling a bit of data, and we will be storing that state using RXJS BehaviorSubjects. BehaviorSubject is a good choice for real-time data because we can notify any subscribers when the data is changed, and they are guaranteed to get a value upon subscription.

We could return each piece of data as its own observable, which could get cumbersome for our subscribers. So instead, we'll combine all the BehaviorSubjects into a single observable and expose it through a getChatAppData() method.

Update the ChatService with the following:

./client/src/app/chat.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, combineLatest, map, Observable } from 'rxjs';

export interface ChatAppData {
connected: boolean;
user: string;
}

@Injectable({
providedIn: 'root',
})
export class ChatService {
private connected$ = new BehaviorSubject(false);
private user$ = new BehaviorSubject('');

constructor() {}

getChatAppData(): Observable<ChatAppData> {
const data = combineLatest([this.connected$, this.user$]).pipe(
map((value) => {
const [connected, user] = value;
return {
connected,
user,
};
})
);
return data;
}
}

The ChatAppData interface defines what data the service exposes. We will start off with a boolean connected property, which defines if there is a current Socket.IO connection, as well as a user property, which will contain the user's name. We'll be adding to this data structure more in the following lessons.

Next, we define two class members, connected$ and user$, which are BehaviorSubjects and maintain the value for their respective variables from ChatAppData.

In the getChatAppData method, we use RXJS's combineLatest function to combine all the BehaviorSubjects into a single observable that can be subscribed to once.

Now that the state is set up, let's add the code required to establish a connection and update the values.

Modify the ChatService, adding or updating the highlighted lines (some existing code omitted for brevity):

./client/src/app/chat.service.ts
import { io, Socket } from 'socket.io-client';

export interface ChatAppData {
connected: boolean;
user: string;
}

@Injectable({
providedIn: 'root',
})
export class ChatService {
private connected$ = new BehaviorSubject(false);
private user$ = new BehaviorSubject('');
private url = 'http://localhost:3000/chat';
private client: Socket;

constructor() {
this.client = io(this.url, { autoConnect: false });

this.client.on('connect', () => {
this.client.emit('identify', this.user$.value);
this.connected$.next(true);
});
this.client.on('disconnect', () => {
this.connected$.next(false);
});

const user = localStorage.getItem('user') || '';
if (user) {
this.connect(user);
}
}

connect(user: string) {
if (this.client.connected) {
return;
}
localStorage.setItem('user', user);
this.user$.next(user);
this.client.connect();
}

disconnect() {
localStorage.removeItem('user');
this.client.disconnect();
this.user$.next('');
}

// getChatAppData omitted for brevity
}

First, we add a url member that contains the URL to connect to. Notice the URL ends with "/chat", which specifies the namespace and corresponds to what was set up in the Nest gateway on the @WebSocketGateway().

Next, we create a client socket, which gets initialized in the constructor. We specify that we don't want to immediately make the backend connection by specifying the autoConnect flag as false. We delay the connection until the user enters their name and hits the connect button in the UI.

After the client is defined, we subscribe to a couple of events.

The connect event is a lifecycle event that gets called automatically on a new connection or reconnection. After an established connection, we fire the identify event, sending the user name so the client can associate the user with the connection. After identifying, we set the connected$ observable to true to indicate we have a connection.

The disconnect event also runs automatically upon disconnection, and here we set the connected$ back to false.

At the end of the constructor, we check to see if we currently have a user name set in local storage. If so, we grab it and call the connect method. This allows the browser to refresh or the user to return later and get back into the chat room without having to fill out their name again.

The connect method is called from the app component, which passes in the user. If the client is currently connected, we return early as there's nothing to do. If not, we set the user in local storage, update the user$ observable with the user name, and initiate a socket connection.

The disconnect method clears the local storage, disconnects the client, and sets the user to blank.

AppComponent​

There are sections of the app component already set up for you. Update the highlighted lines:

./client/src/app/app.component.ts
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { ChatAppData, ChatService } from './chat.service';

export class AppComponent implements OnInit {
message = '';
user = '';
chatAppData$!: Observable<ChatAppData>;

constructor(private chatService: ChatService) {}

ngOnInit(): void {
this.chatAppData$ = this.chatService.getChatAppData();
}

connect() {
if (this.user) {
this.chatService.connect(this.user);
}
}

disconnect() {
this.user = '';
this.chatService.disconnect();
}

sendMessage() {}
}

Hopefully, this section is fairly straightforward. We set up a chatAppData$ observable, and grab it from the chatService. The connect and disconnect methods are bound to buttons in the template, and the existing message and user variables are bound to text inputs.

AppComponent Template​

Now it's time to update the template.

At the top, update the ng-container method to set its ngIf statement to bind to chatAppData. We'll use the async pipe to subscribe to the observable and alias its return value to data, which we will use throughout the rest of the template.

./client/src/app/app.component.html
<ng-container *ngIf="chatAppData$ | async as data">

On the next line, update the ngIf on the div tag to display if data.connected is true:

./client/src/app/app.component.html
<div *ngIf="data.connected; else login">

This displays the chat interface if the user is connected, else it displays the connect form.

Next, update line 24 to display the name of the user instead of the hard coded text 'user':

./client/src/app/app.component.html
<div class="user">Welcome {{ data.user }}, Room room</div>

When you visit the app, you should be able to login/connect and see your name in the welcome message. Reload the page and notice how you stay logged in (thanks to the local storage variable). Clicking the disconnect button should return you to the login screen.

Initial Chat Screen

That was quite a bit of work to get up and running, but it was the bulk of the heavy lifting in getting clients connected and tracking them. Next up, we will see how we can have users join and leave various rooms in the app.