skills/wshaddix/dotnet-skills/signalr-integration

signalr-integration

SKILL.md

Rationale

Real-time communication enhances user experience with instant updates, notifications, and collaborative features. SignalR provides a robust framework for bidirectional communication between server and clients. Without proper patterns, applications can suffer from connection leaks, scalability issues, and security vulnerabilities. These patterns provide production-ready approaches to SignalR in Razor Pages applications.

Patterns

Pattern 1: Hub Structure and Organization

Organize hubs by domain with proper authentication and group management.

// Base hub with common functionality
public abstract class AuthenticatedHub : Hub
{
    protected string? UserId => Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    protected bool IsAuthenticated => !string.IsNullOrEmpty(UserId);

    public override async Task OnConnectedAsync()
    {
        if (!IsAuthenticated)
        {
            Context.Abort();
            return;
        }

        await base.OnConnectedAsync();
    }
}

// Notification hub for real-time updates
public interface INotificationClient
{
    Task ReceiveNotification(NotificationMessage message);
    Task NotificationRead(string notificationId);
    Task UnreadCountUpdated(int count);
}

public class NotificationHub : AuthenticatedHub<INotificationClient>
{
    private readonly INotificationService _notificationService;
    private readonly ILogger<NotificationHub> _logger;

    public NotificationHub(
        INotificationService notificationService,
        ILogger<NotificationHub> logger)
    {
        _notificationService = notificationService;
        _logger = logger;
    }

    public override async Task OnConnectedAsync()
    {
        await base.OnConnectedAsync();

        if (UserId != null)
        {
            // Join user-specific group
            await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{UserId}");
            
            // Send initial unread count
            var count = await _notificationService.GetUnreadCountAsync(UserId);
            await Clients.Caller.UnreadCountUpdated(count);
            
            _logger.LogDebug("User {UserId} connected to notification hub", UserId);
        }
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        if (UserId != null)
        {
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user:{UserId}");
            _logger.LogDebug("User {UserId} disconnected from notification hub", UserId);
        }

        await base.OnDisconnectedAsync(exception);
    }

    public async Task MarkAsRead(string notificationId)
    {
        if (UserId == null) return;

        await _notificationService.MarkAsReadAsync(UserId, notificationId);
        
        var count = await _notificationService.GetUnreadCountAsync(UserId);
        await Clients.Caller.UnreadCountUpdated(count);
    }

    public async Task SubscribeToTopic(string topic)
    {
        // Validate topic access
        if (!await CanSubscribeToTopic(topic))
        {
            throw new HubException("Not authorized for this topic");
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, $"topic:{topic}");
    }

    private Task<bool> CanSubscribeToTopic(string topic)
    {
        // Implement topic authorization logic
        return Task.FromResult(true);
    }
}

// Order status hub for real-time order updates
public interface IOrderClient
{
    Task OrderStatusUpdated(string orderId, OrderStatus status, string? message);
    Task OrderProgressUpdated(string orderId, int progressPercent);
    Task OrderCompleted(string orderId);
}

public class OrderHub : AuthenticatedHub<IOrderClient>
{
    private readonly IOrderService _orderService;

    public OrderHub(IOrderService orderService)
    {
        _orderService = orderService;
    }

    public async Task SubscribeToOrder(string orderId)
    {
        if (UserId == null) return;

        // Verify user owns this order
        var order = await _orderService.GetOrderAsync(orderId);
        if (order?.UserId != UserId)
        {
            throw new HubException("Not authorized to view this order");
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, $"order:{orderId}");
    }

    public async Task UnsubscribeFromOrder(string orderId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"order:{orderId}");
    }
}

Pattern 2: Razor Pages Integration

Integrate SignalR clients in Razor Pages with proper connection lifecycle management.

// SignalR configuration in Program.cs
builder.Services.AddSignalR()
    .AddJsonProtocol(options =>
    {
        options.PayloadSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    })
    .AddStackExchangeRedis("redis:6379"); // For scale-out

// Authentication for SignalR
builder.Services.AddAuthentication()
    .AddCookie(options =>
    {
        // Allow SignalR to use cookie auth
        options.Events.OnRedirectToLogin = context =>
        {
            context.Response.StatusCode = 401;
            return Task.CompletedTask;
        };
    });

// Hub endpoints
app.MapHub<NotificationHub>("/hubs/notifications")
    .RequireAuthorization();

app.MapHub<OrderHub>("/hubs/orders")
    .RequireAuthorization();
// wwwroot/js/signalr-client.js
class SignalRClient {
    constructor() {
        this.connections = new Map();
        this.reconnectDelays = [0, 2000, 5000, 10000, 30000];
    }

    async connect(hubUrl, hubName) {
        if (this.connections.has(hubName)) {
            return this.connections.get(hubName);
        }

        const connection = new signalR.HubConnectionBuilder()
            .withUrl(hubUrl, {
                transport: signalR.HttpTransportType.WebSockets |
                          signalR.HttpTransportType.ServerSentEvents
            })
            .withAutomaticReconnect(this.reconnectDelays)
            .configureLogging(signalR.LogLevel.Information)
            .build();

        connection.onreconnecting(error => {
            console.log(`Reconnecting to ${hubName}...`, error);
            this.showReconnectingUI(hubName);
        });

        connection.onreconnected(connectionId => {
            console.log(`Reconnected to ${hubName}`, connectionId);
            this.hideReconnectingUI(hubName);
        });

        connection.onclose(error => {
            console.log(`Connection to ${hubName} closed`, error);
            this.connections.delete(hubName);
            this.showDisconnectedUI(hubName);
        });

        try {
            await connection.start();
            this.connections.set(hubName, connection);
            console.log(`Connected to ${hubName}`);
            return connection;
        } catch (err) {
            console.error(`Failed to connect to ${hubName}`, err);
            throw err;
        }
    }

    async disconnect(hubName) {
        const connection = this.connections.get(hubName);
        if (connection) {
            await connection.stop();
            this.connections.delete(hubName);
        }
    }

    getConnection(hubName) {
        return this.connections.get(hubName);
    }

    showReconnectingUI(hubName) {
        document.body.classList.add('signalr-reconnecting');
    }

    hideReconnectingUI(hubName) {
        document.body.classList.remove('signalr-reconnecting');
    }

    showDisconnectedUI(hubName) {
        document.body.classList.add('signalr-disconnected');
    }
}

// Global instance
window.signalRClient = new SignalRClient();
// Razor Page with SignalR integration
public class OrderStatusModel : PageModel
{
    private readonly IOrderService _orderService;

    public OrderStatusModel(IOrderService orderService)
    {
        _orderService = orderService;
    }

    public Order Order { get; set; } = null!;

    public async Task<IActionResult> OnGetAsync(string orderId)
    {
        Order = await _orderService.GetOrderAsync(orderId);
        
        if (Order == null || Order.UserId != User.FindFirstValue(ClaimTypes.NameIdentifier))
        {
            return NotFound();
        }

        return Page();
    }
}
<!-- OrderStatus.cshtml -->
@page "{orderId}"
@model OrderStatusModel
@inject IConfiguration Configuration

@section Scripts {
    <script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
    <script src="~/js/signalr-client.js"></script>
    <script>
        document.addEventListener('DOMContentLoaded', async function() {
            const orderId = '@Model.Order.Id';
            
            try {
                const connection = await window.signalRClient.connect(
                    '/hubs/orders',
                    'orders'
                );

                // Subscribe to order updates
                await connection.invoke('SubscribeToOrder', orderId);

                // Handle status updates
                connection.on('OrderStatusUpdated', (id, status, message) => {
                    if (id === orderId) {
                        updateStatusUI(status, message);
                    }
                });

                connection.on('OrderProgressUpdated', (id, progress) => {
                    if (id === orderId) {
                        updateProgressBar(progress);
                    }
                });

                connection.on('OrderCompleted', (id) => {
                    if (id === orderId) {
                        showCompletionUI();
                    }
                });

            } catch (err) {
                console.error('Failed to connect:', err);
                showFallbackUI();
            }
        });

        function updateStatusUI(status, message) {
            document.getElementById('status-badge').textContent = status;
            if (message) {
                document.getElementById('status-message').textContent = message;
            }
        }

        function updateProgressBar(progress) {
            const bar = document.getElementById('progress-bar');
            bar.style.width = progress + '%';
            bar.textContent = progress + '%';
        }

        function showCompletionUI() {
            document.getElementById('completion-modal').classList.remove('hidden');
        }

        function showFallbackUI() {
            // Fall back to polling
            setInterval(() => location.reload(), 30000);
        }
    </script>
}

<div class="order-status">
    <h1>Order #@Model.Order.Id</h1>
    <span id="status-badge" class="badge">@Model.Order.Status</span>
    <p id="status-message" class="text-muted"></p>
    
    <div class="progress">
        <div id="progress-bar" class="progress-bar" style="width: 0%">0%</div>
    </div>
</div>

<div id="completion-modal" class="modal hidden">
    <div class="modal-content">
        <h2>Order Complete!</h2>
        <p>Your order has been processed successfully.</p>
    </div>
</div>

Pattern 3: Server-Side Broadcasting

Send notifications from services and background workers to connected clients.

// Notification service that broadcasts to clients
public interface IRealTimeNotificationService
{
    Task SendToUserAsync(string userId, NotificationMessage message);
    Task SendToUsersAsync(IEnumerable<string> userIds, NotificationMessage message);
    Task BroadcastToTopicAsync(string topic, NotificationMessage message);
    Task SendToGroupAsync(string groupName, NotificationMessage message);
}

public class SignalRNotificationService : IRealTimeNotificationService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;
    private readonly ILogger<SignalRNotificationService> _logger;

    public SignalRNotificationService(
        IHubContext<NotificationHub, INotificationClient> hubContext,
        ILogger<SignalRNotificationService> logger)
    {
        _hubContext = hubContext;
        _logger = logger;
    }

    public async Task SendToUserAsync(string userId, NotificationMessage message)
    {
        try
        {
            await _hubContext.Clients.Group($"user:{userId}")
                .ReceiveNotification(message);
            
            _logger.LogDebug("Notification sent to user {UserId}", userId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to send notification to user {UserId}", userId);
        }
    }

    public async Task SendToUsersAsync(IEnumerable<string> userIds, NotificationMessage message)
    {
        var tasks = userIds.Select(userId => SendToUserAsync(userId, message));
        await Task.WhenAll(tasks);
    }

    public async Task BroadcastToTopicAsync(string topic, NotificationMessage message)
    {
        await _hubContext.Clients.Group($"topic:{topic}")
            .ReceiveNotification(message);
    }

    public async Task SendToGroupAsync(string groupName, NotificationMessage message)
    {
        await _hubContext.Clients.Group(groupName)
            .ReceiveNotification(message);
    }

    public async Task UpdateUnreadCountAsync(string userId, int count)
    {
        await _hubContext.Clients.Group($"user:{userId}")
            .UnreadCountUpdated(count);
    }
}

// Background service that sends notifications
public class OrderProcessingNotifier : BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<OrderProcessingNotifier> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = _serviceProvider.CreateAsyncScope();
                var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>();
                var notificationService = scope.ServiceProvider.GetRequiredService<IRealTimeNotificationService>();

                // Get orders that need status updates
                var ordersToUpdate = await orderService.GetOrdersNeedingUpdatesAsync(stoppingToken);

                foreach (var order in ordersToUpdate)
                {
                    await notificationService.SendToUserAsync(
                        order.UserId,
                        new NotificationMessage
                        {
                            Id = Guid.NewGuid().ToString(),
                            Title = "Order Update",
                            Message = $"Your order #{order.Id} status changed to {order.Status}",
                            Type = NotificationType.OrderUpdate,
                            Data = new Dictionary<string, object>
                            {
                                ["orderId"] = order.Id,
                                ["status"] = order.Status
                            }
                        });

                    await notificationService.UpdateUnreadCountAsync(
                        order.UserId,
                        await orderService.GetUnreadNotificationCountAsync(order.UserId));
                }

                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error in order processing notifier");
                await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
            }
        }
    }
}

Pattern 4: Connection Management and Scale-Out

Handle connection scaling with Redis backplane and connection state management.

// Connection tracking service
public interface IConnectionTracker
{
    Task AddConnectionAsync(string userId, string connectionId);
    Task RemoveConnectionAsync(string userId, string connectionId);
    Task<IReadOnlyList<string>> GetUserConnectionsAsync(string userId);
    Task<bool> IsUserOnlineAsync(string userId);
}

public class RedisConnectionTracker : IConnectionTracker
{
    private readonly IDistributedCache _cache;
    private readonly TimeSpan _connectionTimeout = TimeSpan.FromHours(2);

    public RedisConnectionTracker(IDistributedCache cache)
    {
        _cache = cache;
    }

    public async Task AddConnectionAsync(string userId, string connectionId)
    {
        var key = $"connections:{userId}";
        var connections = await GetConnectionsAsync(userId);
        connections.Add(connectionId);
        
        await _cache.SetStringAsync(key, 
            JsonSerializer.Serialize(connections),
            new DistributedCacheEntryOptions
            {
                SlidingExpiration = _connectionTimeout
            });
    }

    public async Task RemoveConnectionAsync(string userId, string connectionId)
    {
        var key = $"connections:{userId}";
        var connections = await GetConnectionsAsync(userId);
        connections.Remove(connectionId);

        if (connections.Count == 0)
        {
            await _cache.RemoveAsync(key);
        }
        else
        {
            await _cache.SetStringAsync(key, 
                JsonSerializer.Serialize(connections),
                new DistributedCacheEntryOptions
                {
                    SlidingExpiration = _connectionTimeout
                });
        }
    }

    public async Task<IReadOnlyList<string>> GetUserConnectionsAsync(string userId)
    {
        return await GetConnectionsAsync(userId);
    }

    public async Task<bool> IsUserOnlineAsync(string userId)
    {
        var connections = await GetConnectionsAsync(userId);
        return connections.Count > 0;
    }

    private async Task<List<string>> GetConnectionsAsync(string userId)
    {
        var key = $"connections:{userId}";
        var json = await _cache.GetStringAsync(key);
        
        return string.IsNullOrEmpty(json) 
            ? new List<string>() 
            : JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>();
    }
}

// Scale-out configuration
builder.Services.AddSignalR()
    .AddStackExchangeRedis(options =>
    {
        options.Configuration.ConnectionString = "redis:6379";
        options.Configuration.AbortOnConnectFail = false;
    })
    .AddMessagePackProtocol(); // Binary protocol for better performance

// Sticky sessions are NOT needed with Redis backplane
// Load balancer can use round-robin

Anti-Patterns

// ❌ BAD: Creating new connection on every page
const connection = new signalR.HubConnectionBuilder()
    .withUrl('/hubs/notifications')
    .build();
await connection.start(); // Creates new connection every time!

// ✅ GOOD: Reuse connections across pages
// Use the SignalRClient class shown in Pattern 2
// ❌ BAD: Calling clients without error handling
public async Task SendNotification(string userId, string message)
{
    await _hubContext.Clients.Group($"user:{userId}")
        .ReceiveNotification(message); // May throw if user not connected
}

// ✅ GOOD: Handle errors gracefully
public async Task SendNotification(string userId, string message)
{
    try
    {
        await _hubContext.Clients.Group($"user:{userId}")
            .ReceiveNotification(message);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Failed to send notification to {UserId}", userId);
        // Queue for later delivery or mark as pending
    }
}

// ❌ BAD: Storing hub context in static/singleton
public static class BadNotificationService
{
    public static IHubContext<NotificationHub> HubContext { get; set; } = null!;
}

// ✅ GOOD: Inject IHubContext where needed
public class GoodNotificationService
{
    private readonly IHubContext<NotificationHub> _hubContext;
    
    public GoodNotificationService(IHubContext<NotificationHub> hubContext)
    {
        _hubContext = hubContext;
    }
}

// ❌ BAD: Blocking in hub methods
public string GetData()
{
    var result = _service.GetDataAsync().Result; // Deadlock risk!
    return result;
}

// ✅ GOOD: Use async throughout
public async Task<string> GetDataAsync()
{
    return await _service.GetDataAsync();
}

// ❌ BAD: Not validating group membership
public async Task JoinGroup(string groupName)
{
    await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
    // Anyone can join any group!
}

// ✅ GOOD: Validate before adding to group
public async Task JoinGroup(string groupName)
{
    if (!await CanJoinGroup(groupName))
    {
        throw new HubException("Access denied");
    }
    
    await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
}

// ❌ BAD: Trusting client-provided user ID
public async Task SendMessage(string userId, string message)
{
    // Client could impersonate any user!
    await Clients.Group($"user:{userId}").ReceiveMessage(message);
}

// ✅ GOOD: Use authenticated identity from Context
public async Task SendMessage(string targetUserId, string message)
{
    var senderId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    if (string.IsNullOrEmpty(senderId))
    {
        throw new HubException("Not authenticated");
    }
    
    // Validate sender can message target
    if (!await CanMessageUser(senderId, targetUserId))
    {
        throw new HubException("Not authorized");
    }
    
    await Clients.Group($"user:{targetUserId}").ReceiveMessage(new {
        From = senderId,
        Message = message,
        Timestamp = DateTime.UtcNow
    });
}

// ❌ BAD: Broadcasting to all clients without filtering
public async Task BroadcastUpdate(string data)
{
    await Clients.All.ReceiveUpdate(data); // All connected clients!
}

// ✅ GOOD: Target specific groups or use authorization
public async Task BroadcastToAuthorized(string data)
{
    // Only send to users with specific role or permission
    await Clients.Group("admins").ReceiveUpdate(data);
}

References

Weekly Installs
3
GitHub Stars
1
First Seen
8 days ago
Installed on
amp3
cline3
opencode3
cursor3
kimi-cli3
codex3