diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj
index de9f347d..ca75beda 100644
--- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj
+++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj
@@ -1,7 +1,15 @@
+
+ 11.0.0-rc.3.2
+
+
+
+
+
+
diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs
index 3a4615f2..0ea76438 100644
--- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs
+++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorApplicationBuilderExtensions.cs
@@ -1,49 +1,29 @@
-// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
+// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
-using Microsoft.AspNetCore.Http;
-using System.Text.Json;
+using BootstrapBlazor.Components;
namespace Microsoft.AspNetCore.Builder;
///
-///
+/// BootstrapBlazor 服务端中间件扩展
+/// BootstrapBlazor server middleware extensions.
///
public static class BootstrapBlazorApplicationBuilderExtensions
{
///
- ///
+ /// 使用 BootstrapBlazor 中间件
+ /// Use BootstrapBlazor middleware
///
- ///
- ///
+ ///
+ /// 提供客户端 IP 地址查询(/ip.axd)。
+ /// Provides client IP lookup (/ip.axd).
+ ///
public static IApplicationBuilder UseBootstrapBlazor(this IApplicationBuilder builder)
{
- // 获得客户端 IP 地址
- builder.UseWhen(context => context.Request.Path.StartsWithSegments("/ip.axd"), app => app.Run(async context =>
- {
- var ip = "";
- var headers = context.Request.Headers;
- if (headers.TryGetValue("X-Forwarded-For", out var value))
- {
- var ips = new List();
- foreach (var xf in value)
- {
- if (!string.IsNullOrEmpty(xf))
- {
- ips.Add(xf);
- }
- }
- ip = string.Join(";", ips);
- }
- else
- {
- ip = context.Connection.RemoteIpAddress.ToIPv4String();
- }
+ builder.MapWhen(context => context.Request.Path.StartsWithSegments("/ip.axd"), app => app.Run(IpRequestHandler.HandleAsync));
- context.Response.Headers.TryAdd("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json; charset=utf-8"));
- await context.Response.WriteAsync(JsonSerializer.Serialize(new { Id = context.TraceIdentifier, Ip = ip }));
- }));
return builder;
}
}
diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs
new file mode 100644
index 00000000..cdfefda0
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs
@@ -0,0 +1,56 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+using Microsoft.AspNetCore.SignalR;
+using System.Text.Json;
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 消息分发和在线连接共用的 SignalR Hub
+/// Shared SignalR Hub for dispatch and online connections.
+/// 11.0.0
+///
+///
+/// 重连后需恢复订阅。宿主可对映射端点配置认证和授权。
+/// Subscriptions must be restored after reconnecting. Hosts can configure authentication and authorization on the mapped endpoint.
+///
+public sealed class BootstrapBlazorHub(BootstrapBlazorHubOptions options, IServiceProvider services) : Hub
+{
+ ///
+ /// 尝试将当前连接加入消息类型分组,区分订阅拒绝与服务端执行失败
+ /// Attempts to join an entry type group, distinguishing subscription rejection from server execution failures.
+ ///
+ /// 消息类型标识The entry type identifier.
+ /// 订阅成功时返回 true,类型未被允许时返回 false;加入分组失败时抛出异常True when subscribed, or false when the type is disallowed. Group membership failures throw an exception.
+ public Task Subscribe(string entryType)
+ => DispatchHandler.SubscribeAsync(Context, Groups, entryType, options);
+
+ ///
+ /// 取消当前连接对指定消息类型的订阅
+ /// Unsubscribes the current connection from the specified entry type.
+ ///
+ public Task Unsubscribe(string entryType)
+ => DispatchHandler.UnsubscribeAsync(Context, Groups, entryType);
+
+ ///
+ /// 将消息分发给服务端本地订阅者和其他已订阅连接,发送端在调用成功后自行显示消息
+ /// Dispatches a message locally and to other subscribed connections. The sender displays its own message after the invocation succeeds.
+ ///
+ public Task Dispatch(string entryType, JsonElement entry)
+ => DispatchHandler.DispatchAsync(Context, entryType, entry, options, services);
+
+ ///
+ /// 上报客户端业务心跳,服务端禁用心跳时返回 false,传输层保活不能代替业务心跳
+ /// Reports an application heartbeat, returning false when heartbeats are disabled. Transport keepalive messages do not replace application heartbeats.
+ ///
+ public Task Heartbeat(ClientInfo client)
+ => ConnectionHandler.ReportHeartbeatAsync(Context, client, services);
+
+ ///
+ /// 查询在线连接快照,仅返回给调用方,不向消息订阅者广播;查询被禁用或调用方未获授权时返回 null
+ /// Queries a connection snapshot for the caller only, without broadcasting it to dispatch subscribers. Returns null when queries are disabled or the caller is not authorized.
+ ///
+ public Task GetConnectionSnapshot()
+ => ConnectionHandler.GetSnapshotAsync(Context, options, services);
+}
diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHubOptions.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHubOptions.cs
new file mode 100644
index 00000000..c14e267d
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHubOptions.cs
@@ -0,0 +1,69 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.DependencyInjection;
+using System.Text.Json;
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 共享 SignalR Hub 配置,消息类型和在线连接快照查询需显式启用
+/// Shared SignalR Hub options. Dispatch types and connection snapshot queries require explicit opt-in.
+/// 11.0.0
+///
+public sealed class BootstrapBlazorHubOptions
+{
+ private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
+
+ internal Dictionary?> EntryTypes { get; } = new(StringComparer.Ordinal);
+
+ ///
+ /// 获得/设置是否允许查询在线连接快照,快照包含客户端信息,默认关闭
+ /// Gets or sets whether connection snapshot queries are enabled. Disabled by default because snapshots contain client information.
+ /// 11.0.0
+ ///
+ public bool EnableConnectionSnapshots { get; set; }
+
+ ///
+ /// 获得/设置独立于消息订阅的快照查询授权策略;未设置时,启用查询后所有获准连接 Hub 的客户端均可查询
+ /// Gets or sets the optional snapshot authorization policy, independent of dispatch subscriptions. When omitted, enabling snapshots permits all clients authorized to connect to the Hub to query them.
+ /// 11.0.0
+ ///
+ public string? ConnectionSnapshotAuthorizationPolicy { get; set; }
+
+ ///
+ /// 允许客户端订阅指定消息类型,并可选择允许发布;不得公开包含凭据的内部 Webhook 载荷
+ /// Allows clients to subscribe to a type and optionally publish it. Internal webhook payloads containing credentials must not be exposed.
+ ///
+ ///
+ ///
+ /// 是否允许客户端发布,默认 false
+ /// Whether clients are allowed to publish. Defaults to false.
+ ///
+ public void Allow(bool allowClientPublish = false)
+ {
+ EntryTypes[typeof(TEntry).FullName ?? typeof(TEntry).Name] = allowClientPublish ? PublishAsync : null;
+ }
+
+ private static async Task PublishAsync(IServiceProvider services, JsonElement json, string connectionId, CancellationToken cancellationToken)
+ {
+ DispatchEntry? entry;
+ try
+ {
+ entry = json.Deserialize>(_serializerOptions);
+ }
+ catch (Exception ex) when (ex is JsonException or NotSupportedException)
+ {
+ throw new HubException("Invalid dispatch payload.");
+ }
+
+ if (entry == null || json.ValueKind != JsonValueKind.Object)
+ {
+ throw new HubException("Invalid dispatch payload.");
+ }
+
+ var service = (DefaultDispatchService)services.GetRequiredService>();
+ await service.DispatchAsync(entry, connectionId, cancellationToken);
+ }
+}
diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs
new file mode 100644
index 00000000..520949a8
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs
@@ -0,0 +1,75 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using System.Security.Claims;
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 在线连接心跳和快照查询处理类
+/// Handles online connection heartbeats and snapshot queries.
+///
+static class ConnectionHandler
+{
+ public static async Task ReportHeartbeatAsync(HubCallerContext context, ClientInfo client, IServiceProvider services)
+ {
+ var bootstrapOptions = services.GetRequiredService>().Value;
+ if (!bootstrapOptions.ConnectionHubOptions.Enable)
+ {
+ return false;
+ }
+
+ if (client == null || string.IsNullOrWhiteSpace(client.Id) || client.Id.Length > 128 ||
+ !Enum.IsDefined(typeof(WebClientDeviceType), client.Device))
+ {
+ throw new HubException("Invalid connection heartbeat.");
+ }
+
+ var httpContext = context.GetHttpContext() ?? throw new HubException("The connection context is unavailable.");
+ client.Ip = httpContext.Connection.RemoteIpAddress.ToIPv4String();
+ client.UserName = context.User?.Identity is { IsAuthenticated: true } identity ? identity.Name : null;
+ client.City = null;
+
+ if (bootstrapOptions.ConnectionHubOptions.EnableIpLocator)
+ {
+ var factory = services.GetRequiredService();
+ client.City = await factory.Create(bootstrapOptions.IpLocatorOptions.ProviderName).Locate(client.Ip);
+ }
+
+ context.ConnectionAborted.ThrowIfCancellationRequested();
+ await services.GetRequiredService().AddOrUpdateAsync(client, context.ConnectionAborted);
+ return true;
+ }
+
+ public static async Task GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services)
+ {
+ if (!options.EnableConnectionSnapshots)
+ {
+ // 未开启快照查询功能直接返回 null
+ return null;
+ }
+
+ if (!string.IsNullOrEmpty(options.ConnectionSnapshotAuthorizationPolicy))
+ {
+ // 如果设置了授权策略则进行授权检查
+ var authorization = services.GetRequiredService();
+ var result = await authorization.AuthorizeAsync(context.User ?? new ClaimsPrincipal(), null, options.ConnectionSnapshotAuthorizationPolicy);
+ if (!result.Succeeded)
+ {
+ return null;
+ }
+ }
+
+ var service = services.GetRequiredService();
+ return new ConnectionSnapshot
+ {
+ Count = service.Count,
+ Connections = service.Connections
+ };
+ }
+}
diff --git a/src/middleware/BootstrapBlazor.Middleware/DefaultDispatchService.cs b/src/middleware/BootstrapBlazor.Middleware/DefaultDispatchService.cs
new file mode 100644
index 00000000..eda6ef70
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/DefaultDispatchService.cs
@@ -0,0 +1,99 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 服务端本地和 SignalR 消息分发服务
+/// Server-side local and SignalR message dispatch service.
+///
+sealed class DefaultDispatchService(
+ IHubContext hub,
+ BootstrapBlazorHubOptions options,
+ ILogger> logger) : IDispatchService
+{
+ private static readonly string _entryType = typeof(TEntry).FullName ?? typeof(TEntry).Name;
+ private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web);
+ private readonly object _locker = new();
+ private readonly List _subscriptions = [];
+
+ public Task DispatchAsync(DispatchEntry entry, CancellationToken cancellationToken = default)
+ => DispatchAsync(entry, null, cancellationToken);
+
+ internal async Task DispatchAsync(DispatchEntry entry, string? excludedConnectionId, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (options.EntryTypes.ContainsKey(_entryType))
+ {
+ var clients = excludedConnectionId == null
+ ? hub.Clients.Group(_entryType)
+ : hub.Clients.GroupExcept(_entryType, [excludedConnectionId]);
+ await clients.SendAsync("Receive", _entryType, JsonSerializer.SerializeToElement(entry, _serializerOptions), cancellationToken);
+ }
+
+ Subscription[] subscriptions;
+ lock (_locker)
+ {
+ subscriptions = _subscriptions.ToArray();
+ }
+
+ foreach (var subscription in subscriptions)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ Task callbackTask;
+ lock (_locker)
+ {
+ if (!_subscriptions.Contains(subscription))
+ {
+ continue;
+ }
+
+ // Coordinate callback startup with unsubscription.
+ callbackTask = subscription.Callback(entry);
+ }
+
+ await callbackTask;
+ }
+ catch (Exception ex) when (!cancellationToken.IsCancellationRequested)
+ {
+ logger.LogError(ex, "A dispatch subscriber failed for entry type {EntryType}.", _entryType);
+ }
+ }
+ }
+
+ public Task SubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (_locker)
+ {
+ _subscriptions.Add(new Subscription(callback));
+ }
+ return Task.CompletedTask;
+ }
+
+ public Task UnSubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ lock (_locker)
+ {
+ var index = _subscriptions.FindIndex(subscription => subscription.Callback == callback);
+ if (index >= 0)
+ {
+ _subscriptions.RemoveAt(index);
+ }
+ }
+ return Task.CompletedTask;
+ }
+
+ private sealed class Subscription(Func, Task> callback)
+ {
+ public Func, Task> Callback { get; } = callback;
+ }
+}
diff --git a/src/middleware/BootstrapBlazor.Middleware/DispatchHandler.cs b/src/middleware/BootstrapBlazor.Middleware/DispatchHandler.cs
new file mode 100644
index 00000000..df0b31cb
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/DispatchHandler.cs
@@ -0,0 +1,42 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+using Microsoft.AspNetCore.SignalR;
+using System.Text.Json;
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 消息订阅和分发处理类
+/// Handles message subscriptions and dispatch.
+///
+static class DispatchHandler
+{
+ ///
+ /// 尝试订阅消息类型,未允许时返回 false,加入分组失败时传播异常
+ /// Attempts to subscribe to an entry type, returning false when disallowed and propagating group membership failures.
+ ///
+ public static async Task SubscribeAsync(HubCallerContext context, IGroupManager groups, string entryType, BootstrapBlazorHubOptions options)
+ {
+ if (!options.EntryTypes.ContainsKey(entryType))
+ {
+ return false;
+ }
+
+ await groups.AddToGroupAsync(context.ConnectionId, entryType, context.ConnectionAborted);
+ return true;
+ }
+
+ public static Task UnsubscribeAsync(HubCallerContext context, IGroupManager groups, string entryType)
+ => groups.RemoveFromGroupAsync(context.ConnectionId, entryType, context.ConnectionAborted);
+
+ public static Task DispatchAsync(HubCallerContext context, string entryType, JsonElement entry, BootstrapBlazorHubOptions options, IServiceProvider services)
+ {
+ if (!options.EntryTypes.TryGetValue(entryType, out var publish) || publish == null)
+ {
+ throw new HubException("Client publishing is not allowed for this type.");
+ }
+
+ return publish(services, entry, context.ConnectionId, context.ConnectionAborted);
+ }
+}
diff --git a/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs b/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs
index 57c8f42a..d4878ba0 100644
--- a/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs
+++ b/src/middleware/BootstrapBlazor.Middleware/InternalIPAddressExtensions.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
+// Copyright (c) Argo Zhang (argo@163.com). All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
// Website: https://www.blazor.zone or https://argozhang.github.io/
@@ -7,15 +7,15 @@
namespace Microsoft.AspNetCore.Builder;
///
-/// IPAddress 内部操作扩展类
+/// IP 地址内部操作扩展类
+/// Internal IP address extensions.
///
internal static class InternalIPAddressExtensions
{
///
- ///
+ /// 获得 IP 地址字符串,将 IPv4 映射地址转换为 IPv4 格式,空地址使用 IPv6 回环地址
+ /// Gets an IP address string, converting IPv4-mapped addresses to IPv4 format and using the IPv6 loopback address when null.
///
- ///
- ///
public static string ToIPv4String(this IPAddress? address)
{
var ipv4Address = (address ?? IPAddress.IPv6Loopback).ToString();
diff --git a/src/middleware/BootstrapBlazor.Middleware/IpRequestHandler.cs b/src/middleware/BootstrapBlazor.Middleware/IpRequestHandler.cs
new file mode 100644
index 00000000..e60e9a7b
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/IpRequestHandler.cs
@@ -0,0 +1,41 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+// Website: https://www.blazor.zone
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using System.Text.Json;
+
+namespace BootstrapBlazor.Components;
+
+///
+/// 客户端 IP 地址查询请求处理类
+/// Handles client IP address lookup requests.
+///
+static class IpRequestHandler
+{
+ public static async Task HandleAsync(HttpContext context)
+ {
+ var ip = "";
+ var headers = context.Request.Headers;
+ if (headers.TryGetValue("X-Forwarded-For", out var value))
+ {
+ var ips = new List();
+ foreach (var xf in value)
+ {
+ if (!string.IsNullOrEmpty(xf))
+ {
+ ips.Add(xf);
+ }
+ }
+ ip = string.Join(";", ips);
+ }
+ else
+ {
+ ip = context.Connection.RemoteIpAddress.ToIPv4String();
+ }
+
+ context.Response.Headers.TryAdd("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json; charset=utf-8"));
+ await context.Response.WriteAsync(JsonSerializer.Serialize(new { Id = context.TraceIdentifier, Ip = ip }));
+ }
+}
diff --git a/src/middleware/BootstrapBlazor.Middleware/ServiceCollectionExtensions.cs b/src/middleware/BootstrapBlazor.Middleware/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..5b17a014
--- /dev/null
+++ b/src/middleware/BootstrapBlazor.Middleware/ServiceCollectionExtensions.cs
@@ -0,0 +1,49 @@
+// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved.
+// Licensed under the Apache License, Version 2.0.
+
+using BootstrapBlazor.Components;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+///
+/// 服务端 SignalR 服务注册和端点映射扩展
+/// Server-side SignalR service registration and endpoint mapping extensions.
+/// 11.0.0
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// 注册实时 SignalR 分发服务
+ /// Registers the live SignalR dispatch service.
+ /// 11.0.0
+ ///
+ ///
+ /// 应在 AddBootstrapBlazor 后调用,未配置的类型仅在服务端本地分发。单服务解析使用此注册,枚举分发服务时仍包含默认实现。
+ /// Call after AddBootstrapBlazor. Unlisted types remain server-local. Single-service resolution uses this registration; enumerating dispatch services also includes the default implementation.
+ /// 消息不持久化,重连后不补发。同一 Hub 支持连接心跳,快照查询需显式启用。
+ /// Messages are not persisted or replayed after reconnecting. The same Hub accepts connection heartbeats; snapshot queries require explicit opt-in.
+ ///
+ ///
+ ///
+ public static IServiceCollection AddBootstrapBlazorHub(this IServiceCollection services, Action configure)
+ {
+ var options = new BootstrapBlazorHubOptions();
+ configure(options);
+ services.AddSingleton(options);
+ services.AddSignalR();
+ services.AddSingleton(typeof(IDispatchService<>), typeof(DefaultDispatchService<>));
+ return services;
+ }
+
+ ///
+ /// 映射共享 Hub 端点,宿主可链式调用 RequireAuthorization 配置授权
+ /// Maps the shared Hub endpoint. Hosts may chain RequireAuthorization to protect it.
+ /// 11.0.0
+ ///
+ ///
+ ///
+ public static HubEndpointConventionBuilder MapBootstrapBlazorHub(this IEndpointRouteBuilder endpoints, string pattern = "/bb.hub")
+ => endpoints.MapHub(pattern);
+}