I have to implement websocket communication in my nest.js app. I've successfully setup the websocket gateway and I have tested it with postman. My code looks like this
export class SocketIOAdapter extends IoAdapter {
constructor(private app: INestApplicationContext, private configService: ConfigService) {
super(app);
}
createIOServer(port: number, options: ServerOptions) {
const clientPort = parseInt(this.configService.getOrThrow("PORT"));
const cors = {
origin: [
`http://localhost:${clientPort}`,
new RegExp(`/^http:\/\/192\.168\.1\.([1-9]|[1-9]\d):${clientPort}$/`),
],
};
const optionsWithCORS: ServerOptions = {
...options,
cors,
};
const server: Server = super.createIOServer(port, optionsWithCORS);
const orderRepository = this.app.get(OrderRepository);
server
.of("/orders")
.use(createTokenMiddleware(orderRepository));
return server;
}
}
const createTokenMiddleware =
(orderRepository: OrderRepository) =>
async (socket: Socket, next) => {
// here I run some logic using my order repository
next();
} catch {
next(new Error("FORBIDDEN"));
}
};
And
#WebSocketGateway({
namespace: "/orders",
})
#Injectable()
export class OrderGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(OrderGateway.name);
#WebSocketServer() io: Namespace;
afterInit(): void {
this.logger.log("Websocket Gateway initialized.");
}
async handleConnection(client: Socket) {
const sockets = this.io.sockets;
// here I run some logic to know which rooms to use for this client
const roomsToJoin = [...]
await client.join(roomsToJoin);
}
async handleDisconnect(client: Socket) {
this.logger.log(`Disconnected socket id: ${client.id}`);
}
public emitOrderStatusChangeNotification(order: OrderDTO) {
this.io
.to("Here I put some roomId that depends on order")
.emit("order_status_changed", JSON.stringify(order));
}
}
Now, whenever I want to send a notification, I inject the OrderGateway and call emitOrderStatusChangeNotification. This works fine, however, my app is deployed on several instances behind a load balancer. The latter breaks this approach as socket clients may be connected to a different server from the one I'm sending the notification. So, the next step to scale web sockets (as far as I understand) is to use a broker. I tried to use Redis pub/sub in the following way. I have this two classes:
#Injectable()
export class NotificationPublisherService {
constructor(#Inject("ORDER_NOTIFICATION_SERVICE") private client: ClientProxy) {}
async publishEvent(order: OrderDTO) {
console.log("will emit to redis");
this.client.emit(Constants.notificationEventName, order);
}
}
#Controller()
export class NotificationSuscriberController {
private readonly logger = new Logger(NotificationSuscriberController.name);
constructor(private readonly orderGateway: OrderGateway) {}
#EventPattern(Constants.notificationEventName)
async handleOrderStatusChangeEvent(order: OrderDTO) {
try {
this.orderGateway.emitOrderStatusChangeNotification(order);
} catch (err) {
this.logger.log("error sending notification");
}
}
As you can see, I'm injecting orderGateway in the class that have the method that handles the data from redis and in that handler I send the notification. Finally, I replaced all the invocations of emitOrderStatusChangeNotification to the publishEvent method of NotificationPublisherService. After doing this, the flow works well except from the last step. This means, the data is put on redis and read by the suscriber, which tries to send the websocket notification. However, when logging the connected clients for that room in emitOrderStatusChangeNotification method, I'm getting that there are no connected clients, even though I confirmed there where connected clients on that room (I did this by logging the list of connected clients after doing client.join in the handleConnection method of OrderGateway). My best guess is that an instance of OrderGateway handles the socket connection and a different instance of OrderGateway is processing the data from Redis broker. I tried to explicitly set the scope of the Gateway to Default to guarantee that my app has only one instance of OrderGateway (I also confirmed that it has not any request scoped dependency that could bubble up and make it not default scoped). It did not work and I'm out of ideas. Does anyone know what could be happening? Thanks in advance
EDIT
As Gregorio suggested in the answers, I had to extend my adapter as explained in the docs, the following code worked for me
export class SocketIOAdapter extends IoAdapter {
private adapterConstructor: ReturnType<typeof createAdapter>;
constructor(private app: INestApplicationContext, private configService: ConfigService) {
super(app);
}
async connectToRedis(): Promise<void> {
const pubClient = createClient({ url: "redis://localhost:6379" });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
this.adapterConstructor = createAdapter(pubClient, subClient);
}
createIOServer(port: number, options: ServerOptions) {
const clientPort = parseInt(this.configService.getOrThrow("PORT"));
const cors = {
origin: [
`http://localhost:${clientPort}`,
new RegExp(`/^http:\/\/192\.168\.1\.([1-9]|[1-9]\d):${clientPort}$/`),
],
};
const optionsWithCORS: ServerOptions = {
...options,
cors,
};
const server: Server = super.createIOServer(port, optionsWithCORS);
const orderRepository = this.app.get(OrderRepository);
server
.adapter(this.adapterConstructor)
.of(`/orders`)
.use(createTokenMiddleware(orderRepository));
return server;
}
}
const createTokenMiddleware =
(orderRepository: OrderRepository) =>
async (socket: Socket, next) => {
// here I run some logic using my order repository
next();
} catch {
next(new Error("FORBIDDEN"));
}
};
}
and in my main.ts
const redisIoAdapter = new SocketIOAdapter(app, configService);
await redisIoAdapter.connectToRedis();
Have you tried following this page from the nest.js docs? I think it might help you in what you're looking for. You should write in your SocketIOAdapter what it says there in order to connect with Redis, it is not necessary to have the NotificationPublisherService or the NPController.
https://docs.nestjs.com/websockets/adapter
Related
I want to ask for an advice.
In my Nest application, I store all sessions in Redis Database. And I have CacheModule that works with redis so I am able to manually check sessions in DB.
I need to inject CacheModule in WebsocketAdapter class, cause I need to validate sessiondId inside cookie with existing session in my Redis cache.
Here is current version of the WebsocetAdapter class. For now I just decided to tag socket with sessionId and validate it later, but it is not what I want.
export class WebsocketAdapter extends IoAdapter {
createIOServer(port: number, options?: any) {
const server = super.createIOServer(port, options);
server.use(async (socket: AuthenticatedSocket, next) => {
const { cookie: clientCookie } = socket.handshake.headers;
if (!clientCookie) return next(new Error('Не аутентифицирован. Запрос без cookie'));
const { ['connect.sid']: sessionId } = cookie.parse(clientCookie);
if (!sessionId) return next(new Error('Запрос без sessionId'));
socket.user = sessionId;
next();
});
return server;
}
}
I cannot inject CacheModule with constructor, since I extended IoAdapter class and applying WebsocketAdapter like this:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ credentials: true, origin: true });
const adapter = new WebsocketAdapter(app);
app.useWebSocketAdapter(adapter);
await app.listen(9001);
}
Maybe, I shall apply this adapter as a Middleware to websocket route, but I don't know how to do this.
Can you help me out with this?
I am trying to use mock-sockets with Cypress, to mock graphql subscription.
I tried this code but it doesn’t work with Apollo Client because Apollo use ‘WebSocketLink’ instead of 'WebSocket'.
import {
Server,
WebSocket,
} from 'mock-socket';
it('test', () => {
cy.visit('/', {onBeforeLoad(window) {
cy.stub(window, 'WebSocket', url => {
if (mockServer) {
mockServer.stop();
}
mockServer = new Server(url);
mockServer.on('connection', socket => {
mockSocket = socket;
});
mockSocket = new WebSocket(url);
return mockSocket;
});
My question is what is the object of ‘WebSocketLink’ that I need to stub (instead of window)? Or, is there another way to mock graphql web socket?
As I understand RSocket-JS supports routing messages using encodeCompositeMetadata and encodeRoute, however, I cannot get the server to accept a fireAndForget message. The server constantly logs the following message:
o.s.m.r.a.support.RSocketMessageHandler : No handler for fireAndForget to ''
This is the server mapping I am trying to trigger:
#Controller
public class MockController {
private static final Logger LOGGER = LoggerFactory.getLogger(MockController.class);
#MessageMapping("fire-and-forget")
public Mono<Void> fireAndForget(MockData mockData) {
LOGGER.info("fireAndForget: {}", mockData);
return Mono.empty();
}
}
This is the TypeScript code that's trying to make the connection:
client.connect().subscribe({
onComplete: socket => {
console.log("Connected to socket!")
socket.fireAndForget({
data: { someData: "Hello world!" },
metadata: encodeCompositeMetadata([[MESSAGE_RSOCKET_ROUTING, encodeRoute("fire-and-forget")]])
});
},
onError: error => console.error(error),
onSubscribe: cancel => {/* call cancel() to abort */ }
});
I've also tried adding the route in other ways (metadata: String.fromCharCode('route'.length)+'route') I found on the internet, but none seem to work.
What do I need to do to format the route in a way that the Spring Boot server recognizes it and can route the message correctly?
Binary only communication when using CompositeMetadata
Please make sure that you have configured your ClientTransport with binary codecs as follows:
new RSocketWebSocketClient(
{
url: 'ws://<host>:<port>'
},
BufferEncoders,
),
Having Binary encoders you will be able to properly send your routes using composite metadata.
Also, please make sure that you have configured metadataMimeType as:
...
const metadataMimeType = MESSAGE_RSOCKET_COMPOSITE_METADATA.string; // message/x.rsocket.composite-metadata.v0
new RSocketClient<Buffer, Buffer>({
setup: {
...
metadataMimeType,
},
transport: new RSocketWebSocketClient(
{
url: 'ws://<host>:<port>',
},
BufferEncoders,
),
});
Note, once you enabled BufferEncoders your JSONSeriallizer will not work and you would need to encode your JSON to binary yours selves ( I suggest doing that since in the future versions we will remove support of Serializers concept completely). Therefore, your request has to be adjusted as it is in the following example:
client.connect().subscribe({
onComplete: socket => {
console.log("Connected to socket!")
socket.fireAndForget({
data: Buffer.from(JSON.stringify({ someData: "Hello world!" })),
metadata: encodeCompositeMetadata([[MESSAGE_RSOCKET_ROUTING, encodeRoute("fire-and-forget")]])
});
},
onError: error => console.error(error),
onSubscribe: cancel => {/* call cancel() to abort */ }
});
Use #Payload annotation for your payload at spring backend
Also, to handle any data from the client and to let Spring know that the specified parameter argument is your incoming request data, you have to annotate it with the #Payload annotation:
#Controller
public class MockController {
private static final Logger LOGGER = LoggerFactory.getLogger(MockController.class);
#MessageMapping("fire-and-forget")
public Mono<Void> fireAndForget(#Payload MockData mockData) {
LOGGER.info("fireAndForget: {}", mockData);
return Mono.empty();
}
}
I have a simple websocket server built following this example
import { createServer, Server } from 'http';
import * as express from 'express';
import * as socketIo from 'socket.io';
export class MobileObjectServer {
public static readonly PORT:number = 8081;
private app: express.Application;
private server: Server;
private io: socketIo.Server;
private port: string | number;
constructor() {
this.createApp();
this.config();
this.createServer();
this.sockets();
this.listen();
}
private createApp() {
this.app = express();
}
private createServer() {
this.server = createServer(this.app);
}
private config() {
this.port = process.env.PORT || MobileObjectServer.PORT;
}
private sockets() {
this.io = socketIo(this.server);
}
private listen() {
this.server.listen(this.port, () => {
console.log('Running server on port %s', this.port);
});
this.io.on('connect', (socket: any) => {
console.log('Connected client on port %s.', this.port);
socket.on('message', m => {
console.log('[server](message): %s', JSON.stringify(m));
this.io.emit('message', m);
});
socket.on('disconnect', () => {
console.log('Client disconnected');
});
});
}
public getApp(): express.Application {
return this.app;
}
}
This code runs smoothly. I can launch the websocket server on my machine and I can connect if I use the socket.io-client library.
Now I would like to connect to such server from a client using the webSocket and WebSocketSubject facilities provided by RxJs but I am encountering some basic problems just trying to connect.
If I do
import { webSocket } from 'rxjs/observable/dom/webSocket';
webSocket('http://localhost:8081')
nothing happens, no connection is established.
If I use 'ws://localhost:8081' as connection string then I get an error like this
WebSocketSubject.js:142 WebSocket connection to 'ws://localhost:8081/' failed: Connection closed before receiving a handshake response
I am sure I am making a very basic mistake, but I have currently no clue on where.
It is more or less impossible to connect to a socket.io server with pure web sockets. Socket.io adds a lot on top of the Websocket standard that is not compatible with pure web sockets. if you want to skip using socket.io on the client side I would advice using some other library server side, one example would be ws
I have a synchronous operation that is run somewhere down a chain of RxJS observables subscription.
This synchronous operation sets data on local storage (synchronous) that is required further down the chain in order to perform a http call (asynchronous/observable).
Here is a summary of the sequence:
Async operation returning an observable called
Sync operation setting data on local storage
Async operation using local storage date and returning an observable
Final subscription
By the time 3. is called, it seems data is not available on local storage - supposed to have been set by 2.
The above is just a simplification of the issue.
Here is the full code (in typescript):
This is called by a form (located in a component):
resetPassword() {
this.submitted = true;
if (this.passwordResetForm.valid) {
this.route.params.map(params => params['userAccountToken'])
.switchMap(userAccountToken => {
return Observable.concat(
this.userAccountService.resetPassword(Object.assign(this.passwordResetForm.value.passwordReset, {token: userAccountToken})),
this.sessionService.signinByUserAccountToken(userAccountToken)
);
})
//Will require the UserAccountResolve below which will itself fail because 'x-auth-token' is not yet available on local storage
.subscribe(() => this.router.navigate(['/dashboard']));
}
}
from UserAccountService:
resetPassword(passwordResetForm) {
return this.http.put(this.urls.USER_ACCOUNT.RESET_PASSWORD, passwordResetForm);
}
from SessionService:
signinByUserAccountToken(userAccountToken: string) {
return this.http.post(format(this.urls.AUTHENTICATION.SIGNIN_BY_USER_ACCOUNT_TOKEN, {userAccountToken}), null)
.do(response => this.setPersonalInfo(response.headers.get('x-auth-token')));
}
private setPersonalInfo(sessionToken) {
localStorage.setItem('authenticated', 'true');
localStorage.setItem('sessionToken', sessionToken);
this.authenticated$.next(true);
}
UserAccountResolve:
import {Injectable} from '#angular/core';
import {Resolve, ActivatedRouteSnapshot} from '#angular/router';
import {UserAccount} from '../shared/models/useraccount.model';
import {AuthenticatedHttpClient} from '../shared/services/authenticated-http-client.service';
import {URLS} from '../urls/URLS';
#Injectable()
export class UserAccountResolve implements Resolve<UserAccount> {
private urls;
constructor(private authenticatedHttpClient: AuthenticatedHttpClient) {
this.urls = URLS;
}
resolve(route: ActivatedRouteSnapshot) {
//Will fail
return this.authenticatedHttpClient.get(this.urls.USER_ACCOUNT.USER_ACCOUNT)
.map(response => response.json());
}
}
AuthenticatedHttpClient:
#Injectable()
export class AuthenticatedHttpClient {
static createAuthorizationHeader(headers: Headers) {
//Is not available on local storage when required
headers.append('x-auth-token', localStorage.getItem('sessionToken'));
}
constructor(private http: Http) {
}
get(url) {
let headers = new Headers();
AuthenticatedHttpClient.createAuthorizationHeader(headers);
return this.http.get(url, {
headers: headers
});
}
...
Can someone please help?
If I understand your Problem correctly, you want to delay redirecting your user until both HTTP requests have been done. Currently you subscribe to your Observable, causing the redirect to happen as soon as it observes the first result, which is in this case the result of userAccountService.resetPassword().
If you just want to wait for all requests to finish, you can subscribe onCompleted like this:
observable.subscribe(
_ => {},
err => { /* Don't forget to handle errors */ },
() => this.router.navigate(['/dashboard']))
However having an Observable whose results you do not care about is a sign, that your App might need a refactor one of these days.