Skip to content

Commit

Permalink
feat(templates): add support for azure SignalR scale out in Boilerplate
Browse files Browse the repository at this point in the history
  • Loading branch information
ysmoradi authored Dec 1, 2024
1 parent f25d0de commit fe0fa81
Show file tree
Hide file tree
Showing 21 changed files with 110 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,11 @@ public static IServiceCollection AddClientCoreProjectServices(this IServiceColle
.WithAutomaticReconnect(sp.GetRequiredService<SignalRInfinitiesRetryPolicy>())
.WithUrl(new Uri(absoluteServerAddressProvider.GetAddress(), "app-hub"), options =>
{
options.SkipNegotiation = true;
options.SkipNegotiation = false; // Required for Azure SignalR.
options.Transports = HttpTransportType.WebSockets;
// Avoid enabling long polling or Server-Sent Events. Focus on resolving the issue with WebSockets instead.
// WebSockets should be enabled on services like IIS or Cloudflare CDN, offering significantly better performance.
options.HttpMessageHandlerFactory = httpClientHandler => sp.GetRequiredService<HttpMessageHandlersChainFactory>().Invoke(httpClientHandler);
options.AccessTokenProvider = async () =>
{
var accessToken = await authTokenProvider.GetAccessToken();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,8 @@ namespace Boilerplate.Client.Core.Services;

public class SignalRInfinitiesRetryPolicy : IRetryPolicy
{
private static TimeSpan[] delays = new double[] { 1, 3, 5, 10, 15, 20, 30 }
.Select(TimeSpan.FromSeconds)
.ToArray();

public TimeSpan? NextRetryDelay(RetryContext retryContext)
{
var index = retryContext.PreviousRetryCount;

if (index < delays.Length)
{
return delays[index];
}

return TimeSpan.FromSeconds(30);
return TimeSpan.FromSeconds(1); // It's already handled by HttpMessageHandlers/RetryDelegatingHandler during negotiate http request.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
<PackageVersion Condition=" '$(notification)' == 'true' OR '$(notification)' == ''" Include="AdsPush" Version="2.0.0" />
<PackageVersion Condition=" '$(offlineDb)' == 'true' OR '$(offlineDb)' == ''" Include="Bit.Besql" Version="9.1.0-pre-04" />
<PackageVersion Condition=" '$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageVersion Condition="'$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.Azure.SignalR" Version="1.29.0" />
<PackageVersion Condition="'$(sample)' == 'Admin' OR '$(sample)' == ''" Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.22.0" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="BlazorApplicationInsights" Version="3.1.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
<PackageVersion Condition=" '$(notification)' == 'true' OR '$(notification)' == ''" Include="AdsPush" Version="2.0.0" />
<PackageVersion Condition=" '$(offlineDb)' == 'true' OR '$(offlineDb)' == ''" Include="Bit.Besql" Version="9.1.0-pre-04" />
<PackageVersion Condition=" '$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageVersion Condition="'$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.Azure.SignalR" Version="1.29.0" />
<PackageVersion Condition="'$(sample)' == 'Admin' OR '$(sample)' == ''" Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.22.0" />
<PackageVersion Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="BlazorApplicationInsights" Version="3.1.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageReference Condition=" '$(database)' == 'PostgreSQL' OR '$(database)' == '' " Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Condition=" ('$(database)' == 'MySql' OR '$(database)' == '') AND '$(framework)' == 'net8.0' " Include="Pomelo.EntityFrameworkCore.MySql" />
<PackageReference Condition=" '$(sentry)' == 'true' OR '$(sentry)' == '' " Include="Sentry.AspNetCore" />
<PackageReference Condition="'$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.Azure.SignalR" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" PrivateAssets="all" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ public async Task Delete(Guid id, string concurrencyStamp, CancellationToken can
//#if (signalR == true)
private async Task PublishDashboardDataChanged(CancellationToken cancellationToken)
{
// Checkout AppHubConnectionHandler's comments for more info.
await appHubContext.Clients.GroupExcept("AuthenticatedClients", excludedConnectionIds: [User.GetSessionId().ToString()]).SendAsync(SignalREvents.PUBLISH_MESSAGE, SharedPubSubMessages.DASHBOARD_DATA_CHANGED, cancellationToken);
// Checkout AppHub's comments for more info.
// In order to exclude current user session, gets its signalR connection id from database and use GroupExcept instead.
await appHubContext.Clients.Group("AuthenticatedClients").SendAsync(SignalREvents.PUBLISH_MESSAGE, SharedPubSubMessages.DASHBOARD_DATA_CHANGED, cancellationToken);
}
//#endif
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Boilerplate.Server.Api.SignalR;
//#endif
using Boilerplate.Server.Api.Services;
using Boilerplate.Server.Api.Models.Identity;
using Boilerplate.Shared.Controllers.Diagnostics;

namespace Boilerplate.Server.Api.Controllers.Diagnostics;
Expand Down Expand Up @@ -34,7 +35,15 @@ public async Task<string> DoDiagnostics(CancellationToken cancellationToken)
result.AppendLine($"Trace => {Request.HttpContext.TraceIdentifier}");

var isAuthenticated = User.IsAuthenticated();
Guid? userSessionId = isAuthenticated ? User.GetSessionId() : null;
Guid? userSessionId = null;
UserSession? userSession = null;

if (isAuthenticated)
{
userSessionId = User.GetSessionId();
userSession = await DbContext
.UserSessions.SingleAsync(us => us.Id == userSessionId, cancellationToken);
}

result.AppendLine($"IsAuthenticated: {isAuthenticated.ToString().ToLowerInvariant()}");

Expand All @@ -51,9 +60,9 @@ public async Task<string> DoDiagnostics(CancellationToken cancellationToken)
//#endif

//#if (signalR == true)
if (isAuthenticated)
if (isAuthenticated && userSession!.SignalRConnectionId is not null)
{
await appHubContext.Clients.Client(userSessionId.ToString()!).SendAsync(SignalREvents.SHOW_MESSAGE, DateTimeOffset.Now.ToString("HH:mm:ss"), cancellationToken);
await appHubContext.Clients.Client(userSession.SignalRConnectionId).SendAsync(SignalREvents.SHOW_MESSAGE, DateTimeOffset.Now.ToString("HH:mm:ss"), cancellationToken);
}
//#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ public async Task SendResetPasswordToken(SendResetPasswordTokenRequestDto reques
}

//#if (signalR == true)
// Checkout AppHubConnectionHandler's comments for more info.
sendMessagesTasks.Add(appHubContext.Clients.User(user.Id.ToString()).SendAsync(SignalREvents.SHOW_MESSAGE, message, cancellationToken));
//#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,6 @@ public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null,
//#endif

//#if (signalR == true)
// Checkout AppHubConnectionHandler's comments for more info.
sendMessagesTasks.Add(appHubContext.Clients.User(user.Id.ToString()).SendAsync(SignalREvents.SHOW_MESSAGE, pushMessage, cancellationToken));
//#endif

Expand Down Expand Up @@ -371,7 +370,6 @@ public async Task SendTwoFactorToken(SignInRequestDto request, CancellationToken
//#if (signalR == true)
if (firstStepAuthenticationMethod != "SignalR")
{
// Checkout AppHubConnectionHandler's comments for more info.
sendMessagesTasks.Add(appHubContext.Clients.User(user.Id.ToString()).SendAsync(SignalREvents.SHOW_MESSAGE, message, cancellationToken));
}
//#endif
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,11 @@ public async Task RevokeSession(Guid id, CancellationToken cancellationToken)
await DbContext.SaveChangesAsync(cancellationToken);

//#if (signalR == true)
// Checkout AppHubConnectionHandler's comments for more info.
await appHubContext.Clients.Client(userSession.Id.ToString()).SendAsync(SignalREvents.PUBLISH_MESSAGE, SharedPubSubMessages.SESSION_REVOKED, cancellationToken);
// Checkout AppHub's comments for more info.
if (userSession.SignalRConnectionId is not null)
{
await appHubContext.Clients.Client(userSession.SignalRConnectionId).SendAsync(SignalREvents.PUBLISH_MESSAGE, SharedPubSubMessages.SESSION_REVOKED, cancellationToken);
}
//#endif
}

Expand Down Expand Up @@ -357,7 +360,7 @@ public async Task SendElevatedAccessToken(CancellationToken cancellationToken)
// Elevated access token claim gets added to access token upon refresh token request call, so their lifetime would be the same

if (resendDelay < TimeSpan.Zero)
throw new TooManyRequestsExceptions(Localizer[nameof(AppStrings.WaitForElevatedAccessTokenRequestResendDelay) , resendDelay.Value.Humanize(culture: CultureInfo.CurrentUICulture)]);
throw new TooManyRequestsExceptions(Localizer[nameof(AppStrings.WaitForElevatedAccessTokenRequestResendDelay), resendDelay.Value.Humanize(culture: CultureInfo.CurrentUICulture)]);

user.ElevatedAccessTokenRequestedOn = DateTimeOffset.Now;
var result = await userManager.UpdateAsync(user);
Expand Down Expand Up @@ -386,12 +389,12 @@ public async Task SendElevatedAccessToken(CancellationToken cancellationToken)
}

//#if (signalR == true)
// Checkout AppHubConnectionHandler's comments for more info.
// Checkout AppHub's comments for more info.
var userSessionIdsExceptCurrentUserSessionId = await DbContext.UserSessions
.Where(us => us.UserId == user.Id && us.Id != currentUserSessionId)
.Select(us => us.Id)
.Where(us => us.UserId == user.Id && us.Id != currentUserSessionId && us.SignalRConnectionId != null)
.Select(us => us.SignalRConnectionId!)
.ToArrayAsync(cancellationToken);
sendMessagesTasks.Add(appHubContext.Clients.Clients(userSessionIdsExceptCurrentUserSessionId.Select(us => us.ToString()).ToArray()).SendAsync(SignalREvents.SHOW_MESSAGE, messageText, cancellationToken));
sendMessagesTasks.Add(appHubContext.Clients.Clients(userSessionIdsExceptCurrentUserSessionId).SendAsync(SignalREvents.SHOW_MESSAGE, messageText, cancellationToken));
//#endif

//#if (notification == true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ public async Task Delete(Guid id, string concurrencyStamp, CancellationToken can
//#if (signalR == true)
private async Task PublishDashboardDataChanged(CancellationToken cancellationToken)
{
// Checkout AppHubConnectionHandler's comments for more info.
await appHubContext.Clients.GroupExcept("AuthenticatedClients", excludedConnectionIds: [User.GetSessionId().ToString()]).SendAsync(SignalREvents.PUBLISH_MESSAGE, SharedPubSubMessages.DASHBOARD_DATA_CHANGED, cancellationToken);
// Checkout AppHub's comments for more info.
// In order to exclude current user session, gets its signalR connection id from database and use GroupExcept instead.
await appHubContext.Clients.Group("AuthenticatedClients").SendAsync(SignalREvents.PUBLISH_MESSAGE, SharedPubSubMessages.DASHBOARD_DATA_CHANGED, cancellationToken);
}
//#endif
}
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ protected override void Up(MigrationBuilder migrationBuilder)
Privileged = table.Column<bool>(type: "INTEGER", nullable: false),
StartedOn = table.Column<long>(type: "INTEGER", nullable: false),
RenewedOn = table.Column<long>(type: "INTEGER", nullable: true),
UserId = table.Column<Guid>(type: "TEXT", nullable: false)
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
SignalRConnectionId = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<long?>("RenewedOn")
.HasColumnType("INTEGER");

b.Property<string>("SignalRConnectionId")
.HasColumnType("TEXT");

b.Property<long>("StartedOn")
.HasColumnType("INTEGER");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ public partial class UserSession
//#if (notification == true)
public PushNotificationSubscription? PushNotificationSubscription { get; set; }
//#endif

//#if (signalR == true)
public string? SignalRConnectionId { get; set; }
//#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,6 @@
using Boilerplate.Server.Api.Controllers;
using Boilerplate.Server.Api.Models.Identity;
using Boilerplate.Server.Api.Services.Identity;
//#if (signalR == true)
using Microsoft.AspNetCore.SignalR;
using Boilerplate.Server.Api.Signalr;
using Boilerplate.Server.Api.SignalR;
//#endif

namespace Boilerplate.Server.Api;

Expand Down Expand Up @@ -160,11 +155,17 @@ public static void AddServerApiProjectServices(this WebApplicationBuilder builde
});

//#if (signalR == true)
services.AddSingleton<HubConnectionHandler<AppHub>, AppHubConnectionHandler>();
services.AddSignalR(options =>
var signalRBuilder = services.AddSignalR(options =>
{
options.EnableDetailedErrors = env.IsDevelopment();
});
if (string.IsNullOrEmpty(configuration["Azure:SignalR:ConnectionString"]) is false)
{
signalRBuilder.AddAzureSignalR(options =>
{
configuration.GetRequiredSection("Azure:SignalR").Bind(options);
});
}
//#endif

services.AddPooledDbContextFactory<AppDbContext>(AddDbContext);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,41 @@

namespace Boilerplate.Server.Api.SignalR;

/// <summary>
/// SignalR supports basic scenarios like sending messages to all connected clients using `Clients.All()`,
/// which broadcasts to all SignalR connections, whether authenticated or not. Similarly, `Clients.User(userId)`
/// sends messages to all open browser tabs or applications associated with a specific user.
///
/// In addition to these, the following enhanced scenarios are supported:
/// 1. `Clients.Group("AuthenticatedClients")`: Sends a message to all browser tabs and apps that are signed in.
/// 2. Each user session knows its own SignalR connection Id. For instance, the application
/// already uses this approach in the `UserController's RevokeSession` method by sending a SignalR message to
/// `Clients.Client(userSession.SignalRConnectionId)`. This ensures that the corresponding browser tab or app clears
/// its access/refresh tokens from storage and navigates to the sign-in page if necessary.
/// </summary>
[AllowAnonymous]
public partial class AppHub : Hub
{
[AutoInject] private RootServiceScopeProvider rootScopeProvider = default!;

public override async Task OnConnectedAsync()
{
if (Context.User.IsAuthenticated() is false)
{
if (Context.GetHttpContext()?.Request?.Query?.TryGetValue("access_token", out var _) is true)
if (Context.GetHttpContext()?.Request.Headers.Authorization.Any() is true)
{
// AppHub allows anonymous connections. However, if an Authorization is included
// and the user is not authenticated, it indicates the client has sent an invalid or expired access token.
// In this scenario, we should refresh the access token and attempt to reconnect.

throw new HubException(nameof(AppStrings.UnauthorizedException));
}
}
else
{
// Checkout AppHubConnectionHandler's comments for more info.
await using var scope = rootScopeProvider.Invoke();
await using var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.UserSessions.Where(us => us.Id == Context.User!.GetSessionId()).ExecuteUpdateAsync(us => us.SetProperty(x => x.SignalRConnectionId, Context.ConnectionId));

await Groups.AddToGroupAsync(Context.ConnectionId, "AuthenticatedClients");
}

Expand All @@ -29,7 +45,14 @@ public override async Task OnConnectedAsync()

public override async Task OnDisconnectedAsync(Exception? exception)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, "AuthenticatedClients");
if (Context.User.IsAuthenticated())
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, "AuthenticatedClients");

await using var scope = rootScopeProvider.Invoke();
await using var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await dbContext.UserSessions.Where(us => us.Id == Context.User!.GetSessionId()).ExecuteUpdateAsync(us => us.SetProperty(x => x.SignalRConnectionId, (string?)null));
}

await base.OnDisconnectedAsync(exception);
}
Expand Down
Loading

0 comments on commit fe0fa81

Please sign in to comment.