Realtime
Wallow's realtime system is split into two channels:
- SSE (Server-Sent Events) -- one-way server-to-client delivery for notifications and events, with audience scoping by permission, role, or user
- SignalR -- bidirectional communication for presence, page context, and group management
Architecture
+-------------------------------------------+
| Client (Browser) |
| |
| EventSource (/events) SignalR Hub |
| <- notifications <-> presence |
| <- events <-> groups |
| <- alerts <-> page ctx |
+------+----------------------------+-------+
| |
SSE (text/event-stream) WebSocket/LP
| |
+------v----------------------------v-------+
| API Instance |
| |
| SseEndpoint RealtimeHub |
| | | |
| SseConnectionManager | |
| | PresenceService |
| SseRedisSubscriber | |
+------+--------------------+---------------+
| |
Valkey pub/sub Valkey backplane
(sse:tenant:* (SignalR scale-out)
sse:user:*)
When to Use Each Channel
| Use Case | Channel | Why |
|---|---|---|
| Push notifications | SSE | One-way, supports audience scoping |
| Event broadcasts (inquiry updates, announcements) | SSE | One-way with permission filtering |
| Presence / online status | SignalR | Bidirectional, needs group management |
| Page context tracking | SignalR | Bidirectional, updates shared state |
| Group join/leave | SignalR | Requires client-initiated actions |
SSE (Server-Sent Events)
Endpoint
GET /events?subscribe=inquiries,announcements,notifications
Authorization: Bearer <jwt>
Accept: text/event-stream
The route is mapped with RequireAuthorization(), so any authenticated principal works -- a Bearer token as shown, or the BFF's same-origin cookie session. The subscribe query param limits which modules' events are delivered. Each SSE event is a JSON-serialized RealtimeEnvelope.
ISseDispatcher
Modules send events through ISseDispatcher (in api/src/Shared/Wallow.Shared.Contracts/Realtime/ISseDispatcher.cs):
public interface ISseDispatcher
{
Task SendToTenantAsync(Guid tenantId, RealtimeEnvelope envelope, CancellationToken ct = default);
Task SendToTenantPermissionAsync(Guid tenantId, string permission, RealtimeEnvelope envelope, CancellationToken ct = default);
Task SendToTenantRoleAsync(Guid tenantId, string role, RealtimeEnvelope envelope, CancellationToken ct = default);
Task SendToUserAsync(string userId, RealtimeEnvelope envelope, CancellationToken ct = default);
}
Audience Selection
| Method | When to Use | Example |
|---|---|---|
SendToTenantAsync |
All tenant members should see it | Announcement published, inquiry status changed |
SendToTenantPermissionAsync |
Only users with a specific permission | Internal inquiry comment (inquiries.manage) |
SendToTenantRoleAsync |
Only users in a specific role | Admin-only alerts |
SendToUserAsync |
Targeted to one user | Personal notification, direct message |
RealtimeEnvelope
RealtimeEnvelope (in api/src/Shared/Wallow.Shared.Contracts/Realtime/RealtimeEnvelope.cs) carries the event payload and optional audience-scoping fields:
public sealed record RealtimeEnvelope(
string Type,
string Module,
object Payload,
DateTime Timestamp,
string? CorrelationId = null,
string? RequiredPermission = null,
string? RequiredRole = null,
string? TargetUserId = null)
{
public static RealtimeEnvelope Create(string module, string type, object payload, string? correlationId = null)
=> new(type, module, payload, DateTime.UtcNow, correlationId);
}
The ISseDispatcher implementation stamps RequiredPermission, RequiredRole, or TargetUserId onto the envelope before publishing to Valkey. The SSE connection inspects these fields to decide whether to forward the event.
SSE Connection Filtering
When an event arrives via Valkey pub/sub, each local SSE connection applies these filters in order:
- Module in subscribe list? No -- skip
RequiredPermissionset? Check JWT claims -- skip if missingRequiredRoleset? Check JWT claims -- skip if missingTargetUserIdset? Check connection user ID -- skip if mismatch- Write to connection's bounded
Channel<T>and send via SSE response stream
SSE Infrastructure
| Component | Location | Purpose |
|---|---|---|
ISseDispatcher |
api/src/Shared/Wallow.Shared.Contracts/Realtime/ |
Dispatch interface for modules |
RedisSseDispatcher |
api/src/Wallow.Api/Services/ |
Publishes events to Valkey channels |
SseConnectionManager |
api/src/Wallow.Api/Services/ |
Tracks active connections and filters delivery |
SseConnectionState |
api/src/Wallow.Api/Services/ |
Per-connection metadata (user, tenant, modules, permissions, roles) |
SseRedisSubscriber |
api/src/Wallow.Api/Services/ |
Background service that subscribes to Valkey and fans out to connections |
SseEndpoint |
api/src/Wallow.Api/Endpoints/ |
HTTP GET /events endpoint |
RealtimeAccessRevoker |
api/src/Wallow.Api/Services/ |
Implements IRealtimeAccessRevoker -- closes one user's SSE streams and hub sockets in one tenant |
RealtimeConnectionRegistry |
api/src/Wallow.Api/Services/ |
Tracks open hub connections (with their HubCallerContext) so a revocation can abort them |
SignalRRealtimeDispatcher |
api/src/Wallow.Api/Services/ |
Implements IRealtimeDispatcher over the SignalR hub context |
SubClaimUserIdProvider |
api/src/Wallow.Api/Hubs/ |
Resolves the SignalR user identifier from the sub/NameIdentifier claim so Clients.User(...) targets the right user |
Valkey Channel Naming
| Channel Pattern | Purpose |
|---|---|
sse:tenant:{tenantId} |
Tenant-scoped events (broadcast, permission-scoped, role-scoped) |
sse:user:{userId} |
User-targeted events |
Mid-Session JWT Limitation
SSE connections extract permissions and roles from JWT claims at connection time. If a user's permissions or roles change mid-session, the SSE connection continues filtering based on the original claims until the client reconnects.
Membership revocation is the exception: IRealtimeAccessRevoker (api/src/Shared/Wallow.Shared.Contracts/Realtime/IRealtimeAccessRevoker.cs), implemented by RealtimeAccessRevoker (api/src/Wallow.Api/Services/), actively closes both the SSE streams and the hub sockets one user holds in one tenant. Identity's MembershipAccessRevoker invokes it after revoking the user's tokens, because revoking a token says nothing to a socket that is already open. The passive caveat above still applies to plain role or permission changes, which revoke nothing.
This is not a security boundary -- API endpoints enforce permissions on every request. The SSE filter prevents leaking event data to the client UI, but the source of truth for authorization remains the API layer.
SignalR
SignalR handles bidirectional real-time features. It is not used for notifications or event broadcasts -- those go through SSE.
RealtimeHub
Located at api/src/Wallow.Api/Hubs/RealtimeHub.cs, mapped to /hubs/realtime.
| Hub Method | Purpose | Direction |
|---|---|---|
JoinGroup |
Client joins a SignalR group (validated against allowed prefixes and tenant) | Client -> Server |
LeaveGroup |
Client leaves a SignalR group | Client -> Server |
UpdatePageContext |
Client reports current page; triggers PageViewersUpdated broadcast |
Client -> Server |
On connect, the hub automatically joins the user to their tenant group (tenant:{tenantId}) and, for staff users (admin/manager roles), also to tenant:{tenantId}:staff. A UserOnline presence event is broadcast to the tenant group. On disconnect, a UserOffline event is broadcast if the user has no remaining connections.
IRealtimeDispatcher
SignalR's dispatch interface (in api/src/Shared/Wallow.Shared.Contracts/Realtime/IRealtimeDispatcher.cs):
public interface IRealtimeDispatcher
{
Task SendToUserAsync(string userId, RealtimeEnvelope envelope, CancellationToken ct = default);
Task SendToGroupAsync(string groupId, RealtimeEnvelope envelope, CancellationToken ct = default);
Task SendToTenantAsync(Guid tenantId, RealtimeEnvelope envelope, CancellationToken ct = default);
}
Presence Service
IPresenceService (in api/src/Shared/Wallow.Shared.Contracts/Realtime/IPresenceService.cs) tracks user presence across server instances using Valkey. All operations are tenant-scoped:
public interface IPresenceService
{
Task TrackConnectionAsync(Guid tenantId, string userId, string connectionId, CancellationToken ct = default);
Task RemoveConnectionAsync(string connectionId, CancellationToken ct = default);
Task SetPageContextAsync(Guid tenantId, string connectionId, string pageContext, CancellationToken ct = default);
Task<IReadOnlyList<UserPresence>> GetOnlineUsersAsync(Guid tenantId, CancellationToken ct = default);
Task<IReadOnlyList<UserPresence>> GetUsersOnPageAsync(Guid tenantId, string pageContext, CancellationToken ct = default);
Task<bool> IsUserOnlineAsync(Guid tenantId, string userId, CancellationToken ct = default);
Task<string?> GetUserIdByConnectionAsync(string connectionId, CancellationToken ct = default);
}
Valkey data structures for presence (tenant-scoped):
| Key Pattern | Type | Purpose |
|---|---|---|
presence:{tenantId}:conn2user |
Hash | Maps connection ID to user ID |
presence:{tenantId}:user:{userId} |
Set | All connections for a user |
presence:connpage:{connectionId} |
String | Current page for a connection |
presence:{tenantId}:page:{pageContext} |
Set | All connections viewing a page |
presence:conn:tenant:{connectionId} |
String | Maps connection to its tenant (for cleanup) |
Group Naming Conventions
| Group Pattern | Purpose |
|---|---|
tenant:{tenantId} |
All members of a tenant |
tenant:{tenantId}:staff |
Admin and manager users in a tenant |
page:{tenantId}:{pageContext} |
Users viewing a specific page |
SignalR Backplane
SignalR uses Valkey as a backplane to synchronize messages across API instances. The backplane reuses the singleton IConnectionMultiplexer registered in Program.cs.
Handler Checklist
When adding a new event handler that sends realtime events:
- Sensitive data? Use
SendToTenantPermissionAsyncorSendToTenantRoleAsync; otherwise useSendToTenantAsyncfor broadcast - Targeted to a specific user? Use
SendToUserAsync - Bidirectional (needs client response)? Use SignalR (
IRealtimeDispatcher), not SSE - Set the
moduleparameter inRealtimeEnvelope.Create()so clients can filter by subscription
Current SSE Handler Reference
| Handler | Dispatch Method | Audience |
|---|---|---|
InquiryCommentAddedSseHandler (internal) |
SendToTenantPermissionAsync |
inquiries.manage |
InquiryCommentAddedSseHandler (public) |
SendToTenantAsync |
All tenant members |
InquirySubmittedSseHandler |
SendToTenantPermissionAsync |
inquiries.read |
InquiryStatusChangedSseHandler |
SendToTenantPermissionAsync |
inquiries.read |
SseNotificationService.BroadcastToTenantAsync |
SendToTenantAsync |
All tenant members |
SseNotificationService.SendToUserAsync |
SendToUserAsync |
Specific user |
| Presence events | SignalR (IRealtimeDispatcher) |
Via SignalR groups |
The three inquiry SSE handlers live in the Notifications module, at
api/src/Modules/Notifications/Wallow.Notifications.Application/EventHandlers/ -- Inquiries
publishes the integration events; Notifications owns their realtime fan-out.
Troubleshooting
SSE Issues
Events not arriving:
- Verify the
subscribequery param includes the event's module - Check that the user's JWT contains the required permission/role claims
- Verify Valkey pub/sub is connected (check
SseRedisSubscriberlogs)
Stale permission filtering:
- Permissions are read from JWT at connection time -- if permissions changed, the client must reconnect
Connection drops:
EventSourcehandles automatic reconnection- Check server logs for connection cleanup in
SseEndpoint
SignalR Issues
Connection fails silently:
- Check browser console for CORS errors
- Verify JWT token is being sent correctly
- Check server logs for authentication failures
Presence not updating:
- Verify client joined the correct group after connection/reconnection
- Check Valkey connectivity for backplane
Debugging
Enable detailed logging:
{
"Logging": {
"LogLevel": {
"Wallow.Api.Services.SseConnectionManager": "Debug",
"Wallow.Api.Services.SseRedisSubscriber": "Debug",
"Wallow.Api.Hubs.RealtimeHub": "Debug"
}
}
}
Related Documentation
- Caching — the Valkey instance backing pub/sub and the SignalR backplane
- Authorization — where the permission and role claims the SSE filter reads come from
- Messaging —
ISseDispatcherand the events modules publish through it - Observability — connection and delivery telemetry