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: NotificationHub — Shumoul.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_tokenquery-string parameter for the SignalR path specifically — the host'sOnMessageReceivedJWT 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 URIClaimTypes.NameIdentifierthat ASP.NET Core'sDefaultUserIdProviderreads by default.NameIdentifierUserIdProvider(Shumoul.Notification.SignalRpackage) 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:
- Adds the connection to a tenant group:
GroupTenant-{tenantId}(viaNotificationSignalRConstants.TenantGroupPrefix), using the currentITenantInfo.Idresolved by Finbuckle for this request — throws anIdentityExceptionif no tenant could be resolved. - Records the connection ID via
INotificationConnectionService.AddConnectionAsync. - Adds the connection to a topic group per topic the user is subscribed to:
Topic-{topicId}(viaNotificationSignalRConstants.TopicGroupPrefix), read fromINotificationTopicSubscriberService.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
| Constant | Value | Purpose |
|---|---|---|
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.