Nest JS Websockets - Basics

Authors
  • avatar
    Name
    Austin Howard
Updated on

Welcome to part 1, the Basics of building a chat application with Nest JS and React.

This series will attempt to touch on as many features of Nest's websockets integration as well as socket.io in general.

The entire series will be building on one repository that has both the server and the client for the application, so for each part you can download the repository from specific commits to follow along or you can clone the entire repository in it's finished state.

Download the repository at this commit to follow along.

Background

Nest JS is a framework that provides a level of abstraction above common Node frameworks like Express, Fastify, and importantly for this use case - Socket.io. Nest is particularly opinionated about architecture which on one hand means you're "pigeon holed", but on the other hand they've made decisions for you which can allow you to focus on building your application.

React doesn't need much of an introduction as it's such a popular framework. Nevertheless React is a framework for building interactive user interfaces with a ton of broad support across the internet. Given any roadblocks you might run into, someone has most likely posted a solution somewhere or is likely to help you out.

Socket.io is a Node framework around websockets which allow for two-way communication between clients (our React client) and servers (our Nest server) - otherwise known as full-duplex systems.

Design

For a basic first iteration of the application, we'll shoot for this kind of overall design:

  • We want our clients to be able to emit messages to our server via a form input.
  • We want our server to subscribe to messages coming in from clients, and emit or broadcast them out to all connected clients.
basic architecture

File Structure

For this project we're developing both the client and the server in the same repository. The Nest server is also serving the React client so it makes since to have them together.

The core structure at this stage looks like this:

  • public: static files like images and importantly index.html which gets served initially on page render.
  • src
    • client: react client source files.
    • server: nest server source files.
    • shared: source files shared by client and server - mostly share Typescript types for full-stack type safety.
  • ...configuration and miscellaneous files: the rest of the files in the root directory are files such as .gitignore, package.json, webpack.config.client.js for bundling our react client, tailwind.config.js for our css library configuration, etc.

The Server

At this stage our server is quite small - only 4 files.

  • chat
    • chat.gateway.ts
    • chat.module.ts
  • app.module.ts
  • main.ts

Let's take a look at each of the files one by one and see what's happening in them.

main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  await app.listen(3000);

  // Gracefully shutdown the server.
  app.enableShutdownHooks();
}
bootstrap();

The main.ts file is our server's entrypoint. We the server boots up, this is the first file that's executed. As was mentioned before, Nest provides a level of abstraction above Express and Fastify - and we are going with the Exress flavor here as seen when we create the server with NestExpressApplication from the '@nestjs/platform-express' package. Our server runs on port 3000 and we make use of a handy app.enableShutdownHooks() to shut down our server gracefully in the event that the running process is killed - read about shutdown hooks here.

app.module.ts
import { Module } from '@nestjs/common';
import { ChatModule } from './chat/chat.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';

@Module({
  imports: [
    ChatModule,
    ServeStaticModule.forRoot({
      rootPath: join(__dirname, '..', '..', '..', 'dist', 'client'),
    }),
  ],
})
export class AppModule {}

Nest employs the concept of modules as part of it's core architecture. Our app.module.ts file contains our root module which is the starting point that Nest uses to build the application graph. Any other modules in the server would be children of the application module and should be imported into their direct parents. In this case, we have a ChatModule that we'll take a look at shortly, but here we are adding it to our application module's imports: [] array.

We want our Nest server to also serve our client React application. To do that we can make use of the ServeStaticModule from '@nestjs/serve-static'. It takes a rootPath path to the bundle of our client that gets produced by webpack.

chat.gateway.ts
import {
  MessageBody,
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import {
  ServerToClientEvents,
  ClientToServerEvents,
  Message,
} from '../../shared/interfaces/chat.interface';
import { Server } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
})
export class ChatGateway {
  @WebSocketServer() server: Server = new Server<
    ServerToClientEvents,
    ClientToServerEvents
  >();
  private logger = new Logger('ChatGateway');

  @SubscribeMessage('chat')
  async handleEvent(
    @MessageBody()
    payload: Message,
  ): Promise<Message> {
    this.logger.log(payload);
    this.server.emit('chat', payload); // broadcast messages
    return payload;
  }
}

Here is the really interesting part of the server. This file is the websocket gateway for our chat application - see Gateways. All we need to do to create a gateway is importing and using the WebsocketGateway class and pass it some configuration options - in our case we're setting cors to be wide open to all origins by using '*'. This is not considered safe and should only be open to trusted origins - we will address this later in future series parts.

Nest supports 2 websocket platforms - socket.io and ws. We're going to be using socket.io.

To get access to the underlying socket.io server, we can use the WebSocketServer class to instantiate a server with a type of socket.io Server. One extremely cool thing we can do here in terms of Typescript is passing in event types ServerToClientEvents and ClientToServerEvents. We will touch on these again when we go over our shared directory.

We want to log chat messages coming into the server from clients so we also instantiate a logger with Nest's Logger class.

We also need to subscribe to incoming messages from clients. To do this we can use the SubscribeMessage class which takes the name of the messages chat. This requires passing a function to handle the incoming message which takes a MessageBody payload of type Message which we also get from our shared directory since both client and server will want to know the message type. We then log the message with this.logger.log(payload) and then emit the message by invoking the socket.io server's emit method with this.server.emit('chat', payload). This will broadcast the message to all clients that have subscribed to chat messages. Then we return the payload.

chat.module.ts
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';

@Module({
  providers: [ChatGateway],
})
export class ChatModule {}

The chat.module.ts file is as simple as it gets. Nest treats WebSocketGateways as providers which like modules are part of it's core architecture. We pass our ChatGateway into providers: [].

The Client

We're going to try to focus on the websockets aspect of the React client and skip over some things like component styling, webpack configuration, postcss configuration, etc. One thing you'll also notice is we don't have any client-side routing - which we may end up adding in later series parts. But for now we're sticking to the basics.

Our server directory looks like this:

  • components: UI components.
  • layouts: UI page layouts.
  • styles: core css style sheet.
  • app.tsx: core application file.
  • index.tsx: client application entry point.

Let's just focus on the app.tsx file which contains the core logic of the frontend.

app.tsx
import React, { useState, useEffect } from 'react';
import { io, Socket } from 'socket.io-client';
import {
  User,
  Message,
  ServerToClientEvents,
  ClientToServerEvents,
} from '../shared/interfaces/chat.interface';
import { Header } from './components/header';
import { LoginForm } from './components/login.form';
import { MessageForm } from './components/message.form';
import { Messages } from './components/messages';
import { ChatLayout } from './layouts/chat.layout';
import { LoginLayout } from './layouts/login.layout';

const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io();

function App() {
  const [isConnected, setIsConnected] = useState(socket.connected);
  const [messages, setMessages] = useState<Message[]>([]);
  const [user, setUser] = useState<User>();

  useEffect(() => {
    const currentUser = JSON.parse(sessionStorage.getItem('user') ?? '{}');
    if (currentUser.userId) {
      setUser(currentUser);
    }

    socket.on('connect', () => {
      setIsConnected(true);
    });

    socket.on('disconnect', () => {
      setIsConnected(false);
    });

    socket.on('chat', (e) => {
      setMessages((messages) => [e, ...messages]);
    });

    return () => {
      socket.off('connect');
      socket.off('disconnect');
      socket.off('chat');
    };
  }, []);

  const login = (e: React.FormEvent<HTMLFormElement>) => {
    const formValue = e.target[0].value;
    const newUser = {
      userId: Date.now().toLocaleString().concat(formValue),
      userName: formValue,
    };
    sessionStorage.setItem('user', JSON.stringify(newUser));
    setUser(newUser);
  };

  const sendMessage = (e: React.FormEvent<HTMLFormElement>) => {
    if (user) {
      socket.emit('chat', {
        user: {
          userId: user.userId,
          userName: user.userName,
        },
        timeSent: new Date(Date.now()).toLocaleString('en-US'),
        message: e.target[0].value,
      });
    }
  };
  return (
    <>
      {user && user.userId ? (
        <ChatLayout>
          <Header user={user} isConnected={isConnected}></Header>
          <Messages user={user} messages={messages}></Messages>
          <MessageForm sendMessage={sendMessage}></MessageForm>
        </ChatLayout>
      ) : (
        <LoginLayout>
          <LoginForm login={login}></LoginForm>
        </LoginLayout>
      )}
    </>
  );
}

export default App;

Since we're using socket.io for our server websocket platform, we're also going to use another package that they ship called 'socket.io-client'.

After we import all the things we need at the top of the file, the first important thing we do is create a new socket instance with the io() function that we import from the package.

Like in our chat gatway on the server, we're passing in our shared types ServerToClientEvents and ClientToServerEvents.

We have 3 useState hooks where we're storing some state in the client.

// We want to store the client's current state of the connection.
const [isConnected, setIsConnected] = useState(socket.connected)
// We want to store a list of messages that have been emitted from the server.
const [messages, setMessages] = useState<Message[]>([])
// We want to store the current user of the client.
const [user, setUser] = useState<User>()

We also have one useEffect hook where we want to perform some side effects. We're going to leverage a browser api sessionStorage to temporarily store the currently "logged in" user in the browser. There are some nuances between sessionStorage and localStorage. For this version of the application we're going to use sessionStorage so that we can log in a new user per each browser tab that we open for easy demonstration purposes. We're also not doing any authentication at the moment so we need not burden ourselves with security at this stage - sessionStorage is just fine for now.

useEffect(() => {
  // Grab the currently "logged in" user from sessionStorage
  const currentUser = JSON.parse(sessionStorage.getItem('user') ?? '{}')
  // If userId exists, we know there is a "logged in" user.
  if (currentUser.userId) {
    // So we set the current user into state.
    setUser(currentUser)
  }

  // We go ahead and setup an event listener for a 'connect' event.
  socket.on('connect', () => {
    // Set connect state accordingly.
    setIsConnected(true)
  })

  // We go ahead and setup an event listener for a 'disconnect' event.
  socket.on('disconnect', () => {
    // Set connect state accordingly.
    setIsConnected(false)
  })

  // We go ahead and setup an event listener for a 'disconnect' event.
  socket.on('chat', (e) => {
    // Set connect state accordingly.
    setMessages((messages) => [e, ...messages])
  })

  // We define a cleanup procedure to remove event listeners.
  return () => {
    socket.off('connect')
    socket.off('disconnect')
    socket.off('chat')
  }
}, [])

We define a login function that we can execute when a new user submits their name in a login form.

const login = (e: React.FormEvent<HTMLFormElement>) => {
  // Extract user input from the form.
  const formValue = e.target[0].value
  // Construct a new user object with user input formValue.
  const newUser = {
    // Unique userId by concating current date and the user's name.
    // The edge case here would be if two users of the same name logged
    // in at the exact same millisecond in time.
    // We can solve this edge case later with authentication.
    // Ex) "1,668,125,065,218anna"
    userId: Date.now().toLocaleString().concat(formValue),
    userName: formValue,
  }
  // Set new user to session storage.
  sessionStorage.setItem('user', JSON.stringify(newUser))
  // Set new user to state.
  setUser(newUser)
}

We define another function sendMessage that we can execute when a user submits a message in a message form.

const sendMessage = (e: React.FormEvent<HTMLFormElement>) => {
  // There must be a user set in state to send a message.
  if (user) {
    // Given a user is "logged in", emit a 'chat' message to the server
    // with the current user's information, the time of the message (timeSent),
    // and the value of the user input in the message form.
    socket.emit('chat', {
      user: {
        userId: user.userId,
        userName: user.userName,
      },
      timeSent: new Date(Date.now()).toLocaleString('en-US'),
      message: e.target[0].value,
    })
  }
}

Here is a sample Message object:

{
  "user": {
    "userId": "1,667,963,682,100anna",
    "userName": "anna"
  },
  "timeSent": "11/10/2022, 8:41:35 PM",
  "message": "🤨"
}

Finally we return some tsx/jsx elements from our app function. We won't look into each and every React component for the sake of brevity but it's encouraged to browse through the components and layout directory to see what's happening.

return (
  <>
    {/* If a user is "logged in" (set in state), render chat elements. */}
    {user && user.userId ? (
      <ChatLayout>
        <Header user={user} isConnected={isConnected}></Header>
        <Messages user={user} messages={messages}></Messages>
        <MessageForm sendMessage={sendMessage}></MessageForm>
      </ChatLayout>
    ) : (
      {/* If a user is not "logged in" (set in state), render login elements. */}
      <LoginLayout>
        <LoginForm login={login}></LoginForm>
      </LoginLayout>
    )}
  </>
)

That's it for our client for now! Here's what we're left with:

Login UI

login user interface

Chat UI

chat user interface

Shared Types

Before we wrap this up we should touch on one of the key features of this setup - shared types.

Checkout the Typescript docs on socket.io.

In our shared directory we have a common chat.interface.ts file.

chat.interface.ts
export interface User {
  userId: string;
  userName: string;
}

export interface Message {
  user: User;
  timeSent: string;
  message: string;
}

// Interface for when server emits events to clients.
export interface ServerToClientEvents {
  chat: (e: Message) => void;
}

// Interface for when clients emit events to the server.
export interface ClientToServerEvents {
  chat: (e: Message) => void;
}

If you can remember before, we're using these event interfaces in both the client and the server.

chat.gateway.ts
// ...

@WebSocketServer() server: Server = new Server<
    ServerToClientEvents,
    ClientToServerEvents
  >();

// ...

async handleEvent(
  @MessageBody()
  payload: Message,
): Promise<Message> {
  this.logger.log(payload);
  this.server.emit('chat', payload); // <--- ServerToClientEvent
  return payload;
}

// ...
app.tsx
// ...

const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io();

// ...

socket.emit('chat', { // <--- ClientToServerEvent
  user: {
    userId: user.userId,
    userName: user.userName,
  },
  timeSent: new Date(Date.now()).toLocaleString('en-US'),
  message: e.target[0].value,
});

// ...

With these interfaces, we have end-to-end type safety which provides us with a strong layer of assurance that our server and clients will receive the correct data! Super cool.


That's all for this part of the series! Go check out the code and more specifically the UI components that we didn't touch on here like the Messages, MessageForm, and the LoginForm and take a look at some of the tailwind css that we've done for styling them.

Download the repository at this commit to follow along.