A fullstack guide to socket.io, Nest Gateways & Decorators

May 5, 2021

Whenever you want to design an application programming interface with Nest you can choose between different options like REST, GraphQL or Gateways (Websockets). This article will guide you on how to deal with Nest Gateways in an elegant way.

Let’s get started with pure Node.js and Socket.io.

A plain start

When you start with Socket.io most articles focus on broadcasting messages to all connected clients, e.g. showcasing a simple chat application.

Let’s create a simple HTTP server with socket.io first registering an authentication event handler responding only to the requesting client. No typescript, no frameworks besides Socket.io.

socket.io start

import { createServer } from 'http';
import { Server } from 'socket.io';

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: {
    // should by configured by an environment variable
    origin: 'http://localhost:4200', 
    methods: ['GET', 'POST'],
  },
});

io.on('connection', (socket) => {
  socket.on('userRequestsAuthentication', io.to(socket.id).emit('authentication', {
    userId: '0ca9ba4e-e69d-41a3-96a1-62becf17c8c6',
    accessToken: 'jwtWith.payload.andSignature',
  }));
});

httpServer.listen(3333);

By now the server is answering requests from the connected client shipping a static payload containing the users’ identity. On the client we start by registering an event handler for the servers response authentication event.

The simplified client looks like this:

client.js

import { io } from 'socket.io-client';

const socket = io('http://localhost:3333');
const username = 'username';
const password = 'password'
socket.once('authentication', (response) => {
  console.log(response);
});
socket.emit('userRequestsAuthentication', { username, password });

Introducing Nest Gateways

Now let’s switch to Typescript and Nest. The plain javascript code from our first approach can be expressed as a Nest Gateway. To handle CORS we need to extend the NestFactory and include the allowed origins (usually within your main.ts file).

authentication.gateway.ts

import { MessageBody, SubscribeMessage, WebSocketGateway, WsResponse, } from '@nestjs/websockets';

interface RequestPayload {
  username: string;
  password: string;
}

interface ResponsePayload {
  userId: number;
  accessToken: string;
}

@WebSocketGateway()
export default class AuthenticationGateway {

  @SubscribeMessage('userRequestsAuthentication')
  public handleRequestAuthentication(
    @MessageBody() auth: RequestPayload
  ): WsResponse<ResponsePayload> {
    return {
      event: 'authenticated',
      data: {
        userId: '0ca9ba4e-e69d-41a3-96a1-62becf17c8c6',
        accessToken: 'jwtWith.payload.andSignature',
      },
    };
  }
}

This code showcases the server example code from the beginning ported 1:1 to Nest. The Gateway has to be registered as a Nest Provider. As you can see the response event is part of the WsResponse schema, the payload moved to the response data property. The Websocket event handler is still very basic and responds with a fixture.

As the connection remains open we have to deal with corresponding request & response events all the time. These can be shared with both client and server code, so let’s create an Interface for our socket events.

socket-events.model.ts

export interface SocketEventModel {
  request: string;
  response: string;
}

export type SocketEventsModel<T extends string> = {
  [key in T]: SocketEventModel;
};

Armed with these basic interfaces we can start to model the API request & response ping pong by now very easy like this:

auth-events.model.ts

export type AuthEvents = 'auth' | 'check';

export const authEvents: SocketEventsModel<AuthEvents> = {
  auth: {
    request: 'auth:requestAuthentication',
    response: 'auth:authenticated',
  },
  check: {
    request: 'auth:requestCheck',
    response: 'auth:checked',
  },
};

As you can see we’ve introduced socket event prefixes now which are preventing collisions between different gateways and responsibilities. The exported authEvents constant can be consumed on both client and server, ensuring both sides are subscribing the correct corresponding event names that can be changed by now any time.

Within the Gateway the SubscribeMessage decorator consumes a rather expressive constant now. Let’s inject an AuthenticationService as well since this should become more and more real world code.

authentication.gateway.ts

@WebSocketGateway()
export default class AuthenticationGateway {
  constructor(
    private readonly authService: AuthService,
  ) {}

  @SubscribeMessage(authEvents.auth.request)
  public async handleRequestAuthentication(
    @MessageBody() auth: RequestPayload
  ): WsResponse<ResponsePayload> {
    return {
      event: authEvents.auth.response,
      data: await this.authService.login(auth),
    };
  }
}

This should already work so far although this code has no response schema defined yet. If an error occurs the user will never get notified, though. We’ll handle exceptions laters.

A custom decorator to the rescue

Instead of using the SubscribeMessage decorator from Nest all the time we can create our own basic decorator to subscribe to our defined SocketEventModel.

To reduce the overhead we can extend the existing SubscribeMessage MethodDecorator like this:

subscribe-socket-event.decorator.ts

export const SubscribeSocketEvent = <T extends SocketEventModel>(
  socketEvent: T,
): MethodDecorator => {
  return applyDecorators(
    SubscribeMessage(socketEvent.request),
  );
};

So instead of using @SubscribeMessage annotation our Gateway evolves:

authentication.gateway.ts

@WebSocketGateway()
export default class AuthenticationGateway {
  constructor(
    private readonly authService: AuthService,
  ) {}

  @SubscribeSocketEvent(authEvents.auth)
  public async handleRequestAuthentication(
    @MessageBody() auth: RequestPayload
  ): WsResponse<ResponsePayload> {
    return {
      event: authEvents.auth.response,
      data: await this.authService.login(auth),
    };
  }
}

Not much has changed – yet. As we deal with permanent connections we still have to notify the users about errors and unlike REST there’s no status code involved. So let’s proceed and define a better response schema.

A better response schema

An error response could be expressed by a message and an error code constant. The message should not include any internal information from the server, though. At a minimum a unique errorCode could be sufficient as well. The client should easily distinguish between a successful request and a request that went wrong.

I phrase it – rather expressive – like this:

ws-response-schema.ts

// the error response
export type WsErrorResponse = { message: string; code: number };
// happy and error path as a union type
export type AppWsResponseData<T> = { response: T } | { error: WsErrorResponse };
// Helper type to reduce code within our Provider
export type ServerWsResponse<T> = WsResponse<AppWsResponseData<T>>;

The upper noted ServerWsResponse type will be used within our Provider, marrying Nest’s WsResponse with the defined response schema from AppWsResponseData.

Let’s create a utility function to deal with this response schema and errors.

server – create-ws-response.ts

export function createWsResponse<T>(
  event: SocketEventModel,
  data,
): WsResponse<AppWsResponseData<T>> {
  if (data instanceof Error) {
    return {
      event: event.response,
      data: {
        error: {
          message: 'an error occured',
          code: data instanceof HttpException ? data.getStatus(): 500,
        },
      },
    };
  }
  return {
    event: event.response,
    data: { response: data },
  };
}

This little helper translates any HTTP status codes from Nest’s HttpExceptions to response error codes. E.g. if authentication fails we should see a 401 error code therefore. Basically we converted the REST paradigm to websockets.

Let’s modify our gateway handler and make use of this helper function.

authentication.gateway.ts improved

@WebSocketGateway()
export default class AuthenticationGateway {
  constructor(
    private readonly authService: AuthService,
  ) {}

  @SubscribeSocketEvent(authEvents.auth)
  public async handleRequestAuthentication(
    @MessageBody() auth: AuthenticationDataModel,
  ): Promise<ServerWsResponse<UserIdentityModel>> {
    try {
      const data = await this.authService.login(auth);
      return createWsResponse<UserIdentityModel>(authEvents.auth, data);
    } catch (error) {
      return createWsResponse(authEvents.auth, error);
    }
  }

Still a lot of boilerplate code, but in any case our connected clients will be notified accordingly. We’ve implemented a lot of the introduced concepts already. Now let’s move the repetitive try/catch part to our previously declared SubscribeSocketEvent.

final SubscribeSocketEvent decorator

subscribe-socket-event.decorator.ts without metadata

const HandleSocketEvent = <T extends SocketEventModel>(
  socketEvent: T,
): MethodDecorator => {
  return (
    target: object,
    _key: string | symbol,
    descriptor: PropertyDescriptor,
  ) => {
    const originalMethod = descriptor.value;
    descriptor.value = async function (...args: unknown[]) {
      try {
        const returnValue = originalMethod.apply(this, args);
        const result =
          returnValue instanceof Promise ? await returnValue : returnValue;
        return createWsResponse(socketEvent, result);
      } catch (e) {
        return createWsResponse(socketEvent, e);
      }
    };
    return descriptor;
  };
};

export const SubscribeSocketEvent = <T extends SocketEventModel>(
  socketEvent: T,
): MethodDecorator => {
  return applyDecorators(
    HandleSocketEvent(socketEvent),
    SubscribeMessage(socketEvent.request),
  );
};

To be honest, this decorator now consists of two decorators. The HandleSocketEvent decorator could be used standalone as well. We make use of Nest’s applyDecorators chain to apply one decorator after the other. The HandleSocketEvent decorator catches any error and calls our previously introduced helper function createWsResponse accordingly. It wouldn’t work if the order of decorators within the chain swaps as we didn’t copy the metadata of the descriptor’s value. So let’s extend it finally:

final subscribe-socket-event.decorator.ts with metadata

export const HandleSocketEvent = <T extends SocketEventModel>(
  socketEvent: T,
): MethodDecorator => {
  return (
    target: object,
    _key: string | symbol,
    descriptor: PropertyDescriptor,
  ) => {
    const originalMethod = descriptor.value;
    const logger = new AppLogger();
    logger.setContext('HandleSocketEvent');
    const metadataKeys: string[] = Reflect.getMetadataKeys(originalMethod);
    descriptor.value = async function (...args: unknown[]) {
      try {
        const returnValue = originalMethod.apply(this, args);
        const result =
          returnValue instanceof Promise ? await returnValue : returnValue;
        return createWsResponse(socketEvent, result);
      } catch (e) {
        logger.warn('HandleSocketEvent error', e);
        return createWsResponse(socketEvent, e);
      }
    };
    for (const metadataKey of metadataKeys) {
      Reflect.defineMetadata(
        metadataKey,
        Reflect.getMetadata(metadataKey, originalMethod),
        descriptor.value,
      );
      Reflect.deleteMetadata(metadataKey, originalMethod);
      logger.debug(`copied metadata key ${metadataKey}`);
    }
    return descriptor;
  };
};

export const SubscribeSocketEvent = <T extends SocketEventModel>(
  socketEvent: T,
): MethodDecorator => {
  return applyDecorators(
    HandleSocketEvent(socketEvent),
    SubscribeMessage(socketEvent.request),
  );
};

By using this decorator our Gateway handlers can be reduced to return pure service calls. The response will always follow the designed response schema. We have to declare the handlers SocketEvent only once. It doesn’t care about Observables, though.

Feel free to use and extend it to your needs 🙂

The final authentication gateway looks like this:

final authentication.gateway.ts

@WebSocketGateway()
export default class AuthenticationGateway {
  constructor(
    private readonly authService: AuthService,
  ) {}

  @SubscribeSocketEvent(authEvents.auth)
  public async handleRequestAuthentication(
    @MessageBody() auth: AuthenticationDataModel,
  ) {
    return this.authService.login(auth);
  }
}

Client-side socket service

On the client-side the response handler has to deal with both response and error properties now. Let’s create a SocketService to deal with all the introduced concepts as well. I’ll keep the code agnostic so this approach should work with any framework – wether you prefer Angular, React, Vue, …

Of course this SocketService should connect to the Gateway. Additionally we want to deal with corresponding request/response events.

client – socket.service.ts

import { SocketEventModel } from '@application/shared';
import io, { Socket } from 'socket.io-client';

export interface EmitAndAwaitSignature<T> {
  events: SocketEventModel;
  payload: T;
}

export default class SocketService {
  private socket: typeof Socket | undefined;

  private isConnecting = false;

  async connect() {
    if (this.isConnecting) {
      throw new Error('trying to connect multiple times.');
    }
    this.isConnecting = true;
    return new Promise((resolve, reject) => {
      try {
        const socket = io('http://localhost:3333');
        this.socket = socket;

        socket.once('connect', () => {
          this.isConnecting = false;
          resolve(socket);
        });
      } catch (e) {
        reject(e);
      }
    });
  }

  async emitAndAwait<PAYLOAD, RESPONSE>(
    data: EmitAndAwaitSignature<PAYLOAD>,
  ): Promise<RESPONSE> {
    const { events, payload } = data;

    return new Promise(async (resolve, reject) => {
      try {
        if (this.socket == null) {
          await this.connect();
        }
        this.socket.once(events.response, (data) => {
          if ('error' in data) {
            reject(new Error(data.error));
            return;
          }
          if ('response' in data) {
            resolve(data.response);
            return;
          }
          reject(new TypeError('unknown WS response schema'));
        });
        this.socket.emit(events.request, payload);
      } catch (e) {
        reject(e);
      }
    });
  }
}

This simple wrapper:

  • acts as a facade for socket.io
  • connects to the server gateway and returns a promise
  • prevents multiple connections to the server
  • provides a method called emitAndAwait that follows the previously declared SocketEventModel and response schema principles. This method will be called in any further client-side examples to communicate with our Gateways.

It’s still not ready for production, though, e.g. it doesn’t take care about timeouts. One can still create several instances of it so it’s up to you to prevent multiple instances resulting in multiple connections.

Anyways, let’s create an AuthenticationService showcasing its usage.

authentication.service.ts

export default class AuthenticationService {
  constructor(private socketService: SocketService) {
  }

  async authenticate(
    payload: AuthenticationDataModel,
  ): Promise<UserIdentityModel> {
    return this.socketService.emitAndAwait<AuthenticationDataModel, UserIdentityModel>({
      events: authEvents.auth,
      payload,
    });
  }
}

Conclusion

Alright, that was a lot stuff which may look a little complicated. But both sides – gateways and client services – are reduced to the max and thus making my life easier. I hope this article gave you a better idea of some concepts I’ve talked about.

Leave a comment