ManagedDotNet.SignalR.Topics
1.0.0
dotnet add package ManagedDotNet.SignalR.Topics --version 1.0.0
NuGet\Install-Package ManagedDotNet.SignalR.Topics -Version 1.0.0
<PackageReference Include="ManagedDotNet.SignalR.Topics" Version="1.0.0" />
<PackageVersion Include="ManagedDotNet.SignalR.Topics" Version="1.0.0" />
<PackageReference Include="ManagedDotNet.SignalR.Topics" />
paket add ManagedDotNet.SignalR.Topics --version 1.0.0
#r "nuget: ManagedDotNet.SignalR.Topics, 1.0.0"
#:package ManagedDotNet.SignalR.Topics@1.0.0
#addin nuget:?package=ManagedDotNet.SignalR.Topics&version=1.0.0
#tool nuget:?package=ManagedDotNet.SignalR.Topics&version=1.0.0
TopicHubs (ManagedDotNet.SignalR.Topics)
Topic-based SignalR hubs: route by string topic, handle with DI-registered command handlers, serialize per message type.
Communication flow
Two directions, two configs:
| Direction | Client / server call | Registration |
|---|---|---|
| Client → server | Handle(topic, payload) |
HandleOnServer — inbound topic → deserializer → handler |
| Server → client | Handle(topic, payload) |
HandleOnClient — outbound message type → topic → serializer |
**Handle(topic, payload)**— client → server (library hub method)**Handle(topic, payload)**— server → client (listen on the client)
✨ Features
- 📫 Topic-based hubs — type-safe bindings between topics and message types for incoming/outgoing messages
- 🧩 Decoupled message handling — Native MediatR-style command handlers keep business logic (application layer) isolated from hub logic (infrastructure)
- 🛠️ Custom (de)serializers — per message type, or default
System.Text.Json(JsonSerializerDefaults.Web: camelCase, case-insensitive) - 🔐 Authorization — hub-level and topic-level via
RequireAuthorization/AllowAnonymous(fluent onAddTopicHub/HandleOnServer) - 🌐 MapHub parity —
RequireCors,ConfigureHttpConnection,WithMetadata,RequireHost,WithDisplayName, andConfigureEndpointonAddTopicHub(applied byMapTopicHubs) - 🔌Modular setup —
AddTopicHubper module;MapTopicHubs()validates bindings and maps all hubs on the host
📦 Installation
dotnet add package ManagedDotNet.SignalR.Topics
Namespaces are ManagedDotNet.SignalR.Topics.*.
🏁 Quick Start
1. Register hubs
OrderBookHub — client sends a symbol on subscribe / unsubscribe, or JSON { "Reason": "..." } on terminate (Administrator only); server pushes OrderBookUpdate on update and a plain-string alert on connect:
services.AddTopicHub<OrderBookHub>("/orderBook")
.RequireAuthorization()
.HandleOnServer<SubscribeToSymbolCommand>(cfg =>
cfg.WithTopic("subscribe")
.RequireAuthorization(new AuthorizeAttribute { Roles = "User,Administrator" })
.WithDeserializer(str => new SubscribeToSymbolCommand
{
Symbol = str.Trim().ToUpper()
})
.WithHandler<SubscribeToSymbolHubCommandHandler>())
.HandleOnServer<UnsubscribeFromSymbolCommand>(cfg =>
cfg.WithTopic("unsubscribe")
.RequireAuthorization(new AuthorizeAttribute { Roles = "User,Administrator" })
.WithDeserializer(str => new UnsubscribeFromSymbolCommand
{
Symbol = str.Trim().ToUpper()
})
.WithHandler<UnsubscribeFromSymbolHubCommandHandler>())
.HandleOnServer<TerminateCommand>(cfg =>
cfg.WithTopic("terminate")
.RequireAuthorization(new AuthorizeAttribute { Roles = "Administrator" })
.WithHandler<TerminateHubCommandHandler>())
.HandleOnClient<ConnectionAlert>(cfg =>
cfg.WithTopic("alert")
.WithSerializer(alert => alert!.Message))
.HandleOnClient<OrderBookUpdate>(cfg =>
cfg.WithTopic("update")
.WithSerializer(update => JsonSerializer.Serialize(update)));
services.AddHostedService<OrderBookUpdateJob>();
// and within other modules
// services.AddTopicHub<SomeOtherModuleHub>("/SomeOtherModuleHub")
and of course :
builder.Services.AddSignalR();
2. Create the hub
public class OrderBookHub : TopicHub
{
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
ConnectionAlert alert = new ConnectionAlert
{
Message = "Welcome! You are connected to the order book hub."
};
// Inside the hub: use Clients (no ITopicHubContext inject)
await Clients.Caller.Handle(alert);
}
}
3. Create the handler
IHubCommandHandler<T> is registered automatically via .WithHandler<T>().
Inject ITopicHubContext<OrderBookHub> to access groups and reply to hub clients
public class SubscribeToSymbolHubCommandHandler : IHubCommandHandler<SubscribeToSymbolCommand>
{
private readonly ITopicHubContext<OrderBookHub> _hubContext;
public SubscribeToSymbolHubCommandHandler
(
ITopicHubContext<OrderBookHub> hubContext
)
{
_hubContext = hubContext;
}
public async Task Handle(SubscribeToSymbolCommand request, HubCallerContext context, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.Symbol))
throw new ArgumentException("Symbol is null or empty");
string? group = Symbols.GetAll()
.FirstOrDefault(s => s.Equals(request.Symbol, StringComparison.OrdinalIgnoreCase));
if (group == null)
throw new Exception($"Symbol not found: {request.Symbol}");
await _hubContext.Groups.AddToGroupAsync(context.ConnectionId, group, cancellationToken);
}
}
public class TerminateHubCommandHandler : IHubCommandHandler<TerminateCommand>
{
private readonly IHostApplicationLifetime _lifetime;
public TerminateHubCommandHandler
(
IHostApplicationLifetime lifetime
)
{
_lifetime = lifetime;
}
public Task Handle(TerminateCommand request, HubCallerContext context, CancellationToken cancellationToken)
{
// request.Reason is required — soft-stop the host
_lifetime.StopApplication();
return Task.CompletedTask;
}
}
4. Map hubs
Call once after all AddTopicHub registrations — validates every HandleOnServer / HandleOnClient binding (WithTopic, WithHandler, …), then maps every registered hub (replaces multiple MapHub<T> calls). Fluent options from AddTopicHub (auth, CORS, connection options, endpoint conventions) are applied here:
app.UseEndpoints(endpoints =>
{
endpoints.MapTopicHubs();
});
Authorization
Two layers (both must pass when both are set — AND):
| Layer | How you configure it | When it runs |
|---|---|---|
| Hub (connection) | Fluent on AddTopicHub, same shape as MapHub(...).RequireAuthorization(...) |
MapTopicHubs → endpoint conventions |
| Topic (inbound message) | Fluent on HandleOnServer: RequireAuthorization / AllowAnonymous |
HubCommandDispatcher before deserialize |
Hub (fluent)
services.AddTopicHub<OrderBookHub>("/orderBook")
.RequireAuthorization() // default policy
// .RequireAuthorization("TradingPolicy") // named policy
// .RequireAuthorization("TradingPolicy", "AdminPolicy") // multiple named policies
// .RequireAuthorization(new AuthorizeAttribute { Roles = "User,Administrator" })
// .RequireAuthorization(new AuthorizationPolicyBuilder().RequireRole("User").Build())
// .RequireAuthorization(b => b.RequireRole("User").RequireClaim("scope", "orders"))
// .AllowAnonymous()
// MapHub parity (see Endpoint conventions):
// .RequireCors() // default CORS policy
// .RequireCors("SignalRPolicy") // named CORS policy
// .RequireCors(p => p.WithOrigins("https://app.example").AllowAnyHeader().AllowAnyMethod())
// .ConfigureHttpConnection(o => { o.Transports = HttpTransportType.WebSockets; })
// .WithDisplayName("Order book")
// .RequireHost("localhost:5005")
// .WithMetadata(new MyMetadata())
// .ConfigureEndpoint(hub => hub.WithMetadata(...)) // escape hatch
.HandleOnServer<SubscribeToSymbolCommand>(cfg =>
cfg.WithTopic("subscribe")
.RequireAuthorization(new AuthorizeAttribute { Roles = "User,Administrator" })
.WithHandler<SubscribeToSymbolHubCommandHandler>());
Same overloads as MapHub(...).RequireAuthorization(...). [Authorize] on the hub class itself is still honored by SignalR and is not merged or overridden by fluent — if both are present, both apply.
Topic (fluent on HandleOnServer)
.HandleOnServer<SubscribeToSymbolCommand>(cfg =>
cfg.WithTopic("subscribe")
.RequireAuthorization() // default policy
// .RequireAuthorization("TradingPolicy")
// .RequireAuthorization(new AuthorizeAttribute { Roles = "User,Administrator" })
.WithHandler<SubscribeToSymbolHubCommandHandler>())
.HandleOnServer<SomePublicCommand>(cfg =>
cfg.WithTopic("public")
.AllowAnonymous()
.WithHandler<SomePublicCommandHandler>())
Topic overloads: RequireAuthorization(), named policies (string[]), and IAuthorizeData (e.g. AuthorizeAttribute with Roles). Hub-only: AuthorizationPolicy / policy-builder overloads.
Handler type [Authorize] / [AllowAnonymous] attributes are not read — use fluent APIs only.
Footguns:
RequireAuthorization(params …)with an empty array throwsMisconfiguredException(use parameterlessRequireAuthorization()orAllowAnonymous()).HandleOnServer/HandleOnClient/WithHandlerrequireclasstypes (interfaces are rejected at compile time).- Hub
RequireAuthorization+ topicAllowAnonymousstill requires an authenticated connection (MapTopicHubslogs a Warning; topicAllowAnonymousonly skips topic-level auth).
Endpoint conventions (MapHub parity)
Configure on AddTopicHub (not after MapTopicHubs). MapTopicHubs applies them when mapping each hub.
| Region | Fluent API | MapHub equivalent |
|---|---|---|
| CORS | RequireCors() / RequireCors(name) / RequireCors(configurePolicy) |
.RequireCors(...) |
| Connection | ConfigureHttpConnection(configureOptions) |
MapHub(path, configureOptions) |
| Endpoint conventions | WithMetadata, RequireHost, WithDisplayName, ConfigureEndpoint |
chained builder methods / escape hatch |
services.AddTopicHub<OrderBookHub>("/orderBook")
.RequireAuthorization()
.RequireCors("SignalRPolicy") // named CORS policy
// .RequireCors() // default CORS policy
// .RequireCors(p => p.WithOrigins("https://app.example").AllowAnyHeader().AllowAnyMethod())
.ConfigureHttpConnection(o =>
{
o.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
})
.WithDisplayName("Order book")
// .RequireHost("localhost:5005")
// .WithMetadata(new MyMetadata())
// .ConfigureEndpoint(hub => hub.WithMetadata(...)) // anything else on HubEndpointConventionBuilder
.HandleOnServer<SubscribeToSymbolCommand>(cfg =>
cfg.WithTopic("subscribe")
.WithHandler<SubscribeToSymbolHubCommandHandler>());
Still register CORS in the host (AddCors / UseCors) the same way you would with MapHub. Last RequireCors call wins.
Messaging API
Outside the hub (controller / job / service)
Same inject as handlers: ITopicHubContext<THub> — not IHubContext<THub>.
public class OrderBookUpdateJob : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
public OrderBookUpdateJob
(
IServiceProvider serviceProvider
)
{
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using IServiceScope scope = _serviceProvider.CreateScope();
ITopicHubContext<OrderBookHub> hubContext =
scope.ServiceProvider.GetRequiredService<ITopicHubContext<OrderBookHub>>();
OrderBookUpdate update = new OrderBookUpdate
{
Symbol = "BTC/USDT",
Price = 75_000m
};
await hubContext.Clients.Group(update.Symbol).Handle(update);
}
}
}
Inside the hub
No injection : invoke Clients.Caller.Handle(alert);
public class OrderBookHub : TopicHub
{
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
ConnectionAlert alert = new ConnectionAlert
{
Message = "Welcome! You are connected to the order book hub."
};
// Inside the hub: use Clients (no ITopicHubContext inject)
await Clients.Caller.Handle(alert);
}
}
Client (C#)
Wire signatures (always string topic + string payload):
| Direction | Method name | Signature the client uses |
|---|---|---|
| Listen (server → client) | "Handle" |
void Handle(string topic, string payload) via HubConnection.On<string, string> |
| Call (client → server) | "Handle" |
Task Handle(string topic, string message) via InvokeAsync |
When the hub uses RequireAuthorization(), pass a JWT (demo: AuthService.CreateToken):
string token = AuthService.CreateToken("trader", AuthService.Roles.User);
HubConnection orderBook = new HubConnectionBuilder()
.WithUrl("http://localhost:5005/orderBook", o =>
o.AccessTokenProvider = () => Task.FromResult<string?>(token))
.Build();
// Must match: Handle(string topic, string payload)
orderBook.On<string, string>("Handle", async (string topic, string payload) =>
{
switch (topic)
{
case "alert":
// WithSerializer sends plain text (ConnectionAlert.Message), not JSON
Console.WriteLine($"[alert] {payload}");
break;
case "update":
OrderBookUpdate? update = JsonSerializer.Deserialize<OrderBookUpdate>(payload);
if (update is null || string.IsNullOrWhiteSpace(update.Symbol))
return;
Console.WriteLine($"{update.Symbol}: {update.Price}");
break;
default:
Console.WriteLine($"Unknown topic: {topic}");
break;
}
});
await orderBook.StartAsync();
// Must match: Handle(string topic, string message)
await orderBook.InvokeAsync("Handle", "subscribe", "BTC/USDT");
await orderBook.InvokeAsync("Handle", "unsubscribe", "BTC/USDT");
// Administrator-only topic — JSON TerminateCommand { Reason }
await orderBook.InvokeAsync("Handle", "terminate",
JsonSerializer.Serialize(new TerminateCommand { Reason = "demo complete" }));
Requirements
- .NET 8.0 or later
- Microsoft.AspNetCore.SignalR
License
MIT
Examples
See [/examples](../examples/README.md) for the OrderBook modular server + C# client. Start App, then CSharpClient.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 96 | 9/20/2026 |