Skip to main content
Version: 1.1

10. SignalR

Real-time delivery to connected web (and, via the same client contract, mobile) sessions. The hub itself lives in the legacy MultiTenancyApi host (see §4.4.1 for why), while the user-ID resolution and canonical constants are shared, versioned framework code.

10.1 Hub

Class: NotificationHubShumoul.Framework.MultiTenancy.Api\Notifications\SignalR\NotificationHub.cs Base: Hub (Microsoft.AspNetCore.SignalR) · [Authorize] at class level — a connection must present a valid JWT to connect at all.

[Authorize]
public class NotificationHub : Hub, ITransientService
{
public override async Task OnConnectedAsync() { /* ... */ }
public override async Task OnDisconnectedAsync(Exception? exception) { /* ... */ }
}

The hub has no callable client→server methods (hubConnection.invoke(...)) — it is purely a server→client push channel. All "interaction" is connection lifecycle (connect/disconnect) plus topic subscribe/unsubscribe, which happens over the separate REST endpoints in §9.13.4.

10.2 Connection & Authentication

  • Connect to the hub's mapped path (the SignalR endpoint route is configured where MapHub<NotificationHub>() is called in the MultiTenancyApi host's startup — confirm the exact path with your environment's Swagger/ configuration, as it was not independently re-verified for this documentation pass).
  • Authentication is standard JWT bearer, but because browsers cannot set custom headers on a WebSocket upgrade request, the token must be passed as an access_token query-string parameter for the SignalR path specifically — the host's OnMessageReceived JWT event extracts it from the query string only for the notifications hub path, not for ordinary REST calls.
  • User ID resolution: Shumoul JWTs carry the user ID under a custom claim named "NameIdentifier", not the WS-Federation URI ClaimTypes.NameIdentifier that ASP.NET Core's DefaultUserIdProvider reads by default. NameIdentifierUserIdProvider (Shumoul.Notification.SignalR package) reads the custom claim first, falling back to the standard one:
public class NameIdentifierUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection) =>
connection.User?.FindFirst("NameIdentifier")?.Value
?? connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}

Without this custom provider, Clients.User(userId) would never resolve any connection, because the default provider looks for a claim type Shumoul's JWTs don't use under that name.

10.3 Groups

On every successful connection, OnConnectedAsync:

  1. Adds the connection to a tenant group: GroupTenant-{tenantId} (via NotificationSignalRConstants.TenantGroupPrefix), using the current ITenantInfo.Id resolved by Finbuckle for this request — throws an IdentityException if no tenant could be resolved.
  2. Records the connection ID via INotificationConnectionService.AddConnectionAsync.
  3. Adds the connection to a topic group per topic the user is subscribed to: Topic-{topicId} (via NotificationSignalRConstants.TopicGroupPrefix), read from INotificationTopicSubscriberService.GetUserSubscribedTopicsIds().

OnDisconnectedAsync reverses all of the above — removes the connection from the tenant group, deletes the tracked connection ID, and removes it from every subscribed topic group.

10.4 Client event contract

ConstantValuePurpose
NotificationSignalRConstants.ClientMethod"messages"The exact client-side event name to listen on
NotificationSignalRConstants.TenantGroupPrefix"GroupTenant-"Prefix for tenant-scoped groups
NotificationSignalRConstants.TopicGroupPrefix"Topic-"Prefix for topic-scoped groups

These three values are a contract between server and every client (Angular, Flutter, any future client). Changing any of them breaks real-time delivery for every connected user until server and every client are redeployed together. Treat them as append-only/never-rename constants.

Server-side send (conceptually — the actual send call lives in the SignalR channel provider / NotificationSender, not the hub itself):

await hubContext.Clients.User(userId.ToString()).SendAsync(NotificationSignalRConstants.ClientMethod, payload);

The payload delivered on the "messages" event is shaped like UserNotificationDto:

{
"id": "8b1e2f40-0000-0000-0000-000000000001",
"title": "تم اعتماد فاتورة الشراء",
"fTitle": "Purchase invoice posted",
"message": "تم اعتماد الفاتورة INV-2026-00451",
"fMessage": "Invoice INV-2026-00451 has been posted",
"url": "/purchase/invoices/1a2b3c4d-0000-0000-0000-000000000099",
"status": 1,
"displayingHoursCount": 24,
"canBeIgnored": true,
"read": false,
"ignored": false,
"createdBy": "00000000-0000-0000-0000-000000000000",
"createdOn": "2026-07-04T09:12:03Z"
}

10.5 Angular sample

import * as signalR from '@microsoft/signalr';

const connection = new signalR.HubConnectionBuilder()
.withUrl('/notificationHub', { accessTokenFactory: () => authService.getAccessToken() })
.withAutomaticReconnect()
.build();

connection.on('messages', (payload: UserNotificationDto) => {
notificationStore.pushRealtime(payload); // update badge / toast immediately
});

await connection.start();

withAutomaticReconnect() handles transient disconnects; on each successful reconnect, the hub's OnConnectedAsync re-runs and re-adds the connection to its tenant/topic groups automatically — the client does not need to manually resubscribe.

10.6 Flutter sample

final hubConnection = HubConnectionBuilder()
.withUrl(
'$baseUrl/notificationHub',
options: HttpConnectionOptions(accessTokenFactory: () async => await authService.getAccessToken()),
)
.withAutomaticReconnect()
.build();

hubConnection.on('messages', (arguments) {
final payload = UserNotificationDto.fromJson(arguments![0] as Map<String, dynamic>);
notificationBloc.add(RealtimeNotificationReceived(payload));
});

await hubConnection.start();

10.7 Reconnect behavior

Both samples above use client-library automatic reconnect. There is no server-side message replay for a gap between disconnect and reconnect — SignalR is a best-effort live channel, not a durable queue. This is exactly why the InApp channel exists in parallel (§7.6): a client should call GetInbox/GetUnreadCount (§9.1) on reconnect/app resume to reconcile anything it may have missed while disconnected, rather than relying on SignalR alone for durability.