Catalin Ciubotaru avatar

SocketIO with Angular but without a wrapper package

To use or not to use a wrapper package. Maybe not.

The Problem

You need to implement SocketIO(probably for some sort of chat) in your application. However, after a bit of Google-ing, you struggle to decide between using an npm package that wraps SocketIO for Angular, or write the implementation yourself.

Good to know before jumping in

In this post I'll talk about SocketIO.
Angular knowledge is also a prerequisite.
Please read the docs for these tools, since they make more sense than I do.

What is SocketIO

Socket.IO is a library that enables low-latency, bidirectional and event-based communication between a Client and a Server. It is built on top of the WebSocket protocol and provides additional guarantees like fallback to HTTP long-polling or automatic reconnection.

Basically it's a way to establish an open communication between the Server and the Client(browser in our case) and allow both parties to send and listen to messages.
This way, the communication is not controlled by the Client, but both participants can send network messages.
In a traditional setup(exclude HTTP 2.0), if the Client wants to get any updates from the Server(for example if the user has any new messages), the Client will have to constantly ask the Server: are there new messages? Are there new messages? Are there new messages?

As you can imagine, this is quite inefficient. With SocketIO, the Server will tell the Client when there are new messages, no need to constantly ask.
The even nicer bit? If the browser does not support SocketIO(which relies on WebSockets), it has multiple fallbacks, up to the point where it will actually do the polling method explained above. So, long story short, you're covered no matter what setup the user has(browser, OS, network etc.)


Rambling about library usage


After a bit of looking around, you'll see that most people use something like ngx-socket-io. This is a pretty good package. It makes your life easy, and makes using SocketIO a breeze. It also saves you time ⏱.

Now, as with any library, it has its advantages and disadvantages. For one, it just gives you the functionality you need. Somebody already implemented it, probably better than you would have, and made it freely available to you. Also, it's being tested by a bunch of other consumers. So it's quite robust(assuming the library in question does not have 7 downloads per week). Did I mention that it also updates itself(well, not really, but it's not you doing the work). This sounds magical 🧙‍♂️.

However, sometimes it is not updated. Sometimes the owner(s) of the library have no time, or motivation to update the library. Then, you're kinda stuck. Also, maybe the library has a ton of functionality that you don't need/use. You might pull in a whole lot of Javascript at the expense of you Core Web Vitals score. Not good. These are a few of the reasons that are always in the back of my head when I'm about to run npm i something-something.

I digress.

How to use SocketIO with Angular


If you're still reading this, chances are you want to implement SocketIO with Angular by yourself, not use a library for it(again, I'm not saying using a library is bad). Here's how you can do it(at least, how I did it).

Install


First, you need to install the SocketIO client library and its types.

npm install socket.io-client

npm install @types/socket.io-client -D

This is the SocketIO package, not a wrapping library or anything. You need this. The types are the stuff needed for your TypeScript to behave and help you along the way.

Setup


After that's done, you need to create a new Angular Service that will handle all the SocketIO stuff for you.

import { Injectable } from '@angular/core';
import { io, Socket } from 'socket.io-client';
import { environment } from '@env/environment';

@Injectable()
export class WebChatSocketService {
private socket: Socket;

setup(authToken: string): void {
this.socket = io(environment.chatURL, {
path: '/chat/',
reconnection: true,
autoConnect: false,
extraHeaders: {
Authorization: 'Bearer ' + authToken
}
});

this.socket.connect();
}
}

Let's go throught what's happening here.
First, we have a setup method. This needs an authToken. This is because(quite often) you will want to pass an Authorization header in your communication. Users will need to be authorized and identified in order to use this feature.
This is not mandatory though. It's just what I needed. If your thing is fully public, ignore the extraHeaders part.
Second, we call the io function, imported from the library we just installed. To this call, we pass the route and path(if needed) where our SocketIO Server is, and we tell it to automatically reconnect when the connection drops, but we tell it to not automatically connect.
We want to be in control of when the connection will be initiated(more on this later).
After the io function returns this new instance of a Socket, we call connect on the Socket.
Hope it makes sense so far.

Error monitoring


Now, we'll want some error monitoring with this.

import { Injectable } from '@angular/core';

import { BehaviorSubject, Observable, Subject } from 'rxjs';

import { ChatConversation, ChatMessage, ChatSocketService } from 'business-logic';
import { io, Socket } from 'socket.io-client';

@Injectable()
export class WebChatSocketService extends ChatSocketService {
private socket: Socket;
private errorSubject: Subject<string>;
errors$: Observable<string>;

setup(authToken: string): void {
this.socket = io(this.chatURL, {
path: '/chat',
reconnection: true,
autoConnect: false,
extraHeaders: {
Authorization: 'Bearer ' + authToken
}
});

this.errors$ = this.setupSocketErrorListeners();
this.socket.connect();
}

private setupSocketErrorListeners(): Observable<string> {
this.errorSubject = new Subject<string>();

this.socket.on('error', (error: Error) => {
this.errorSubject.next('error ' + error);
});

this.socket.on('connect_error', (connectionError: Error) => {
this.errorSubject.next('connect_error ' + {connectionError?.message || connectionError);
});

this.socket.on('connect_timeout', (connectionError: Error) => {
this.errorSubject.next('connect_timeout ' + {connectionError?.message || connectionError);
});

this.socket.on('reconnect_error', () => {
this.errorSubject.next('reconnect_error');
});

this.socket.on('reconnect_failed', () => {
this.errorSubject.next('reconnect_failed');
});

return this.errorSubject.asObservable();
}
}

Here we added a new stream that will listen to, and emit when the Socket has any kind of error. This is where the autoConnect comes into place. We want to setup the error listening before we start the connection, we want to already listen to errors in case the connection fails. The way we set this up is in 3 parts:

  1. We need a Subject that we can easily emit on, when there's an error
  2. We need an Observable stream that we expose to the consumers of this service
  3. We listen to different events on the Socket, and for each of them, we next something on the newly created Subject.

Now we're fully aware of any errors happening with our Socket.

Connection monitoring


The same thing we did with the errors, we can do to monitor the connection.

import { Injectable } from '@angular/core';

import { BehaviorSubject, Observable, Subject } from 'rxjs';

import { ChatConversation, ChatMessage, ChatSocketService } from 'business-logic';
import { io, Socket } from 'socket.io-client';

@Injectable()
export class WebChatSocketService extends ChatSocketService {
private socket: Socket;

private errorSubject: Subject<string>;
errors$: Observable<string>;

private connectionSubject: Subject<boolean>;
connected$: Observable<boolean>;

setup(authToken: string): void {
this.socket = io(this.chatURL, {
path: '/chat/v4/socket',
reconnection: true,
autoConnect: false,
extraHeaders: {
Authorization: 'Bearer ' + authToken
}
});

this.connected$ = this.monitorConnection();
this.errors$ = this.setupSocketErrorListeners();
this.socket.connect();
}

private monitorConnection(): Observable<boolean> {
this.connectionSubject = new BehaviorSubject<boolean>(false);

this.socket.on('connect', () => {
this.connectionSubject.next(true);
});

this.socket.on('connection', () => {
this.connectionSubject.next(true);
});

this.socket.on('disconnect', () => {
this.connectionSubject.next(false);
});

this.socket.on('disconnecting', () => {
this.connectionSubject.next(false);
});

return this.connectionSubject.asObservable();
}

private setupSocketErrorListeners(): Observable<string> {
...
}
}

This is very similar to the previous part. The difference is in the events that we listen to. Here, we listen to events like connection and disconnect. Then, we map these to true or false.
We're doing this so it's easier for the consumer of the API to trigger actions when the connection is actually established, and to react to when the connection is lost.
This makes the status transparent to the consumer.

Loading all conversations


Now that the connection is established, how do we actually tell it to do stuff? For example, this is how we would load all conversations.

loadConversations(): Observable<ChatConversation[]> {
return new Observable<ChatConversation[]>((observer) => {
this.socket.emit(
'get-conversations', {},
(err: unknown, response: { conversations: ChatConversation[] }) => {
if (err) {
observer.error(err);
return;
}
observer.next(conversations);
observer.complete();
}
);
});
}

For the sake of brevity, I'm not going to put in the whole code so far, just the newly added method(which is public).
Here, we create a new Observer that we emit on, based on the result of socket.emit.
So, what we do here is we emit the get-conversation message, and if the response is an error, our newly created Observer will error. Otherwise, our Observer will emit the new conversations.
The important part here is to not forget to complete the Observer, to not leave it open. If this is a bit too much, I think the observer documentation will help.

Loading messages in a conversation


Following up on that, here's how we can load the messages in a specific conversation:

loadChatMessages(conversationId: string, offset: number): Observable<ChatMessage[]> {
const getMessagesOptions = {
conversationId,
limit: this.messageLimit,
offset
};

return new Observable<ChatMessage[]>((observer) => {
this.socket.emit(
'get-messages',
getMessagesOptions,
(err: unknown, response: { messages: ChatMessage[] }) => {
if (err) {
observer.error(err);
return;
}
observer.next(messages);
observer.complete();
}
);
});
}

This is very similar to loading all the conversations. The difference is in the fact that now we also pass some options to the message emission. This allows us to handle pagination for example.

Listening for new messages


Another useful method is the one that sets up the listening for new messages. This one is different because it's not triggered by the Client, but it listens to when the Server has something to say.

private newMessagesSubject: Subject<ChatMessage>;

listenForMessages(): Observable<ChatMessage> {
this.newMessagesSubject?.complete();

this.newMessagesSubject = new Subject<ChatMessage>();
this.socket.on('new-message', (msg: ChatMessage) => {
this.newMessagesSubject.next(msg);
});

return this.newMessagesSubject.asObservable();
}

For this, we need a new variable in the service. This way, we'll be able to stop the listening when the service is destroyed for example.
In here, first we complete the previous stream, if present. Then, we listen for the new-message event, and when it's triggered, we emit a new value in our Subject.
Again, the key here is that this is a hot stream, controlled by the Server. The Client is just listening here, so we need to keep track of that.

Sending a new message


What about sending a new message? Well, with all this accumultated knowledge, this is pretty straight forward:

sendChatMessage(recipientId: string, message: string): Observable<ChatMessage> {
return new Observable<ChatMessage>((observer) => {
this.socket.emit(
'send-message',
{
receiver: recipientId,
message
},
(err: unknown, createdMessage: ChatMessage) => {
if (err) {
observer.error(err);
return;
}

observer.next(createdMessage);
observer.complete();
}
);
});
}

Very similar to the other Client controlled events. We create an Observer, send an event and handle the response. Key thing again, don't forget to complete the stream.

Destroy


Last but not least, it would be useful to be able to tear all this down. When the user logs out for example. Here's how the stop method could look like:

stop(): void {
this.socket?.disconnect();
this.newMessagesSubject?.complete();
this.connectionSubject?.complete();
this.errorSubject?.complete();
}

What we're doing here is disconnecting the Socket, completing the connection controlled streams, and completing all the Server Controlled streams. This insures there are no leaks in the app.

Final thoughts


This is how I ended up implementing SocketIO on an app that I'm working on. It doesn't mean this is the best way, it doesn't mean this has no disadvantages and it doesn't mean you should copy/paste this. It's here to serve as inspiration in case you find yourself in a similar situation.

Want more?

If you want to know more about this, or something doesn't make any sense, please let me know.

Also, if you have questions, you know where to find me… On the internet!

Be kind to each other! 🧡

Over and out

My Twitter avatar
Catalin Ciubotaru 🚀

Here's how I implemented SocketIO in an Angular app without using a wrapper package. https://catalincodes.com/posts/socketio-with-angular #SocketIO #Angular

Oct 21, 2022
42 people are talking about this

Wanna read more?

Updates delivered to your inbox!

A periodic update about my life, recent blog posts, how-tos, and discoveries.

No spam - unsubscribe at any time!