On one of my projects I used SignalR pretty extensivly in an Angular 1.6 application and I was using the angular-signalr-hub library to integrate it into my application. It worked very well but I am moving to Angular 2 so I needed to find a way to do it without having to use the library and I was hoping to get to a more object oriented way of doing it.
With Typescript you can use base classes so I ended up coming up with the following and overall I really like how it came out. Everything that comes from the server passes through here so I can intercept whatever I need which actually came in handy. For date values I had some instances where they came through as strings and arrays came through as array-like objects. I just parsed them internally and my calling code know the difference.
/// <reference types="signalr" />
import { NgZone } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { Observable } from 'rxjs/Observable';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import * as moment from 'moment';
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;
// These are used to feed the public observables
public error$ = new Observable<SignalR.ConnectionError>();
private connectionState$ = new Observable<ConnectionState>();
// These are used to track the internal SignalR state
private callbackQueue: (() => void)[] = [];
private connection: SignalR.Hub.Connection;
private proxy: SignalR.Hub.Proxy;
private connectionStateSubject = new BehaviorSubject<ConnectionState>(ConnectionState.Disconnected);
private errorSubject = new Subject<SignalR.ConnectionError>();
// tslint:disable-next-line:max-line-length
private datePattern: RegExp = new RegExp(/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+)|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d)|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d)/);
protected constructor(
private hubName: string,
private listenerNames: string[],
private methodNames: string[],
private ngZone: NgZone,
) {
// Set up our observables
this.connectionState$ = this.connectionStateSubject.asObservable();
this.error$ = this.errorSubject.asObservable();
this.connection = SignalrService.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.connectionStateSubject.next(state.newState);
});
// Define handlers for any errors
//
this.connection.error(error => {
console.error(`[SignalR][${this.hubName}]`, error);
this.errorSubject.next(error);
});
this.listenerNames.forEach(listenerName => this.buildListener(listenerName));
this.methodNames.forEach(methodName => this.buildMethod(methodName));
}
private static InitConnection(url: string) {
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) {
this[methodName] = (...args: any[]) => {
const results = new Subject<any>();
const invokeCall = () => {
this.proxy.invoke(methodName, ...args)
.done((params: any) => {
this.ngZone.run(() => results.next(this.convertProperties(params)));
})
.fail((error: any) => {
this.ngZone.run(() => results.error(error));
})
.always(() => {
results.complete();
});
};
if (this.connectionState$.getValue() !== ConnectionState.Connected) {
this.callbackQueue.push(invokeCall);
} else {
invokeCall();
}
return results.asObservable();
};
}
private buildListener(listenerName: string) {
const methodName = 'on' + listenerName.charAt(0).toUpperCase() + listenerName.slice(1);
const results = new Subject<any>();
this.proxy.on(listenerName, resultData => {
resultData = this.convertProperties(resultData);
this.ngZone.run(() => results.next(resultData));
});
this[methodName] = results.asObservable();
}
private convertProperties = (params: any): any => {
// this logic is here in an attempt to automatically parse dates and array like values to arrays
// if you are wondering what array like values are then just know that it
// isn't an instance of array but it is Array.isArray (stupid IE)
if (!(params instanceof Array) && Array.isArray(params)) {
params = Object.keys(params).map((key: any) => params[key]);
}
for (const propertyName in params) {
if (params.hasOwnProperty(propertyName)) {
let propertyValue = params[propertyName];
if (!propertyValue) {
continue;
} else if (propertyValue instanceof Array || Array.isArray(propertyValue)
|| propertyValue instanceof Object || typeof propertyValue === 'object') {
// pass this in so we can check the array values or object properties
propertyValue = this.convertProperties(propertyValue);
} else {
try {
// check that the string is a match for the date regex
// we need to use the regex because momentjs will look at integers as valid dates
// and we only want to parse the string versions
const isMatch = this.datePattern.exec(propertyValue);
const parsedDate = moment(propertyValue);
if (isMatch && isMatch.length > 0 && parsedDate.isValid()) {
params[propertyName] = parsedDate.toDate();
}
} catch (err) {
console.warn(`Error trying to auto parse date result. Method: '${name}',Property: '${propertyName}', Value: '${propertyValue}'`, err);
}
}
}
}
return params;
}
}
This is my implementation class which is pretty barebone. The only thing I wasn’t able to figure out was getting NgZone injected directly into the base class so I pass it from the impementing class. It isn’t perfect but it is pretty minor to have one extra parameter. If you aren’t using Angular 2 then you can remove all references to NgZone and be fine, it is only used to trigger the UI to update.
import { Injectable, NgZone } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { SignalrService } from './signalr.service.base';
@Injectable()
export class DataService extends SignalrService {
// Listeners
public onMessage: Observable<{ username: string, text: string }>;
// Getters
public getData: () => Observable<string[]>;
constructor(ngZone: NgZone) {
super('CrewHub',
[
// this will be converted to onMessage in the base class
'message',
],
[
'getData',
], ngZone);
}
}
I don’t know a lot of the nuances in Angular 2 so maybe this isn’t the “correct” way to do it but in my case it worked pretty well.