SignalR Group messages with ngrx and Angular

This article shows how SignalR can be used to send grouped messages to an Angular SignalR client, which uses ngrx to handle the SignalR events in the Angular client.

Code: https://github.com/damienbod/AspNetCoreAngularSignalRSecurity

Posts in this series

History

2023-01-08 Updated Angular 15, .NET 7
2021-01-25 Updated Angular 11.1.0 .NET 5, ngrx implementation
2020-03-21 updated packages, fixed Admin UI STS
2019-08-18 Updated ASP.NET Core 3.0, Angular 8.2.2
2019-02-06 Updated Angular 7.2.4, latest NGRX, SignalR CORS fix
2018-12-12 Updated .NET Core 2.2, ASP.NET Core SignalR 1.1.0, Angular 7.1.3
2018-05-31 Updated Microsoft.AspNetCore.SignalR 2.1
2018-05-08 Updated Microsoft.AspNetCore.SignalR 2.1 rc1, Angular 6
2018-03-15 Updated signalr Microsoft.AspNetCore.SignalR 1.0.0-preview1-final, Angular 5.2.8, @aspnet/signalr 1.0.0-preview1-update1

SignalR Groups

SignalR allows messages to be sent to specific groups if required. You can read about this here:

https://docs.microsoft.com/en-us/aspnet/signalr/overview/guide-to-the-api/working-with-groups

To get started, add the SignalR Nuget package to the csproj file where the Hub(s) are to be implemented.

<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.1" />

In this application, the NewsItem class is used to send the messages between the SignalR clients and server.

namespace AspNetCoreAngularSignalR.SignalRHubs;

public class NewsItem
{
    public string Header { get; set; } = string.Empty;
    public string NewsText { get; set; } = string.Empty;
    public string Author { get; set; } = string.Empty;
    public string NewsGroup { get; set; } = string.Empty;
}

The NewsHub class implements the SignalR Hub which can send messages with NewsItem classes, or let the clients join, or leave a SignalR group. When the Send method is called, the class uses the NewsGroup property to send the messages only to clients in the group. If the client is not a member of the group, it will receive no message.

using AspNetCoreAngularSignalR.Providers;
using Microsoft.AspNetCore.SignalR;

namespace AspNetCoreAngularSignalR.SignalRHubs;

public class NewsHub : Hub
{
    private NewsStore _newsStore;

    public NewsHub(NewsStore newsStore)
    {
        _newsStore = newsStore;
    }

    public Task Send(NewsItem newsItem)
    {
        if(!_newsStore.GroupExists(newsItem.NewsGroup))
        {
            throw new System.Exception("cannot send a news item to a group which does not exist.");
        }

        _newsStore.CreateNewItem(newsItem);
        return Clients.Group(newsItem.NewsGroup).SendAsync("Send", newsItem);
    }

    public async Task JoinGroup(string groupName)
    {
        if (!_newsStore.GroupExists(groupName))
        {
            throw new System.Exception("cannot join a group which does not exist.");
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        await Clients.Group(groupName).SendAsync("JoinGroup", groupName);

        var history = _newsStore.GetAllNewsItems(groupName);
        await Clients.Client(Context.ConnectionId).SendAsync("History", history);
    }

    public async Task LeaveGroup(string groupName)
    {
        if (!_newsStore.GroupExists(groupName))
        {
            throw new System.Exception("cannot leave a group which does not exist.");
        }

        await Clients.Group(groupName).SendAsync("LeaveGroup", groupName);
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
    }
}

The SignalR hub is configured in the Startup class. The path defined in the hub, must match the configuration in the SignalR client.

app.MapHub<LoopyHub>("/loopy");
app.MapHub<NewsHub>("/looney");
app.MapHub<LoopyMessageHub>("/loopymessage");
app.MapHub<ImagesMessageHub>("/zub");

Angular Service for the SignalR client

To use SignalR in the Angular application, the npm package @microsoft/signalr needs to be added to the packages.json file.

“@microsoft/signalr”: “7.0.0”,

The Angular NewsService is used to send SignalR events to the ASP.NET Core server and also to handle the messages received from the server. The send, joinGroup and leaveGroup functions are used in the ngrx store effects and the init method adds event handlers for SignalR events and dispatches ngrx actions when a message is received.

import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { HubConnection } from '@microsoft/signalr';
import { NewsItem } from './models/news-item';
import { Store } from '@ngrx/store';
import * as newsAction from './store/news.action';
import * as signalR from '@microsoft/signalr';
import { Observable } from 'rxjs';

@Injectable()
export class NewsService {
  private _hubConnection: HubConnection | undefined;
  private actionUrl: string;
  private headers: HttpHeaders;

  constructor(private http: HttpClient, private store: Store<any>) {
    this.init();
    this.actionUrl = 'https://localhost:44324/api/news/';

    this.headers = new HttpHeaders();
    this.headers = this.headers.set('Content-Type', 'application/json');
    this.headers = this.headers.set('Accept', 'application/json');
  }

  send(newsItem: NewsItem): NewsItem {
    if (this._hubConnection) {
      this._hubConnection.invoke('Send', newsItem);
    }
    return newsItem;
  }

  joinGroup(group: string): void {
    if (this._hubConnection) {
      this._hubConnection.invoke('JoinGroup', group);
    }
  }

  leaveGroup(group: string): void {
    if (this._hubConnection) {
      this._hubConnection.invoke('LeaveGroup', group);
    }
  }

  getAllGroups(): Observable<string[]> {
    return this.http.get<string[]>(this.actionUrl, { headers: this.headers });
  }

  private init() {
    this._hubConnection = new signalR.HubConnectionBuilder()
      .withUrl('https://localhost:44324/looney')
      .configureLogging(signalR.LogLevel.Information)
      .build();

    this._hubConnection.start().catch((err) => console.error(err.toString()));

    this._hubConnection.on('Send', (newsItem: NewsItem) => {
      this.store.dispatch(
        newsAction.receiveNewsItemAction({ payload: newsItem })
      );
    });

    this._hubConnection.on('JoinGroup', (data: string) => {
      console.log('received data from the hub');
      console.log(data);
      this.store.dispatch(
        newsAction.receiveGroupJoinedAction({ payload: data })
      );
    });

    this._hubConnection.on('LeaveGroup', (data: string) => {
      this.store.dispatch(newsAction.receiveGroupLeftAction({ payload: data }));
    });

    this._hubConnection.on('History', (newsItems: NewsItem[]) => {
      console.log('received history from the hub');
      console.log(newsItems);
      this.store.dispatch(
        newsAction.receiveNewsGroupHistoryAction({ payload: newsItems })
      );
    });
  }
}

Using ngrx to manage SignalR events

The NewsState interface is used to save the application state created from the SignalR events, and the user interactions.

import { NewsItem } from '../models/news-item';

export interface NewsState {
  newsItems: NewsItem[];
  groups: string[];
}

The news.action action classes are used to connect, define the actions for events which are dispatched from Angular components, the SignalR Angular service, or ngrx effects. These actions are used in the hubConnection.on event, which receives the SignalR messages, and dispatches the proper action.

import { Action } from '@ngrx/store';
import { createAction, props } from '@ngrx/store';
import { NewsItem } from '../models/news-item';

export const joinGroupAction = createAction(
  '[News] JOIN_GROUP',
  props<{ payload: string }>()
);
export const joinGroupFinishedAction = createAction(
  '[News] JOIN_GROUP_COMPLETE',
  props<{ payload: string }>()
);
export const leaveGroupAction = createAction(
  '[News] LEAVE_GROUP',
  props<{ payload: string }>()
);
export const leaveGroupFinishedAction = createAction(
  '[News] LEAVE_GROUP_COMPLETE',
  props<{ payload: string }>()
);
export const sendNewsItemAction = createAction(
  '[News] SEND_NEWS_ITEM',
  props<{ payload: NewsItem }>()
);
export const sendNewsItemFinishedAction = createAction(
  '[News] SEND_NEWS_ITEM_COMPLETE',
  props<{ payload: NewsItem }>()
);
export const selectAllNewsGroupsAction = createAction(
  '[News] SELECTALL_GROUPS'
);
export const selectAllNewsGroupsFinishedAction = createAction(
  '[News] SELECTALL_GROUPS_COMPLETE',
  props<{ payload: string[] }>()
);
export const receiveNewsItemAction = createAction(
  '[News] RECEIVED_NEWS_ITEM',
  props<{ payload: NewsItem }>()
);
export const receiveGroupJoinedAction = createAction(
  '[News] RECEIVED_GROUP_JOINED',
  props<{ payload: string }>()
);
export const receiveGroupLeftAction = createAction(
  '[News] RECEIVED_GROUP_LEFT',
  props<{ payload: string }>()
);
export const receiveNewsGroupHistoryAction = createAction(
  '[News] RECEIVED_GROUP_HISTORY',
  props<{ payload: NewsItem[] }>()
);

The newsReducer ngrx reducer class receives the actions and changes the state as required. For example, when a RECEIVED_NEWS_ITEM event is sent from the Angular SignalR service, it creates a new state with the new message appended to the existing items.

import { NewsState } from './news.state';
import * as newsAction from './news.action';
import { createReducer, on, Action } from '@ngrx/store';

export const initialState: NewsState = {
  newsItems: [],
  groups: ['IT', 'global', 'sport'],
};

// on(
//   YOUR_ACTION(S)_HERE,
//   (state, { payload }) => {
//   const { news } = state;
//   const { newsItems, groups } = news;
//     return {
//       ...state,
//       news: {
//     newsItems,
//     groups: [...groups, action.group]
//   }
//     };
//   }
// ),

// or

// on(
//   YOUR_ACTION(S)_HERE,
//   (state, { payload }) => {
//   const { news } = state;
//     return {
//       ...state,
//       news: {
//     newsItems: news.newsItems,
//     groups: [...news.groups, action.group]
//   }
//     };
//   }
// ),

const newsReducerInternal = createReducer(
  initialState,
  on(newsAction.receiveGroupJoinedAction, (state, { payload }) => {
    const allGroups = [...state.groups, payload];
    const allGroupsWithoutDuplicates = [...new Set(allGroups)];
    return {
      ...state,
      groups: [...allGroupsWithoutDuplicates],
    };
  }),
  on(newsAction.receiveNewsItemAction, (state, { payload }) => {
    return {
      ...state,
      newsItems: [...state.newsItems, payload],
    };
  }),
  on(newsAction.receiveNewsGroupHistoryAction, (state, { payload }) => {
    return {
      ...state,
      newsItems: [...payload],
    };
  }),
  on(newsAction.selectAllNewsGroupsFinishedAction, (state, { payload }) => {
    const allGroups = [...state.groups, ...payload];
    const allGroupsWithoutDuplicates = [...new Set(allGroups)];
    return {
      ...state,
      groups: [...allGroupsWithoutDuplicates],
    };
  }),
  on(newsAction.receiveGroupLeftAction, (state, { payload }) => {
    const data = [];
    for (const entry of state.groups) {
      if (entry !== payload) {
        data.push(entry);
      }
    }
    console.log(data);

    return {
      ...state,
      groups: [...data],
    };
  })
);

export function newsReducer(
  state: NewsState | undefined,
  action: Action
): NewsState {
  return newsReducerInternal(state, action);
}

News effects:

import { Injectable } from '@angular/core';
import { Actions, ofType, createEffect } from '@ngrx/effects';
import { of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';

import * as newsAction from './news.action';
import { NewsService } from '../news.service';

@Injectable()
export class NewsEffects {
  constructor(private newsService: NewsService, private actions$: Actions) {}

  sendNewsItemAction$ = createEffect(() =>
    this.actions$.pipe(
      ofType(newsAction.sendNewsItemAction),
      map((action) => action.payload),
      switchMap((payload) => {
        this.newsService.send(payload);
        return of(newsAction.sendNewsItemFinishedAction({ payload }));
      })
    )
  );

  joinGroupAction$ = createEffect(() =>
    this.actions$.pipe(
      ofType(newsAction.joinGroupAction),
      switchMap(({ payload }) => {
        this.newsService.joinGroup(payload);
        return of(newsAction.joinGroupFinishedAction({ payload }));
      })
    )
  );

  leaveGroupAction$ = createEffect(() =>
    this.actions$.pipe(
      ofType(newsAction.leaveGroupAction),
      map((action) => action.payload),
      switchMap((payload) => {
        this.newsService.leaveGroup(payload);
        return of(newsAction.leaveGroupFinishedAction({ payload }));
      })
    )
  );

  selectAllNewsGroups$ = createEffect(() =>
    this.actions$.pipe(
      ofType(newsAction.selectAllNewsGroupsAction),
      switchMap(() =>
        this.newsService.getAllGroups().pipe(
          map((payload) =>
            newsAction.selectAllNewsGroupsFinishedAction({ payload })
          ),
          catchError((error) => of(error))
        )
      )
    )
  );
}

news selectors:

import { NewsState } from './news.state';
import { createFeatureSelector, createSelector } from '@ngrx/store';

export const newsStoreName = 'news';

export const selectNewsStore = createFeatureSelector<NewsState>(newsStoreName);

export const selectNewsItems = createSelector(
  selectNewsStore,
  (state: NewsState) => state.newsItems
);

export const selectGroups = createSelector(
  selectNewsStore,
  (state: NewsState) => state.groups
);

The ngrx store is configured in the module class.

StoreModule.forFeature('news', newsReducer), 
  EffectsModule.forFeature([NewsEffects])

The store is then used in the different Angular components. The component only uses the ngrx store to send, receive SignalR data.

import * as newsAction from './../store/news.action';
import { Component, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { NewsState } from '../store/news.state';
import { NewsItem } from '../models/news-item';
import { Observable } from 'rxjs';
import * as fromSelectorsStore from '../store/news.selectors';
import { select } from '@ngrx/store';

@Component({
  selector: 'app-news-component',
  templateUrl: './news.component.html',
})
export class NewsComponent implements OnInit {
  public async: any;
  newsItem: NewsItem;
  newsItemHeader = '';
  newsItemNewsText= '';
  group = 'IT';
  author = 'unknown';
  group$: Observable<string[]>;
  newsItems$: Observable<NewsItem[]>;

  constructor(
    private store: Store<any>,
  ) {
    this.group$ = this.store.pipe(select(fromSelectorsStore.selectGroups));
    this.newsItems$ = this.store.pipe(
      select(fromSelectorsStore.selectNewsItems)
    );

    this.newsItem = new NewsItem();
    this.newsItem.AddData('', '', this.author, this.group);
  }

  public sendNewsItem(): void {

    this.newsItem = new NewsItem();
    this.newsItem.AddData(this.newsItemHeader, this.newsItemNewsText, this.author, this.group);

    this.store.dispatch(
      newsAction.sendNewsItemAction({ payload: this.newsItem })
    );
  }

  public join(): void {
    this.store.dispatch(newsAction.joinGroupAction({ payload: this.group }));
  }

  public leave(): void {
    this.store.dispatch(newsAction.leaveGroupAction({ payload: this.group }));
  }

  ngOnInit() {
    console.log('go');
    this.store.dispatch(newsAction.selectAllNewsGroupsAction());
  }
}

The component template then displays the data as required.

<div class="container-fluid">
  <div class="form-horizontal" style="padding: 10px 0">
    <div class="row">
      <div class="col">
        <label class="col-sm-2 control-label" for="group">Group: </label>
        <div class="col-sm-9">
          <select class="form-control" type="number" [(ngModel)]="group">
            <option *ngFor="let g of group$ | async" [ngValue]="g">
              {{ g }}
            </option>
          </select>
        </div>
      </div>
      <div class="col">
        <label class="col-sm-2 control-label" for="author">Author: </label>
        <div class="col-sm-9">
          <input
            type="text"
            class="form-control"
            id="author"
            placeholder="your name"
            name="author"
            [(ngModel)]="author"
            required
          />
        </div>
      </div>
      <div class="col">
        <div class="col-sm-9">
          <button
            class="btn btn-primary col-sm-12"
            style="margin-bottom: 5px"
            (click)="join()"
          >
            Join
          </button>
          <button class="btn btn-primary col-sm-12" (click)="leave()">
            Leave
          </button>
        </div>
      </div>
    </div>
  </div>
  <hr />

  <form
    class="form-horizontal"
    style="padding: 10px 0"
    (ngSubmit)="sendNewsItem()"
    #newsItemForm="ngForm"
  >
    <div class="row">
      <div class="col">
        <label for="header" class="col-sm-2 control-label">Header</label>
        <div class="col-sm-9">
          <input
            type="text"
            class="form-control"
            id="header"
            placeholder="your header..."
            name="header"
            [(ngModel)]="newsItemHeader"
            required
          />
        </div>
      </div>
      <div class="col">
        <label for="newsText" class="col-sm-2 control-label">Text</label>
        <div class="col-sm-9">
          <input
            type="text"
            class="form-control"
            id="newsText"
            placeholder="your newsText..."
            name="newsText"
            [(ngModel)]="newsItemNewsText"
            required
          />
        </div>
      </div>
      <div class="col">
        <label for="newsText" class="col-sm-2 control-label"></label>
        <div class="col-sm-9">
          <button
            type="submit"
            class="btn btn-primary col-sm-12"
            [disabled]="!newsItemForm.valid"
          >
            Send News to: {{ group }}
          </button>
        </div>
      </div>
    </div>
  </form>

  <div *ngIf="newsItems$ | async as newsItems">
    <div class="row" *ngIf="newsItems?.length > 0; else noitemsfound">
      <div class="table-responsive">
        <table class="table table-striped">
          <thead>
            <tr>
              <th>#</th>
              <th>header</th>
              <th>Text</th>
              <th>Author</th>
              <th>Group</th>
            </tr>
          </thead>
          <tbody>
            <tr *ngFor="let item of newsItems; let i = index">
              <td>{{ i + 1 }}</td>
              <td>{{ item.header }}</td>
              <td>{{ item.newsText }}</td>
              <td>{{ item.author }}</td>
              <td>{{ item.newsGroup }}</td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  </div>

  <ng-template #noitemsfound>
    <div class="row">
      <span>No items found</span>
    </div>
  </ng-template>
</div>

When the application is started, SignalR messages can be sent, received and displayed from the instances of the Angaulr application.

Links

https://learn.microsoft.com/en-us/aspnet/core/signalr/introduction

https://github.com/aspnet/SignalR#readme

https://github.com/ngrx

https://www.npmjs.com/package/msgpack5

5 comments

  1. […] SignalR Group messages with ngrx and Angular – Damien Bowden […]

  2. […] SignalR Group messages with ngrx and Angular (Damien Bowden) […]

  3. Tejashri · · Reply

    Where can i find the code of this

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: