Skip to content

Instantly share code, notes, and snippets.

@jehugaleahsa
Last active July 23, 2024 12:02
Show Gist options
  • Save jehugaleahsa/825518ee860b84d3a041c17ccf095f61 to your computer and use it in GitHub Desktop.
Save jehugaleahsa/825518ee860b84d3a041c17ccf095f61 to your computer and use it in GitHub Desktop.
Consume SignalR Using Angular 2+

Consume SignalR Using Angular 2+

The beautiful thing about the web was that this article is outdated before I even started writing it. I am going to show how I was able to encapsulate SignalR functionality behind a simple service that works nicely in an Angular 2+ environment. I find myself frequently ruminating about the fact that Angular 2+ is more of an "environment" than a framework. It's not just a handful of libraries strewn together - it literally drives your development process - pretty much forcing you to introduce Node.js, TypeScript and a build tool (eg., Webpack) into your daily activities. It also strongly reinforces how you organize your files and what you name them. It's so painfully opinionated and I love it!

Services

If you are working on an Angular 2+ application and you don't have a lot of services, you are doing something woefully wrong. One of the biggest parts of getting your head wrapped around Angular 2+ is familiarizing yourself with their new approach to dependency injection and getting used to describing those dependencies in the providers section of your modules and components. This article is going to talk about how you use SignalR+MVC5 with Angular. If you are using .NET Core 2.0+, you can use the new SignalR libraries for that; most of the code on the client side will be about the same, so the code below should be easy to adapt.

I am going to show you a simple SignalR service that you can inject into your components. One of the big things you need to know about SignalR is that everything is broken out into "hubs". Clients can subscribe to a hub to be notified when something happens on the server, listening for specific events. The thing to realize is that events coming from the server can happen at any time, so it's not like requesting data from a database or REST API where you just sit there waiting until you get a response. The good thing is JavaScript is well-suited to this sort of asynchronous behavior. In Angular, the correct way to deal asynchronous events is to use RxJS's Observables. Observables are like streams or collections, except that you can't predict when the next element will come along. Angular has great support for Observables, as you'll see.

Hub factory

I want to support creating a separate "hub" object for each server-side hub. Each hub has a different name and can be configured differently. Because the hub object needs to be named, I can't directly inject a hub into my component's constructor. Instead, I need a "hub factory" that can create hubs:

import { Injectable, NgZone } from "@angular/core";

import { SignalRHub } from "./signalr-hub.service";

@Injectable()
export class SignalRHubFactory {
    public constructor(private zone: NgZone) {
    }

    public createHub(hubName: string): SignalRHub {
        return new SignalRHub(this.zone, hubName);
    }
}

We decorate the class with Injectable since it needs to be passed to my components. As I said, there is a single method createHub that accepts the name of the hub as an argument. It simply creates a new SignalRHub (which I show below) and returns it. You'll notice that I am passing along an NgZone - I will explain this more in a moment, but for now just know we need to notify Angular when the server sends us something.

Hub at a glance

Here is the full definition of the SignalRHub class. I will break it apart to explain what each piece is doing:

import { EventEmitter, NgZone, OnDestroy } from "@angular/core";
import * as jQuery from "jquery";
import { Observable, Observer, ReplaySubject, Subject } from "rxjs";

export enum HubConnectionState {
    Connecting = 1,
    Connected = 2,
    Reconnecting = 3,
    Disconnected = 4
}

export class SignalRHub implements OnDestroy {
    private hubName: string;
    private hubConnection: SignalR.Hub.Connection = null;
    private hubProxy: SignalR.Hub.Proxy = null;
    private subscriptions = new Map<string, Subject<any>>();

    public constructor(private zone: NgZone, hubName: string) {
        if (typeof jQuery === "undefined") {
            throw new Error("jQuery is not defined on the global object.");
        }
        if (typeof jQuery.hubConnection === "undefined") {
            throw new Error("SignalR is not defined on the jQuery object.");
        }
        this.hubName = hubName;
        this.hubConnection = jQuery.hubConnection();
        this.hubConnection.url = "/CAM/signalr";

        this.hubConnection.stateChanged((state: SignalR.StateChanged) => {
            const newState = this.getConnectionState(state);
            this.connectionStateChange.next(newState);
        });

        this.hubConnection.error((error: SignalR.ConnectionError) => {
            this.connectionError.next(error);
        });
    }

    public ngOnDestroy(): void {
        this.connectionStateChange.complete();
        this.connectionError.complete();
    }

    private getConnectionState(state: SignalR.StateChanged): HubConnectionState {
        switch (state.newState) {
            case SignalR.ConnectionState.Connecting:
                return HubConnectionState.Connecting;
            case SignalR.ConnectionState.Connected:
                return HubConnectionState.Connected;
            case SignalR.ConnectionState.Reconnecting:
                return HubConnectionState.Reconnecting;
            case SignalR.ConnectionState.Disconnected:
                return HubConnectionState.Disconnected;
            default:
                return HubConnectionState.Connecting;
        }
    }

    public connectionStateChange = new EventEmitter<HubConnectionState>();

    public connectionError = new EventEmitter<Error>();

    public disconnect(): void {
        if (this.hubProxy == null) {
            return;
        }
        this.hubConnection.stop(true, true);
        this.subscriptions.forEach((subject) => {
            subject.complete();
        });
        this.subscriptions.clear();
        this.hubProxy = null;
    }

    public async invoke(actionName: string, ...data: any[]): Promise<void> {
        await this.prepareHubProxy();
        await this.hubProxy.invoke(actionName, ...data);
    }

    public async listen(eventName: string): Promise<Observable<any>> {
        const subject = await this.getCachedSubject(eventName);
        return this.getZonedObservable(subject);
    }

    private async getCachedSubject(eventName: string): Promise<Subject<any>> {
        if (this.subscriptions.has(eventName)) {
            return this.subscriptions.get(eventName);
        }
        const subject = new ReplaySubject<any>(1);
        await this.listenAsync(eventName, (...data: any[]) => {
            subject.next(data);
        });
        this.subscriptions.set(eventName, subject);
        return subject;
    }

    private getZonedObservable<T>(observable: Observable<T>): Observable<T> {
        return Observable.create((observer: Observer<T>) => {
            const onNext = (value: T) => this.zone.run(() => observer.next(value));
            const onError = (error: any) => this.zone.run(() => observer.error(error));
            const onComplete = () => this.zone.run(() => observer.complete());
            return observable.subscribe(onNext, onError, onComplete);
        });
    }

    private async listenAsync(eventName: string, handler: (...data: any[]) => void): Promise<void> {
        await this.prepareHubProxy();
        this.hubProxy.on(eventName, handler);
    }

    private async prepareHubProxy(): Promise<void> {
        if (this.hubProxy != null) {
            return;
        }
        const hubProxy = this.hubConnection.createHubProxy(this.hubName);
        hubProxy.on("$$__FAKE_EVENT", () => { return; });
        await this.hubConnection.start();
        this.hubProxy = hubProxy;
    }
}

Dealing with SignalR's dependency on jQuery

The first thing to notice is import * as jQuery from "jquery": this is an artifact of working with an older JavaScript library in a modern setting. Back when jQuery was ruling the universe, it was pretty popular to expose your library by extending the global $ (or jQuery) object. By today's standards, this is an abomination! In order for TypeScript to know about jQuery and SignalR, I had to install type definitions:

npm install --save-dev @types/jquery
npm install --save-dev @types/signalr

Even then, SignalR is a bit odd because its type definitions are an extension of the jQuery type definitions. In order to refer to the SignalR types, I have to use namespaces. I should also warn that TypeScript uses ECMAScript modules, so global pollution isn't possible. That means you will probably need to do some magic to expose jQuery to your modules that use it. Using Webpack, I had to add use the webpack.ProvidePlugin and the expose-loader to make this work (https://stackoverflow.com/questions/28969861/managing-jquery-plugin-dependency-in-webpack). Had SignalR not been implemented with jQuery as a dependency, none of this would have been necessary. Fortunately, it sounds like the new .NET Core JavaScript client is freed of this nightmare.

Out of paranoia, I verify jQuery and hubConnection are defined in the constructor:

if (typeof jQuery === "undefined") {
    throw new Error("jQuery is not defined on the global object.");
}
if (typeof jQuery.hubConnection === "undefined") {
    throw new Error("SignalR is not defined on the jQuery object.");
}

EventEmitters for the connection

As a general rule, I avoid doing any work in constructors - you'll notice I create a "hub connection" object but I am not actually connecting to anything yet (aka., calling start):

this.hubName = hubName;
this.hubConnection = jQuery.hubConnection("/signalr");

this.hubConnection.stateChanged((state: SignalR.StateChanged) => {
    const newState = this.getConnectionState(state);
    this.connectionStateChange.next(newState);
});

this.hubConnection.error((error: SignalR.ConnectionError) => {
    this.connectionError.next(error);
});

I should point out that hard-coding the URL like that is probably a bad idea. It might be better to move the URL into a settings file.

The last two statements are interesting. Both connectionStateChange and connectionError are instances of EventEmitter. You can think of EventEmitter as an Observable that you can add items to. In RxJS, Observables that you can add items to are called "subjects" (don't even get me started on the reactive programming name choices!). In fact, EventEmitters are implemented in terms of RxJS subjects, with extra methods to make them easier to work with in Angular. Truth be told, I don't know when you should choose one over the other and I probably could have implemented SignalRHub with one or the other but ended up using both.

In the system I work on, I actually don't use these two emitters. In retrospect, they were just adapted from another example and could be removed. The hub connection class exposes several events that you can listen to. However, most of them deal with the state of the connection changing and stateChanged is a convenient catch-all. I went through the effort of wrapping SignalR.ConnectionState with my own HubConnectionState enum to save clients from knowing too much about SignalR. The error event could be useful if you are doing some sort of client-side logging. You'll notice in ngOnDestroy that I explicitly complete these two emitters to avoid memory leaks. I could argue that ngOnDestroy should also call disconnect.

this.connectionStateChange.complete();
this.connectionError.complete();

Preparing a hub proxy

A SignalR client can listen for events and send messages to the server, using listen and invoke respectively. If you follow through the code for either of these methods, you'll see they are both calling prepareHubProxy:

private async prepareHubProxy(): Promise<void> {
    if (this.hubProxy != null) {
        return;
    }
    const hubProxy = this.hubConnection.createHubProxy(this.hubName);
    hubProxy.on("$$__FAKE_EVENT", () => { return; });
    await this.hubConnection.start();
    this.hubProxy = hubProxy;
}

I'll be the first to admit that this code is complex. Part of it is that I establish the connection lazily; this saves me from implementing an explicit connect method. I blame most of the "complexity" on the way SignalR works. Before you can call start on the hub connection, you must be listening for at least one event on a hub proxy. You can read more about it in the notes section here. Until you call on, SignalR may not work as expected. That's a pretty annoying restriction, as it turns out.

There are times when all you want to do is send messages to the server (call invoke) - you don't even want to listen for anything. For example, in my application, users want to see who else is looking at a record. When you navigate to a record in the UI, the JavaScript sends out a heartbeat to the hub to let SignalR (and the other users) know that you're still looking at it. In that case, calling on feels completely wasteful, but it's necessary in order for SignalR to work properly.

My not-so-ingenious solution to this problem is to listen for a non-existant event right before calling start. That's what's going on with the $$__FAKE_EVENT line. I then cache the hub proxy so I can detect when I am already connected.

Implementing invoke

Now we can focus on the implementation of invoke. The invoke method simply sends the server a message. In my application, I invoke "review" and "finish" operations on my hub to track when a user is looking at a record or when they are done, respectively. After preparing the hub proxy, I simply call invoke on it:

public async invoke(actionName: string, ...data: any[]): Promise<void> {
    await this.prepareHubProxy();
    await this.hubProxy.invoke(actionName, ...data);
}

There are some restrictions on the number of arguments you can pass to a hub operation (something like 8), but I don't concern myself with it here.

Implementing listen

The listen method is much more complicated. The first thing I want to protect against is returning different Observables when calling listen multiple times for the same event. Technically this code should work as expected:

const hub = this.hubFactory.createHub("myHub");
const messages1$ = await hub.listen("onMessage");
const messages2$ = await hub.listen("onMessage");

I would argue that messages1$ and messages2$ should be the same Observable. The only way to correctly do that is to cache our Observable so it gets returned for the same event. That's exactly what getCachedSubject is doing for us:

private async getCachedSubject(eventName: string): Promise<Subject<any>> {
    if (this.subscriptions.has(eventName)) {
        return this.subscriptions.get(eventName);
    }
    const subject = new ReplaySubject<any>(1);
    await this.listenAsync(eventName, (...data: any[]) => {
        subject.next(data);
    });
    this.subscriptions.set(eventName, subject);
    return subject;
}

private async listenAsync(eventName: string, handler: (...data: any[]) => void): Promise<void> {
    await this.prepareHubProxy();
    this.hubProxy.on(eventName, handler);
}

If we see the event name in subscriptions (a Map<string, Subject>), we simply return the cached Subject. Otherwise, we create a new Subject and store it in subscriptions. I use a ReplaySubject so new listeners get the last value sent from the server; this might not be appropriate for your particular application. The real work is being perform within the listenAsync callback: the value is pushed onto the Subject using next.

Calling on twice

What's curious/unfortunate about listenAsync is that it will end up calling hubProxy.on("$$__FAKE_EVENT", () => { return; }); right before calling hubProxy.on(eventName, handler). Technically, I could have avoided making this unnecessary call to on, but I didn't like how ugly it made the code. Here's how prepareHubProxy could have been implemented:

private async prepareHubProxy(listener: (proxy: SignalR.Hub.Proxy) => void): Promise<void> {
    if (this.hubProxy != null) {
        listener(this.hubProxy);
        return;
    }
    const hubProxy = this.hubConnection.createHubProxy(this.hubName);
    listener(hubProxy);
    await this.hubConnection.start();
    this.hubProxy = hubProxy;
}

And then invoke and listenAsync could have been implemented like this:

public async invoke(actionName: string, ...data: any[]): Promise<void> {
    await this.prepareHubProxy((proxy) => proxy.on("$$__FAKE_EVENT", () => { return; }));
    await this.hubProxy.invoke(actionName, ...data);
}

private async listenAsync(eventName: string, handler: (...data: any[]) => void): Promise<void> {
    await this.prepareHubProxy((proxy) => proxy.on(eventName, handler));
}

I'll let you decide which is better...

Zones

The next method to talk about is getZonedObservable. Angular has no innate knowledge of SignalR, so it can't respond when the server sends the client an update. In order to make Angular SignalR-aware, you have to work with the NgZone class. You can isolate those interactions by creating an Observable that calls zone.run; I found this StackOverflow post invaluable. If you search for "NgZone", you'll find plenty of articles explaining what NgZone does. Fundamentally, it is what allows Angular 2+ to efficiently render changes on your page, making AngularJS's digest cycle an obsolete relic of the past.

Once we have our zone-aware Observables, we can wrap our Subjects listening for events, which is what listen is doing:

public async listen(eventName: string): Promise<Observable<any>> {
    const subject = await this.getCachedSubject(eventName);
    return this.getZonedObservable(subject);
}

One idea might be to make listen a generic method, so we can say hub.listen<MyMessage>("onMessage"), giving us a type-safe Observable.

Disconnect

The disconnect method resets our hub, closing any open connections and clearing our cached values.

public disconnect(): void {
    if (this.hubProxy == null) {
        return;
    }
    this.hubConnection.stop(true, true);
    this.subscriptions.forEach((subject) => {
        subject.complete();
    });
    this.subscriptions.clear();
    this.hubProxy = null;
}

Looping though and calling complete on the subjects tells downstream subscribers to cleanup after themselves.

Wrapping hubs

In my application, my components do not work directly with hubs. Instead, I put the hub factory and hubs behind other services with type-safe interfaces and meaningful names. For example, to track what users are looking at a record, I would have a wrapper class like this:

import { Injectable } from "@angular/core";
import { Observable } from "rxjs";

import { UserModel } from "app/models/models.module";

import { SignalRHubFactory } from "./signalr-hub-factory.service";
import { SignalRHub } from "./signalr-hub.service";

interface IRecordView {
    user: UserModel;
    recordId: number | null;
}

@Injectable()
export class RecordViewsSignalRService {
    private hub: SignalRHub = null;

    public constructor(hubFactory: SignalRHubFactory) {
        this.hub = hubFactory.createHub("RecordViewsHub");
    }

    public async onRecordOpened(): Promise<Observable<IRecordView>> {
        const pairs = await this.hub.listen("onRecordOpened");
        return pairs.map((p) => ({ user: p[0], recordId: p[1] }));
    }

    public async onRequestClosed(): Promise<Observable<IRecordView>> {
        const pairs = await this.hub.listen("onRecordClosed");
        return pairs.map((p) => ({ user: p[0], recordId: p[1] }));
    }

    public async review(recordId: number): Promise<void> {
        await this.hub.invoke("Review", recordId);
    }

    public async finish(recordId: number): Promise<void> {
        await this.hub.invoke("Finish", recordId);
    }

    public disconnect(): void {
        this.hub.disconnect();
    }
}

Now components using RecordViewsSignalRService are shielded from SignalR and don't need to worry about the "factory". Yay!

Conclusion

If anything, this article demonstrates just how much work can go into building "correct" Angular applications. Personally, I find this code a pleasure! I'd gladly put in the extra effort to modularize my application this way, as it makes the code more maintainable. Now SignalR is just a service I consume and all the complexity is hidden away in one place.

Addendum - What I've learned

It's been several weeks and I've learned a lot from working with these services. One of the challenges I ran into is that your browser has a limit to the number of connections it can have open at any time. Many of the tranport protocols used by SignalR use up one of those connections. Therefore, you have to be careful how many connections any one page opens. Fortunately, SignalR is designed in such a way that you can use the same connection for multiple hubs simultaneously. Furthermore, the services I provide above do not need to change their interface (very much) to reuse the same connection. In fact, in my application, I didn't have to change a single line of code outside of these two services themselves.

Most of the changes I made control the lifetime of the connection and the hubs. I will, again, dump the entire code and then highlight what changed.

Here is the "factory" class, which I've renamed to "connection":

import { EventEmitter, Injectable, NgZone, OnDestroy } from "@angular/core";
import * as jQuery from "jquery";

import { SignalRHub } from "./signalr-hub.service";

export enum HubConnectionState {
    Connecting = 1,
    Connected = 2,
    Reconnecting = 3,
    Disconnected = 4
}

@Injectable()
export class SignalRConnection implements OnDestroy {
    private hubConnection: SignalR.Hub.Connection = null;
    private hubLookup = new Map<string, SignalRHub>();

    public constructor(private zone: NgZone) {
        if (typeof jQuery === "undefined") {
            throw new Error("jQuery is not defined on the global object.");
        }
        if (jQuery.hubConnection == null) {
            throw new Error("SignalR is not defined on the jQuery object.");
        }
        this.hubConnection = jQuery.hubConnection("/CAM/signalr", { useDefaultPath: false });

        this.hubConnection.stateChanged((state: SignalR.StateChanged) => {
            const newState = this.getConnectionState(state);
            this.connectionStateChange.next(newState);
            if (newState === HubConnectionState.Disconnected) {
                this.disconnect();
            }
        });

        this.hubConnection.error((error: SignalR.ConnectionError) => {
            this.connectionError.next(error);
        });
    }

    private getConnectionState(state: SignalR.StateChanged): HubConnectionState {
        switch (state.newState) {
            case SignalR.ConnectionState.Connecting:
                return HubConnectionState.Connecting;
            case SignalR.ConnectionState.Connected:
                return HubConnectionState.Connected;
            case SignalR.ConnectionState.Reconnecting:
                return HubConnectionState.Reconnecting;
            case SignalR.ConnectionState.Disconnected:
                return HubConnectionState.Disconnected;
            default:
                return HubConnectionState.Connecting;
        }
    }

    public ngOnDestroy(): void {
        this.connectionStateChange.complete();
        this.connectionError.complete();
        this.disconnect();
    }

    public connectionStateChange = new EventEmitter<HubConnectionState>();

    public connectionError = new EventEmitter<Error>();

    public createHub(hubName: string): SignalRHub {
        if (this.hubLookup.has(hubName)) {
            return this.hubLookup.get(hubName);
        }
        const hub = new SignalRHub(this.zone, this.hubConnection, hubName);
        this.hubLookup.set(hubName, hub);
        return hub;
    }

    public disconnect(): void {
        this.hubLookup.forEach((hub) => hub.disconnect());
        this.hubLookup.clear();
        this.safeDisconnect();
    }

    private safeDisconnect(): void {
        if (this.hubConnection.state === SignalR.ConnectionState.Disconnected) {
            return;
        }
        try {
            this.hubConnection.stop(true, true);
        } catch (e) {
            return;
        }
    }
}

The first thing to notice is that the connection object now lives outside of the hubs. The logic for creating the connection has moved from the hub into the connection class. Obviously, the events for tracking the state of the connection and errors are moved to the connect, as well. There's a nice parallel between my classes and the SignalR client classes.

The connection object now keeps track of all the hubs that it creates. This is important to ensure the same hub object is returned every time. It also allows me to communicate with the hubs when it is time to clean up after themseleves.

There's actually two types of "disconnection" that take place: 1) disconnecting from the server and 2) indicating to hub listeners that no more messages will be received (aka., call complete on any Subjects). That responsibility is nicely divided between the connection and hub classes now. You'll notice the disconnect method first loops through the hubs to tell them to disconnect, then it tells the server that the connection is closing. The two parameters to stop indicate that the disconnect should happen asynchronously and that the server should be notified, in that order. I also decided that the ngOnDestroy should be responsible for calling disconnect, in case I forget to do it explicitly.

Since the connection handling is moved to the connection class, the hub class gets much easier:

import { NgZone } from "@angular/core";
import { Observable, Observer, ReplaySubject, Subject } from "rxjs";

export class SignalRHub {
    private hubProxy: SignalR.Hub.Proxy = null;
    private subscriptions = new Map<string, Subject<any>>();

    public constructor(private zone: NgZone, private hubConnection: SignalR.Hub.Connection, private hubName: string) {
    }

    public disconnect(): void {
        if (this.hubProxy == null) {
            return;
        }
        this.subscriptions.forEach((subject) => {
            subject.complete();
        });
        this.subscriptions.clear();
        this.hubProxy = null;
    }

    public async invoke(actionName: string, ...data: any[]): Promise<any> {
        await this.prepareHubProxy((proxy) => proxy.on("$$__FAKE_EVENT", () => { return; }));
        const result = await this.hubProxy.invoke(actionName, ...data);
        return result;
    }

    public async listen(eventName: string): Promise<Observable<any>> {
        const subject = await this.getCachedSubject(eventName);
        return this.getZonedObservable(subject);
    }

    private async getCachedSubject(eventName: string): Promise<Subject<any>> {
        if (this.subscriptions.has(eventName)) {
            return this.subscriptions.get(eventName);
        }
        const subject = new ReplaySubject<any>();
        await this.listenAsync(eventName, (...data: any[]) => {
            subject.next(data);
        });
        this.subscriptions.set(eventName, subject);
        return subject;
    }

    private getZonedObservable<T>(observable: Observable<T>): Observable<T> {
        return Observable.create((observer: Observer<T>) => {
            const onNext = (value: T) => this.zone.run(() => observer.next(value));
            const onError = (error: any) => this.zone.run(() => observer.error(error));
            const onComplete = () => this.zone.run(() => observer.complete());
            const subscription = observable.subscribe(onNext, onError, onComplete);
            return () => subscription.unsubscribe();
        });
    }

    private async listenAsync(eventName: string, handler: (...data: any[]) => void): Promise<any> {
        const result = await this.prepareHubProxy((proxy) => proxy.on(eventName, handler));
        return result;
    }

    private async prepareHubProxy(listener: (proxy: SignalR.Hub.Proxy) => void): Promise<any> {
        if (this.hubProxy == null) {
            this.hubProxy = this.hubConnection.createHubProxy(this.hubName);
        }
        listener(this.hubProxy);
        if (this.hubConnection.state === SignalR.ConnectionState.Disconnected) {
            const result = await this.hubConnection.start();
            return result;
        } else {
            return null;
        }
    }
}

The main difference is in the prepareHubProxy method. I finally decided to avoid calling on with $$__FAKE_EVENT using the technique I described in my initial post. But the real difference is that I now only call start on the connection if it is currently disconnected. This ensures that no matter how many hubs I have, only one connection is ever made. Since I detect when the connection is lost and automatically disconnect all hubs in the SignalRConnection class, I can ensure I get a fresh connection and hubs whenever I go to reconnect. This approach might not be appropriate for your particular environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment