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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ManagedDotNet.SignalR.Topics" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ManagedDotNet.SignalR.Topics" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="ManagedDotNet.SignalR.Topics" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ManagedDotNet.SignalR.Topics --version 1.0.0
                    
#r "nuget: ManagedDotNet.SignalR.Topics, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ManagedDotNet.SignalR.Topics@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ManagedDotNet.SignalR.Topics&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=ManagedDotNet.SignalR.Topics&version=1.0.0
                    
Install as a Cake Tool

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) Client-To-Server Flow
  • **Handle(topic, payload)** — server → client (listen on the client) Server-to-Client Flow

✨ 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 on AddTopicHub / HandleOnServer)
  • 🌐 MapHub parity — RequireCors, ConfigureHttpConnection, WithMetadata, RequireHost, WithDisplayName, and ConfigureEndpoint on AddTopicHub (applied by MapTopicHubs)
  • 🔌Modular setup — AddTopicHub per 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 throws MisconfiguredException (use parameterless RequireAuthorization() or AllowAnonymous()).
  • HandleOnServer / HandleOnClient / WithHandler require class types (interfaces are rejected at compile time).
  • Hub RequireAuthorization + topic AllowAnonymous still requires an authenticated connection (MapTopicHubs logs a Warning; topic AllowAnonymous only 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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