March 6, 2018

Using SignalR Base Classes in Angular

Post by: Eric Ditter

On one of my previous projects I used SignalR extensively in an Angular 1.6 application, and I was using the angular-signalr-hub library to integrate it into the application. It worked very well, but I am moving to the next version of Angular so I wanted to find a way to do it without having to use another library. I was hoping to have a more object-oriented approach to it, which ultimately led me to just write something myself.

With Typescript, you can use base classes very easily so I ended up coming up with the following code. Overall, I really like how it came out. Everything that comes or goes between my app and the server passes through this which can be very powerful. In the past, I have done some date parsing or conversions from an array like value to a regular array, and my calling code just worked without knowing the difference (even IE 8). That, as well as removing all of the boilerplate code from the classes, has made this piece of code very useful.

SignalrBase.ts

/// <reference types="signalr" />

import { NgZone, Injector } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';

export enum ConnectionState {
  Connecting = 0,
  Connected = 1,
  Reconnecting = 2,
  Disconnected = 4
}

export abstract class SignalrBase {

  // static so I can have a shared connection
  private static connections: { [url: string]: SignalR.Hub.Connection } = {};
  [propertyName: string]: any;

  public connectionState$ = new BehaviorSubject<ConnectionState>(ConnectionState.Disconnected);
  public error$ = new Subject<SignalR.ConnectionError>();

  private callbackQueue: (() => void)[] = [];
  private connection: SignalR.Hub.Connection;
  private proxy: SignalR.Hub.Proxy;
  private ngZone: NgZone;

  protected constructor(
    private hubName: string,
    private listenerNames: string[],
    private methodNames: string[],
    private injector: Injector,
  ) {
    this.ngZone = this.injector.get(NgZone);

    this.connection = SignalrBase.InitConnection('/MySite/SignalR');
    this.proxy = this.connection.createHubProxy(hubName);

    // Define handlers for the connection state events
    //
    this.connection.stateChanged(state => {

      switch (state.newState) {
        case ConnectionState.Connected:
          while (this.callbackQueue.length > 0) {
            const callback = this.callbackQueue.pop();
            if (callback instanceof Function) {
              callback();
            }
          }

          break;
        case ConnectionState.Disconnected:
          const timeoutKey = setInterval(async (intervalState: { attempt: number }) => {
            if (intervalState.attempt > 30) {
              clearInterval(timeoutKey);
              console.error(`[SignalR][${this.hubName}] Clearing Timeout for disconnect`);
              return;
            }

            try {
              await this.connection.start();
            } catch (err) {
              console.error(`[SignalR][${this.hubName}]`, err);

            }

            intervalState.attempt++;

          }, 10000, { attemptNum: 0 }); // Try to restart connection after 10 seconds

          break;

      }

      this.connectionState$.next(state.newState);

    });

    // Define handlers for any errors
    //
    this.connection.error(error => {
      console.error(`[SignalR][${this.hubName}]`, error);
      this.error$.next(error);

    });

    // Build up the class methods
    //
    this.listenerNames.forEach(listenerName => this.buildListener(listenerName));
    this.methodNames.forEach(methodName => this.buildMethod(methodName));

  }

  private static InitConnection(url: string) {
    // Check for the Signalr dependencies
    const jq = (window as any).jQuery as SignalR;
    if (typeof jq === 'undefined') {
      throw new Error(`The variable "jQuery" is not defined...please check that jQuery has been loaded properly`);

    } else if (!(jq.hubConnection instanceof Function)) {
      throw new Error(`The 'jQuery.hubConnection()' function is not defined...please check that SignalR has been loaded properly`);

    }

    // we do this so the connection can be shared per URL
    if (typeof this.connections[url] === 'undefined') {
      this.connections[url] = jq.hubConnection(url);
      this.connections[url].start()
        .done(d => {
        })
        .fail(error => {
          console.error(error);

        });

    }

    return this.connections[url];

  }

  private buildMethod(methodName: string) {
    // add the method to the object
    this[methodName] = (...args: any[]) => {
      const results = new Subject<any>();
      const invokeCall = () => {

        this.proxy.invoke(methodName, ...args)
          .done((results: any) => {
            this.ngZone.run(() => results.next(results));
          })
          .fail((error: any) => {
            this.ngZone.run(() => results.error(error));
          })
          .always(() => {
            results.complete();
          });

      };

      if (this.connectionState$.getValue() !== ConnectionState.Connected) {
        // in the case that we aren't connected to the hub
        // queue up the call which will run in the this.connection.stateChanged callback
        this.callbackQueue.push(invokeCall);
      } else {
        // otherwise just make the call
        invokeCall();
      }

      return results.asObservable();
    };

  }

  private buildListener(listenerName: string) {
    // converts the name from 'message' to 'onMessage'
    const methodName = 'on' + listenerName.charAt(0).toUpperCase() + listenerName.slice(1);
    const results = new Subject<any>();

    this.proxy.on(listenerName, resultData => {
      this.ngZone.run(() => results.next(resultData));
    });

    // add the listener to the object
    this[methodName] = results.asObservable();
  }

}

This is my implementing class, which is pretty barebones. The only thing I wasn’t able to figure out was getting NgZone injected directly into the base class. Instead, I pass the Injector type in from the implementing class which will be used to get anything the base class might need. It isn’t perfect, but it is pretty minor to have one extra parameter.

If you aren’t using Angular, then you can simply remove all references to NgZone and everything should work fine. NgZone is only used to trigger the UI to update. Also, because we are using RXJS Observables we can easily intercept anything that comes through at a class level and pass that along, which is what onBase64Message does.

data.service.ts

import { Injectable, Injector } from '@angular/core';
import { SignalrBase } from './SignalrBase';

@Injectable()
export class DataService extends SignalrBase {
  // Listeners
  public onMessage: Observable<{ username: string, text: string }>;
  public onBase64Message: () => Observable<string>;

  // Methods
  public getData: () => Observable<string[]>;

  constructor(injector: Injector) {
    super('SignalrHub',
      [
        // this will be converted to onMessage in the base class
        'message',
        'base64Message'
      ],
      [
        'getData',
      ], injector);

      // a message from the server like 'SGVsbG8gV29ybGQ=' will become 'Hello World'
      this.onBase64Message = this.onBase64Message.map((encodedString: string) => atob(encodedString))
  }

}

And here is a simple example of how it is used in a component:

messages.component.ts

import { DataService } from '../';
import { Component } from '@angular/core';

@Component({
  moduleId: module.id,
  selector: 'my-messages',
  template: 'messages.component.html',
  styleUrls: ['messages.component.scss'],
})
export class MessagesComponent {
  public messages: string[] = [];
  constructor(
    private dataService: DataService,
  ) {
    this.dataService.getData.subscribe(messages => this.messages = messages);
    this.dataService.onMessage.subscribe(info => this.messages.push(`${info.username}: ${info.text}`));
    this.dataService.onBase64Message.subscribe(message => this.messages.push(message));
  }

}

The Power of JavaScript

The project this came from has about a half-dozen hubs. This relatively simple piece of code has definitely come in handy by reducing the need for copy and pasted code. If you aren’t using a library that is built around RXJS like Angular is, you can easily change this to use Promises and every implementing class will simply work. This kind of dynamic coding can seem like magic at times but it definitely shows the power of some JavaScript.

Learn more about our software development capabilities.

Relevant Insights

New Site Showcases Our Growth Into a Full-Service IT Consultancy

Over the past year, Core BTS has evolved. Simply put, we’ve amassed greater scale and expertise that, in combination with...

5 Steps to Reduce Your Ransomware Risk

As the recent ransomware attack on the U.S.’s second-largest meat producer, JBS, made clear, cyberattacks on critical infrastructure can cause...

How to Unlock the Organizational Value of Digital Transformation

As organizations look to stay competitive in today's dynamic and unpredictable marketplace, a trend has re-emerged that is ushering us...
X