Part 2/3 - How to create a server-side timer using WebSockets (with Socket.IO), NestJS and Flutter

rukshanjs

Rukshan J. Senanayaka

Posted on August 15, 2022

Part 2/3 - How to create a server-side timer using WebSockets (with Socket.IO), NestJS and Flutter

Introduction

In this part (part 2/3), I will discuss how to implement the backend server application using NestJS to establish a WebSocket connection.

The code segments in this article are written for reading purpose, the full source code is available on my GitHub, linked at the end.

NestJS is a wonderful backend framework to develop mid to large scale projects. It supports WebSockets officially and a very straightforward setup is available to get things going.

I am considering a single stateful server of NestJS and I will avoid Redis and microservices for now but they would be advisable to use for a fully-scalable application, to keep track of users connected to each WebSocket connection.

I mentioned this stateful because to keep track of all the connected users and their timers, a single variable called userTimers is maintained in our backend app.

That means if the server shuts down for some reason, that state would be lost and we would loose the timers for the users. If we use Redis and microservices knowledge, we could avoid this problem. But I think that should be for another topic.

Abstract architecture of our server-side timer app

The following diagram gives an abstract idea of the system we are going to implement. The method calls in the diagram are the ones usually used in a WebSocket connection. If you don't understand it fully, please go through this tutorial series again after reading all 3 parts for the first time. I have tried to simplify things as much as possible.

Image description

The things that we have to do for the backend implementation are the following steps.

1. Start a new NestJS project

Please install NestJS on your computer if you don't have already, by running the following command.



npm i -g @nestjs/cli


Enter fullscreen mode Exit fullscreen mode

Then create a new NestJS project.



nest new server-timer-backend


Enter fullscreen mode Exit fullscreen mode

2. Install the needed packages

The two packages that we require are provided by NestJS developers. Since I’ve tested this setup with NestJS version 8.0.0 (Although the latest version is 9), I’ll be using an earlier, supported version of the required packages. This setup is working as of early August, 2022.

  • @nestjs/websockets@8.4.7
  • @nestjs/platform-socket.io@8.4.7

We can install both of them by running the following command in the terminal.



npm i --save @nestjs/websockets@8.4.7 @nestjs/platform-socket.io@8.4.7


Enter fullscreen mode Exit fullscreen mode

3. Create a sockets gateway (sockets.gateway.ts)

Firstly, I’ll create a folder named sockets where we will keep all of our files required for WebSocket communication for now. You can refactor these files in anyway you like.

Create a new file called sockets.gateway.ts.


Here the gateway file can be thought of as the main file in the backend related to WebSocket communication. It handles the connection, disconnection and any other message event.

This SocketsGateway class implements two interfaces which are provided by the @nestjs/websockets package.

We use the @WebSocketServer() decorator provided by the above package to tell NestJS to inject the WebSocket server.



@WebSocketGateway()
export class SocketsGateway
  implements OnGatewayConnection, OnGatewayDisconnect
{
  @WebSocketServer()
  public server: Server;

  // handleConnection(){}

  // handleDisconnect(){}

  // startMyTimer(){}

  // stopMyTimer(){}
}


Enter fullscreen mode Exit fullscreen mode

I mentioned that an agreed-upon communication channel has to be created between client and server to start WebSocket communication. This agreement is established via a handshake.

Whenever this handshake is established, the handleConnection(){} method is invoked. This handshake is a real thing and its details can be accessed via client.handshake as a JSON with various information related to this WebSocket connection.



  handleConnection(@ConnectedSocket() client: any) {
    // console.log(
    //   `user ${client.user.id} with socket ${client.id} connected with device ${client.handshake?.query?.deviceId}`,
    // );

    client.join(
      getUserDeviceRoom(
        client.user.id,
        client.handshake.query.deviceId.toString(),
      ),
    );
  }


Enter fullscreen mode Exit fullscreen mode

handleDisconnect(){} is more or less similar and it is invoked when an already connected socket is disconnected. You still have access to the handshake via client.handshake.



  handleDisconnect(@ConnectedSocket() client: any) {
    // console.log(
    //   `user ${client.user.id} with socket ${client.id} with device ${client.handshake?.query?.deviceId} DISCONNECTED`,
    // );

    client.leave(
      getUserDeviceRoom(
        client.user.id,
        client.handshake.query.deviceId.toString(),
      ),
    );
  }


Enter fullscreen mode Exit fullscreen mode

A message event is a user-defined event to which the server listens (or subscribes). When a client (for example our flutter app) sends a message under that event, the server will execute whatever code is inside this listener method for that particular message event.

We are using two message events in our timer app - timerStart and timerStop.

In the startMyTimer(){} method, I use the @MessageBody() decorator to catch the payload sent from the client to server via the WebSocket connection. Here, the duration of the timer to be started in seconds, is contained in body.dur value. (We set this in our Flutter app)

In this example, I stop any existing timer for a user device before starting a new timer for that device.



  @SubscribeMessage(TimerEvents.timerStart.toString())
  startMyTimer(@ConnectedSocket() client: any, @MessageBody() body: any): void {
    // Stop any existing timer for this user device.
    stopTimerForUserDevice(
      client.user.id,
      client.handshake.query.deviceId.toString(),
    );

    // Start a new timer for this user device.
    startTimerForUserDevice(
      this.server,
      client.user.id,
      client.handshake.query.deviceId.toString(),
      body.dur, // Timer duration
    );
  }


Enter fullscreen mode Exit fullscreen mode


  @SubscribeMessage(TimerEvents.timerStop.toString())
  stopMyTimer(@ConnectedSocket() client: any): void {
    // Stop current timer for this user device.
    stopTimerForUserDevice(
      client.user.id,
      client.handshake.query.deviceId.toString(),
    );
  }


Enter fullscreen mode Exit fullscreen mode

4. Create a socket adapter (authenticated-socket.adapter.ts)

When we use WebSockets, we want to only allow authenticated users to connect with our backend WebSocket service. For that, we have to have a custom socket adapter (although one is given to us by default). This will make sure each and every request to establish a WebSocket connection will be made by a user authenticated in your backend.


First, create a class named AuthenticatedSocketAdapter which extends the IoAdapter from @nestjs/platform-socket.io package as below.



export class AuthenticatedSocketAdapter extends IoAdapter {
   // constructor(){}

   // createIOServer(){}
}


Enter fullscreen mode Exit fullscreen mode

Next, write the constructor for this class. Here, if you have an AuthService implemented using Firebase Auth or any other auth service of your choice, just uncomment the commented lines shown in the code snippet below.

This authentication can be done using Firebase Auth or any other auth service of your choice and it doesn’t matter, as long as it will throw an exception for an unauthorized user. If the user is authorized, the WebSocket connection is established and if not, it is terminated by throwing Authentication error error.

If you don't have such an implementation yet, don't worry just leave these as is - we can test the implementation without authentication also.



  // private readonly authService:AuthService;
  constructor(private app: INestApplicationContext) {
    super(app);
    // this.authService = this.app.get(AuthService);
  }


Enter fullscreen mode Exit fullscreen mode

We'll cover authentication in another tutorial so that the WebSocket connections will only be available to authenticated users.


Next, create a method named createIOServer(). Inside it, we are writing a middleware to grab the auth token from the handshake of the WebSocket connection, and authenticate that token using an AuthService (AuthService file is not implemented in this tutorial - if you don't have one yourself, you can ignore it for now because we don't need authentication for testing).

If the token is valid, the AuthService should return the relevant user from the authenticateToken() method. This returned user value is assigned to the socket.user variable. It is because we do this, we are able to access the relevant user as client.user by using the @ConnectedSocket() decorator in the sockets.gateway.ts file.

If the token is invalid, it will throw an error and the WebSocket connection would not be established.

Here also, if you have an AuthService implemented using Firebase Auth or any other auth service of your choice, just uncomment the commented lines shown in the code snippet below.

Please note that I have imported the Server type from socket.io library to our application as,



import { Server } from 'socket.io';


Enter fullscreen mode Exit fullscreen mode

This is how I have imported Server type in sockets.gateway.ts, rooms.ts, events.ts files as well.



  createIOServer(port: number, options?: any) {
    const server: Server = super.createIOServer(port, options);

    server.use(async (socket: any, next) => {
      const tokenPayload: string = socket.handshake?.auth?.token;

      if (!tokenPayload) {
        return next(new Error('Token not provided'));
      }

      const [method, token] = tokenPayload.split(' ');

      if (method !== 'Bearer') {
        return next(
          new Error('Invalid authentication method. Only Bearer is supported.'),
        );
      }

      try {
        socket.user = {};
        // const user = await this.authService.authenticateToken(token);
        // socket.user = user;
        return next();
      } catch (error: any) {
        return next(new Error('Authentication error'));
      }
    });
    return server;
  }


Enter fullscreen mode Exit fullscreen mode

5. Enable WebSockets in main.ts

In order to use the AuthenticatedSocketAdapter, the custom WebSocket adapter that we created, we should add the following code to main.ts file as below.



  const app = await NestFactory.create(AppModule);
  app.useWebSocketAdapter(new AuthenticatedSocketAdapter(app)); // Add our custom socket adapter.


Enter fullscreen mode Exit fullscreen mode

Don’t forget to set the SocketsGateway as a provider in the app.module.ts



@Module({
  imports: [],
  controllers: [AppController],
  providers: [AppService, SocketsGateway],
})
export class AppModule {}


Enter fullscreen mode Exit fullscreen mode

6. Create rooms.ts helper file

The rooms.ts is there to keep track of the connected users. This is the place where I will write the application logic related to our server-side timer. The methods in this file would send messages to clients as required.


First, create a global variable userTimers, to keep track of the connected users and their timers. Here, the keys of the object are the userId + deviceId combinations. The values are the timers of each user.



var userTimers = {}; // To keep track of timers for each user.


Enter fullscreen mode Exit fullscreen mode

Image description

Here is how the userTimers variable would look like.



{
   "user001device001":Timeout {/* data of timer1 */},
   "user002device002":Timeout {/* data of timer2 */},
   "user003device003":Timeout {/* data of timer3 */}
}


Enter fullscreen mode Exit fullscreen mode

But for more scalable applications, an approach which uses Redis and microservices and thereby making our backend application stateless would be advisable - then unexpected shutdowns of the backend server would not cause loss of user timers. This is because we can use Redis as a high speed key-value store.


Next, create a method getUserDeviceRoom(){}, to keep track of a single device of a user, based on userId and the deviceId. It returns the roomName as a string for that user device.



export function getUserDeviceRoom(userId: string, deviceId: string) {
  return `user:${userId}-device:${deviceId}`;
}


Enter fullscreen mode Exit fullscreen mode

This way, we can use this returned roomName to create a conceptual room for that user device.

In the handleConnection(){} method, the connected client device join to this room. Then we can send a message to this specific device of a user, by sending that message to that room.

In the handleDisconnect(){} method, the connected client device leave from this room. We can no longer send messages to that user device until it establishes a socket connection again.


Next, create the method sendToUserDevice(){} to send the actual message using WebSockets.



export function sendToUserDevice(
  server: Server,
  userId: string,
  deviceId: string,
  event: string,
  payload: any,
) {
  server.to(getUserDeviceRoom(userId, deviceId)).emit(event, payload); // Actually send the message to the user device via WebSocket channel.
}


Enter fullscreen mode Exit fullscreen mode

Next, we have to implement the methods to start the timer and stop the timer. I am using the setInterval() method of JavaScript to create a counter, and that counter value is sent to the user device in each tick.

At the same time, when a timer is created, I add it to a variable timer and it is stored in the variable userTimers that I mentioned in the beginning.



export function startTimerForUserDevice(
  server: Server,
  userId: string,
  deviceId: string,
  dur: number,
) {
  var counter = dur; // Set initial counter value to timer duration `dur` (in seconds).

  var timer = setInterval(function () {
    console.log(`counting ${counter}`);

    sendToUserDevice(server, userId, deviceId, TimerEvents.tick.toString(), {
      timer: counter,
    }); // Send tick message to user device.

    if (counter > 0) {
      counter--;
    } else {
      // Stop timer for this user.
      console.log(`user ${userId} has a timeout`);
    }
  }, 1000);

  userTimers[userId + deviceId] = timer; // Store timer for this user device.
}


Enter fullscreen mode Exit fullscreen mode

We store the timers in the userTimers variable because we need a reference to the timer of each user, to stop it later if the client requests (using the Flutter app) to stop the server-side timer for that device. The relevant code is shown below.

After clearing the interval for that timer, we also delete it from the userTimers object, to release the memory allocated for it.



export function stopTimerForUserDevice(userId: string, deviceId: string) {
  clearInterval(userTimers[userId + deviceId]); // Stop the timer for this user device.

  delete userTimers[userId + deviceId]; // Delete the timer for this user device from the `userTimers` object.
}


Enter fullscreen mode Exit fullscreen mode

Don’t forget to import this file inside the sockets.gateway.ts file.

7. Create events.ts helper file

The events.ts file just contains an enum to easily keep track of the message events I mentioned earlier.



export enum TimerEvents {
  tick = 'tick',
  timerStart = 'timerStart',
  timerStop = 'timerStop',
}


Enter fullscreen mode Exit fullscreen mode

The reason to use enums is because this way we can use the variable name of the enum instead of typing the exact event name as a string everywhere that we need it - this reduces errors due to typing mistakes.

Don’t forget to import this file inside the sockets.gateway.ts file.

Conclusion

We wrote code in the following files in our NestJS server application to enable WebSocket communication.

  • sockets.gateway.ts
  • authenticated-sockets.adapter.ts
  • rooms.ts
  • events.ts
  • app.module.ts
  • main.ts

Although it is recommended to restructure these files according to a proper standard in a production application, this would be more than enough for us for now.

When you are done with this section, please move onto the next section of this 3-part series to see how to setup the flutter front end to connect to this backend WebSocket implementation.

Support me!

Do you think I deserve a cup of coffee for this article? 😃

Buy Me A Coffee

Video

IMAGE ALT TEXT HERE

Source code

You can find the full source code of the project at my GitHub, https://github.com/RukshanJS/websockets-nestjs-flutter

References

  1. Official NestJS docs on sockets -https://docs.nestjs.com/websockets/gateways

  2. Why Socket.IO is needed? - https://stackoverflow.com/a/32811489/8995555

  3. Why two packages are needed for WebSocket implementation in NestJS? - https://stackoverflow.com/a/73339808/8995555

💖 💪 🙅 🚩
rukshanjs
Rukshan J. Senanayaka

Posted on August 15, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related