Scalable WebSocket Solutions for Modern Applications
The Problem with Stateless Applications
Traditional backend applications are designed to be stateless, allowing for easy scalability and fault tolerance. However, when introducing WebSockets, this stateless nature is compromised. WebSockets require maintaining a connection state, which can lead to complexities when scaling applications.
Setting Up a Nest Application
We’ll use the Nest CLI to scaffold our application and Docker with docker-compose to add Redis and Postgres for local development. Our Redis setup will utilize ioredis, providing robust performance and features.
nest new my-app
cd my-app
docker-compose up -d
Creating a State of Sockets
To manage WebSocket connections, we’ll implement a socket state service. This service will store sockets in a Map<string, Socket[]> format, allowing us to easily retrieve sockets for a specified user.
import { Injectable } from '@nestjs/common';
import { Socket } from 'ocket.io';
@Injectable()
export class SocketStateService {
private sockets: Map<string, socket[]=""> = new Map();
addSocket(userId: string, socket: Socket): void {
const userSockets = this.sockets.get(userId) || [];
userSockets.push(socket);
this.sockets.set(userId, userSockets);
}
removeSocket(userId: string, socket: Socket): void {
const userSockets = this.sockets.get(userId);
if (userSockets) {
const index = userSockets.indexOf(socket);
if (index!== -1) {
userSockets.splice(index, 1);
}
}
}
getSockets(userId: string): Socket[] {
return this.sockets.get(userId) || [];
}
}
</string,>
Creating an Adapter
Nest’s adapter system enables seamless integration with various libraries. We’ll extend the existing socket.io adapter to track currently open sockets.
import { Injectable } from '@nestjs/common';
import { Server } from 'ocket.io';
import { SocketStateService } from './socket-state.service';
@Injectable()
export class SocketAdapter extends Server {
constructor(private readonly socketStateService: SocketStateService) {
super();
}
create(): void {
//...
}
bindClientConnect(): void {
//...
}
}
Creating the Redis Event Propagator
With our Redis integration and socket state in place, we’ll create a Redis event propagator service. This service will listen to incoming Redis events and dispatch events to other instances.
import { Injectable } from '@nestjs/common';
import * as ioredis from 'ioredis';
@Injectable()
export class RedisEventPropagatorService {
private redis: ioredis;
constructor() {
this.redis = new ioredis('redis://localhost:6379');
}
listen(): void {
this.redis.subscribe('events');
this.redis.on('message', (channel, message) => {
if (channel === 'events') {
this.dispatch(message);
}
});
}
dispatch(event: string): void {
//...
}
}
Listening to Event Dispatches
To fully utilize the Nest ecosystem, we’ll create an interceptor that will have access to each socket event response.
import { Injectable, NestInterceptor, ExecutionContext } from '@nestjs/common';
@Injectable()
export class SocketEventInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: () => Observable): Observable {
return next().pipe(
tap((response) => {
// React to the response without altering it
}),
);
}
}
Working Example
Our final step is to create a gateway that listens to incoming events and demonstrates the propagation of events across instances.
import { WebSocketGateway, SubscribeMessage, MessageBody } from '@nestjs/websockets';
import { UseInterceptors } from '@nestjs/common';
import { SocketEventInterceptor } from './socket-event.interceptor';
@WebSocketGateway()
@UseInterceptors(SocketEventInterceptor)
export class EventsGateway {
@SubscribeMessage('events')
handleEvent(@MessageBody() event: string): void {
// Handle incoming events
}
}
We’ll use the @UseInterceptors
decorator to register the interceptor and simulate a user session with a fake token.