Utilizing SignalR in Angular
Our objective is to create a service which functions as an intermediary between Angular and our signalR connection. This style of approach means there are two general problems we need to solve
- Connecting with SignalR
- Designing an approach to interface with the rest of our app
Angular Interfacing
Our goal is to be able to inject our service anywhere within our application, and have our components react appropriately when an event happens in our signalR hub which is of interest to our service
Making the service available
Since our service is going to potentially be used anywhere, and has lifecycle logic that deals with the connectionHub which will fail if trying to open a non-closed connection, the only injectors we can use are the following:
app.module.ts
@Injectable({providedIn: 'root'})
The simplest solution is utilizing the @Injectable({providedIn: 'root'})
decorator, however for services with nuanced internal lifecycles such as a SignalR service, I prefer to expose a public API so that we only expose methods that are safe for our team to utilize.
The public Interface
First, let's create an interface which we can use to make the SignalR service available in the rest of our application. However, because we cannot provide interfaces in Angular, we utilize an abstract class instead.
public SignalR interface
import {Observable} from 'rxjs';
import {Injectable} from '@angular/core';
@Injectable()
export abstract class SignalRService {
abstract getDataStream(): Observable<any>
}
The beginning of our SignalR Service
import {Subject, Observable} from 'rxjs';
import {SignalRService} from './signalr.service';
import {Injectable} from '@angular/core';
@Injectable()
export class PrivateSignalRService extends SignalRService {
private _signalEvent: Subject<any>;
constructor() {
super();
this._signalEvent = new Subject<any>();
}
getDataStream(): Observable<any> {
return this._signalEvent.asObservable();
}
}
Now we have the basic setup for our injection. We describe the public interface for which services we want to be available in our abstract class, treating it as an interface, and implement our logic in PrivateSignalRService
Now, all we have left to do is tell the Angular Injector to provide a PrivateSignalRService
whenever we ask for a SignalRService
app.module.ts
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
AppRoutingModule
],
providers: [{
provide: SignalRService,
useClass: PrivateSignalRService
}],
bootstrap: [AppComponent]
})
export class AppModule {
// inject signal directly in module if you want to start connecting immediately.
constructor(private signal: SignalRService) {
}
}
Mapping SignalR events to subject events
We are now able to inject our SignalRService
in our application, but our HubConnection may grow over time and it may not be the case that every event is relevant for every component, so we create a filter.
First we create an enum which represents each of the different HubConnection events we may expect to receive.
signal-event-type.ts
export enum SignalEventType {
EVENT_ONE,
EVENT_TWO
}
next we need an interface so that we know what to expect when we call getDataStream
signal-event
import {SignalEventType} from './signal-event-type';
export interface SignalEvent<TDataShape> {
type: SignalEventType,
data: TDataShape
}
change the signature of our public abstract class interface
SignalRService
@Injectable()
export abstract class SignalRService {
abstract getDataStream<TDataShape>(...filterValues: SignalEventType[]): Observable<SignalEvent<TDataShape>>
}
PrivateSignalRService
@Injectable()
export class PrivateSignalRService extends SignalRService {
private _signalEvent: BehaviorSubject<SignalEvent<any>>;
constructor() {
super();
this._signalEvent = new BehaviorSubject<any>(null);
}
getDataStream<TDataShape>(...filterValues: SignalEventType[]): Observable<SignalEvent<TDataShape>> {
return this._signalEvent.asObservable().pipe(filter(event => filterValues.some(f => f === event.type)));
}
}
Connecting with SignalR
NOTICE: This example uses the @aspnet/signalr
package.
Observe the following changes to the PrivateSignalRService
@Injectable()
export class PrivateSignalRService extends SignalRService {
private _signalEvent: Subject<SignalEvent<any>>;
private _openConnection: boolean = false;
private _isInitializing: boolean = false;
private _hubConnection!: HubConnection;
constructor() {
super();
this._signalEvent = new Subject<any>();
this._isInitializing = true;
this._initializeSignalR();
}
getDataStream<TDataShape>(...filterValues: SignalEventType[]): Observable<SignalEvent<TDataShape>> {
this._ensureConnection();
return this._signalEvent.asObservable().pipe(filter(event => filterValues.some(f => f === event.type)));
}
private _ensureConnection() {
if (this._openConnection || this._isInitializing) return;
this._initializeSignalR();
}
private _initializeSignalR() {
this._hubConnection = new HubConnectionBuilder()
.withUrl('https://localhost:5001/signalHub')
.build();
this._hubConnection.start()
.then(_ => {
this._openConnection = true;
this._isInitializing = false;
this._setupSignalREvents()
})
.catch(error => {
console.warn(error);
this._hubConnection.stop().then(_ => {
this._openConnection = false;
})
});
}
private _setupSignalREvents() {
this._hubConnection.on('MessageHelloWorld', (data) => {
// map or transform your data as appropriate here:
this._onMessage({type: SignalEventType.EVENT_ONE, data})
})
this._hubConnection.on('MessageNumberArray', (data) => {
// map or transform your data as appropriate here:
const {numbers} = data;
this._onMessage({type: SignalEventType.EVENT_TWO, data: numbers})
})
this._hubConnection.onclose((e) => this._openConnection = false);
}
private _onMessage<TDataShape>(payload: SignalEvent<TDataShape>) {
this._signalEvent.next(payload);
}
}
Now, the first time you request a getDataStream
the service will attempt to create a signalR connection if there is no open connection, so you no longer need to inject the service in the AppModule
constructor.
Listening for events in components
- example-one is interested in
EVENT_ONE
- example-two is interested in
EVENT_TWO
example-one.component.ts
@Component({
selector: 'app-example-one',
template: `<p>Example One Component</p>`
})
export class ExampleOneComponent implements OnInit, OnDestroy {
subscription!: Subscription;
constructor(private signal: SignalRService) {
}
ngOnInit(): void {
this.subscription = this.signal.getDataStream<string>(SignalEventType.EVENT_ONE).subscribe(message => {
console.log(message.data);
})
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
example-two.component.ts
@Component({
selector: 'app-example-two',
template: `<p>Example Two Component</p>`
})
export class ExampleTwoComponent implements OnInit, OnDestroy {
subscription!: Subscription;
constructor(private signal: SignalRService) {
}
ngOnInit(): void {
this.subscription = this.signal.getDataStream<string[]>(SignalEventType.EVENT_TWO).subscribe(message => {
message.data.forEach(m => console.log(m));
})
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
ExampleOneComponent
and ExampleTwoComponent
now only receive events when the event received in the HubConnection is the appropriate type for each component.
Final Notes
This example code doesn't have robust error handling, and only demonstrates a general approach we've taken to integrating signalR with Angular.
You also need to determine a strategy for managing local persistence, as the Subject will only display any new incoming messages, which will reset your components when you navigate around in your app, as an example.