Search code examples
.netasp.net-coreblazorsignalrblazor-webassembly

How to send a realtime notification to specific user in Blazor wasm client from ASP.NET Core background service using SignalR in .NET 8


I'm new to SignalR and learning it and practicing it for the first time. I'm trying to send real time notification to specific User in my Blazor WASM app from a BackgroundService in ASP.NET Core Web API using SignalR in .NET 8 as per the following code.

Client-side:

private HubConnection? _hubConnection;
private readonly List<string> _notifications = new();

[Inject] IAccessTokenProvider TokenProvider { get; set; }

[Inject] IConfiguration Configuration { get; set; }

[CascadingParameter] private Task<AuthenticationState> authenticationStateTask { get; set; }

protected override async Task OnInitializedAsync()
{
    var authState = await authenticationStateTask;
    var user = authState.User;

    if (user.Identity.IsAuthenticated)
    {
        _hubConnection = new HubConnectionBuilder()
                        .WithUrl($"{Configuration.GetValue<string>("ApiBaseAddress")}notifications",
                        options =>
                        {
                            options.AccessTokenProvider = async () =>
                            {
                                var accessTokenResult = await TokenProvider.RequestAccessToken();
                                accessTokenResult.TryGetToken(out var token);
                                return token.Value;
                            };
                        })
                        .Build();

        _hubConnection.On<string>("ReceiveNotification", notification =>
        {
            _notifications.Add(notification);

            InvokeAsync(StateHasChanged);
        });

        await _hubConnection.StartAsync();
    }
}

I'm sending the auth token along with the connection.

Server-side:

Hub:

[Authorize]
public class NotificationsHub : Hub<INotificationClient>
{
}

public interface INotificationClient
{
    Task ReceiveNotification(string message);
}

Program.cs:

...
builder.Services.AddSignalR();
...
app.MapHub<NotificationsHub>("notifications");

BackgroundService:

public class InventoryNotifier(
    IHubContext<NotificationsHub, INotificationClient> hubContext,
    ILogger<InventoryNotifier> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));

        while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
        {
            await hubContext.Clients
                .All
                .ReceiveNotification($"InventoryNotifier is starting... {DateTime.Now}");
        }

        stoppingToken.Register(() => logger.LogWarning($"{nameof(InventoryNotifier)} is stopping due to host shut down."));
    }
}

The above setup works fine and I receive notifications in my Blazor wasm client app. However I'm sending messages to Clients.All from background service.

await hubContext.Clients
                .All // <--- This sends for All Client.
                .ReceiveNotification($"InventoryNotifier is starting... {DateTime.Now}");

When I tried to send to specific User, I noticed that Clients.User() exists and that needs an userId.

How can I get the user id inside background service? After some research I noticed that I can use IUserIdProvider and call GetUserId() method from that as shown below.

namespace Microsoft.AspNetCore.SignalR;

/// <summary>
/// A provider abstraction for configuring the "User ID" for a connection.
/// </summary>
/// <remarks><see cref="IUserIdProvider"/> is used by <see cref="IHubClients{T}.User(string)"/> to invoke connections associated with a user.</remarks>
public interface IUserIdProvider
{
    /// <summary>
    /// Gets the user ID for the specified connection.
    /// </summary>
    /// <param name="connection">The connection to get the user ID for.</param>
    /// <returns>The user ID for the specified connection.</returns>
    string? GetUserId(HubConnectionContext connection);
}

But again this requires HubConnectionContext. And now I'm not sure how to get HubConnectionContext inside BackgroundService.

The final idea I had in my mind is to have something like this:

public class InventoryNotifier(
    IHubContext<NotificationsHub, INotificationClient> hubContext,
    IUserIdProvider userIdProvider,
    ILogger<InventoryNotifier> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
        
        while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
        {
            await hubContext.Clients
                .User(userIdProvider.GetUserId()) // <---- Not sure how to get HubConnectionContext here
                //.All
                .ReceiveNotification($"InventoryNotifier is starting... {DateTime.Now}");
        }

        stoppingToken.Register(() => logger.LogWarning($"{nameof(InventoryNotifier)} is stopping due to host shut down."));
    }
}

Please can anyone assist me on this? Is this the right way? Or am I complicating it?


Solution

  • After getting guidance from @BrianParker in the comments section of the question and following his sample repo github.com/BrianLParker/Blazor8WithRolesAndSignalR , I was able to achieve this as shown below.

    Client Side:

    private HubConnection? _hubConnection;
    private readonly List<string> _notifications = new();
    
    [Inject] IAccessTokenProvider TokenProvider { get; set; }
    
    [Inject] IConfiguration Configuration { get; set; }
    
    [CascadingParameter] private Task<AuthenticationState> authenticationStateTask { get; set; }
    
    protected override async Task OnInitializedAsync()
    {
        var authState = await authenticationStateTask;
        var user = authState.User;
    
        if (user.Identity.IsAuthenticated)
        {
            _hubConnection = new HubConnectionBuilder()
                            .WithUrl($"{Configuration.GetValue<string>("ApiBaseAddress")}notifications",
                            options =>
                            {
                                options.AccessTokenProvider = async () =>
                                {
                                    var accessTokenResult = await TokenProvider.RequestAccessToken();
                                    accessTokenResult.TryGetToken(out var token);
                                    return token.Value;
                                };
                            })
                            .Build();
    
            _hubConnection.On<string>("ReceiveNotification", notification =>
            {
                _notifications.Add(notification);
    
                InvokeAsync(StateHasChanged);
            });
    
            await _hubConnection.StartAsync();
        }
    }
    

    I'm sending the auth token along with the connection.

    Server Side:

    Hub:

    public class NotificationsHub(ConnectedUsers connectedUsers,
        ILogger<NotificationsHub> logger) : Hub<INotificationClient>
    {
        public override async Task OnConnectedAsync()
        {
            //await Clients.Client(Context.ConnectionId).ReceiveNotification($"Connected {Context.User?.Identity?.Name}");
    
            var userToNotify = new
            {
                UserId = Guid.Parse(Context.User!.FindFirst("sub")!.Value),
                BranchId = Guid.Parse(Context.User!.FindFirst("branchId")!.Value)
            };
    
            await Groups.AddToGroupAsync(Context.ConnectionId, userToNotify.BranchId.ToString());
    
            connectedUsers.AddUser(userToNotify.UserId, userToNotify.BranchId);
    
            logger.LogInformation($"User {userToNotify.UserId} connected to branch {userToNotify.BranchId}");
    
            await base.OnConnectedAsync();
        }
    
        public override Task OnDisconnectedAsync(Exception? exception)
        {
            var userToNotify = new
            {
                UserId = Guid.Parse(Context.User!.FindFirst("sub")!.Value),
                BranchId = Guid.Parse(Context.User!.FindFirst("branchId")!.Value)
            };
    
            logger.LogInformation($"User {userToNotify.UserId} disconnected from branch {userToNotify.BranchId}");
    
            connectedUsers.RemoveUser(userToNotify.UserId);
    
            if (exception is not null) 
            {
                logger.LogError(exception, "An error occurred during disconnection.");
            }
    
            return base.OnDisconnectedAsync(exception);
        }
    }
    
    public interface INotificationClient
    {
        Task ReceiveNotification(string message);
    }
    
    public class ConnectedUsers 
    {
        public Dictionary<Guid,Guid> Users { get; private set; } = [];
    
        public void AddUser(Guid userId, Guid branchId)
        {
            Users.TryAdd(userId, branchId);
        }
    
        public void RemoveUser(Guid userId)
        {
            Users.Remove(userId);
        }
    }
    

    Program.cs:

    ...
    builder.Services.AddSignalR();
    builder.Services.AddHostedService<InventoryNotifierService>();
    builder.Services.AddSingleton<ConnectedUsers>();
    ...
    app.MapHub<NotificationsHub>("notifications");
    

    BackgroundService:

    public class InventoryNotifierService(
        IHubContext<NotificationsHub, INotificationClient> hubContext,
        IServiceProvider serviceProvider,
        ConnectedUsers connectedUsers,
        ILogger<InventoryNotifierService> logger) : BackgroundService
    {
        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            try
            {
                using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
                using var scope = serviceProvider.CreateScope();
                var isolatedReadContext = scope.ServiceProvider.GetRequiredService<AnyBillsBaseIsolatedReadContext>();
    
                while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
                {
                    var branches = connectedUsers.Users.Values.Distinct().ToList();
    
                    var items = await isolatedReadContext
                            .Items
                            .Where(i => branches.Contains(i.BranchId) && i.Product && i.Quantity < 5)
                            .GroupBy(i => i.BranchId)
                            .ToListAsync();
    
                    foreach (var branchItems in items)
                    {
                        foreach (var item in branchItems)
                        {
                            await hubContext.Clients
                                .Group(branchItems.Key.ToString())
                                .ReceiveNotification($"You have {item.Name} {item.Quantity} in your inventory.");
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error while reading branch items for sending notifications.");
            }
    
            stoppingToken.Register(() => logger.LogWarning($"{nameof(InventoryNotifierService)} is stopping due to host shut down."));
        }
    }