Papooch / nestjs-cls

A continuation-local storage (async context) module compatible with NestJS's dependency injection.
https://papooch.github.io/nestjs-cls/
MIT License
393 stars 23 forks source link

Cls support for websockets #8

Closed nileger closed 2 years ago

nileger commented 2 years ago

Problem statement

Solution

I'm not familiar with this repository, neither with cls nor the different NestJs communication protocols. Therefore, at the moment, I can't provide an ideas. From the perspective of a consumer of nestjs-cls, it would be great to extend the existing functionality in such a way, that cls for websockt context can be added by setting a property or by an additional import in the module that provides the websocket gateway.

Minimum reproduction

Papooch commented 2 years ago

Okay, so I've found the issue.

The problem is that Websocket Gateways don't respect globally bound enhancers (neither with app.useGlobal<Enhancer> nor with the APP_<ENHACER> provider (which is what nestjs-cls uses with the mount: true option)), which kind of makes sense now, since they're bound to the Express or Fastify apps.

To make CLS also work with Websockets, all you have to do is explicitly bind the Cls<Enhancer> to the Websocket Gateway like so:

@WebSocketGateway()
@UseInterceptors(/* interceptor */ ClsInterceptor, WsTenantInterceptor)
export class WebsocketGateway {
   // ...
}

or

@WebSocketGateway()
@UseGuards(/* guard */ ClsGuard)
@UseInterceptors(WsTenantInterceptor)
export class WebsocketGateway {
   // ...
}

(btw, you will still need to configure the ClsGuard or ClsInterceptor in ClsModule.register())

I will update the README to reflect that, but Websockets are currently supported :)

peisenmann commented 1 year ago

@Papooch The docs further don't address that even if you set everything up the way you've described, handleConnection is not covered by Interceptors, Guards, or Middleware. It's not a NestJS CLS problem, it's just something of a shortcoming in Nest:

It does appear this might be something they support in the future https://github.com/nestjs/nest/issues/882

A simple solution I've found for now is to inject the ClsService into my gateway, then do:

  async handleConnection(socket: Socket, incoming: IncomingMessage) {
    // handleConnection is not covered by NestJS Middleware, Guards, or Interceptors
    this.clsService.run(async () => {
       // Do stuff here
       const authToken = getAuthTokenFromIncomingMessage(incoming);
       this.clsService.set('request.authToken', authToken);
    }
 }

The rest of the incoming messages are covered by the interceptor as you mentioned. It may be obvious, but of course the CLS values won't persist between web socket messages since there's not any asynchronous context left to contain them. So, I've been storing the values I care about in a map I added onto the socket. Then, in the interceptor, I grab them off the socket and copy them into the real cls. My app has a mixture of HTTP and WS, and this lets my app only have to check one place, the cls, for these values which I think keeps it cleaner.

Papooch commented 1 year ago

@peisenmann Thanks for the writeup, this might definitely help someone implementing the same!

As for the need to wrap the call to handleConnection in CLS, that's something that will be addressed in #19 in the future.

wormen commented 1 year ago

maybe it will help someone, I did it this way, in appmo AppModule I imported it globally

@Module({
    imports: [
        ClsModule.forRoot({
            global: true,
            guard: {
                mount: true,
                generateId: true,
                idGenerator: () => uuidv4(),
                setup: clsSetupHelper
            },
            interceptor: {
                mount: true,
                generateId: true,
                idGenerator: () => uuidv4(),
                setup: clsSetupHelper
            },
            middleware: {
                mount: true,
                generateId: true,
                useEnterWith: true,
                idGenerator: (req: Request) => req.headers['X-Request-Id'] ?? uuidv4(),
                setup: clsSetupHelper
            }
        })
  ]
})
export class AppModule implements NestModule {
    configure(consumer: MiddlewareConsumer): any {
        consumer.apply(ClsMiddleware).forRoutes('*');
    }
}

clsSetupHelper

export const clsSetupHelper = (cls: ClsService, context: ExecutionContext) => {
    try {
        let xUser: Record<string, any> | null = null;

        if (typeof context.getType !== 'function') {
            xUser = context.headers['x-user'] ?? null;
        } else if (context.getType() === 'http') {
            const request = context.switchToHttp().getRequest();
            xUser = request.headers['x-user'] ?? null;
        } else if (context.getType() === 'ws') {
            const request = context.switchToWs().getClient();
            xUser = request.handshake.headers['x-user'] ?? null;
        }

        cls.set('xUser', xUser);
    } catch (e) {
        console.error('clsSetupHelper ==>', e);
    }
};

works everywhere except handleConnection

@WebSocketGateway({
    cors: {
        origin: shareOrigin()
    }
})
@UseGuards(AuthGuard)
@UseInterceptors(ClsInterceptor)
export class AppWebsocketGateway implements OnGatewayConnection, OnGatewayDisconnect
{
    @WebSocketServer()
    server: Server;
}