Cirreum.Runtime.Invocation.SignalR
1.1.1
dotnet add package Cirreum.Runtime.Invocation.SignalR --version 1.1.1
NuGet\Install-Package Cirreum.Runtime.Invocation.SignalR -Version 1.1.1
<PackageReference Include="Cirreum.Runtime.Invocation.SignalR" Version="1.1.1" />
<PackageVersion Include="Cirreum.Runtime.Invocation.SignalR" Version="1.1.1" />
<PackageReference Include="Cirreum.Runtime.Invocation.SignalR" />
paket add Cirreum.Runtime.Invocation.SignalR --version 1.1.1
#r "nuget: Cirreum.Runtime.Invocation.SignalR, 1.1.1"
#:package Cirreum.Runtime.Invocation.SignalR@1.1.1
#addin nuget:?package=Cirreum.Runtime.Invocation.SignalR&version=1.1.1
#tool nuget:?package=Cirreum.Runtime.Invocation.SignalR&version=1.1.1
Cirreum Runtime Invocation SignalR
Runtime Extensions package for the Cirreum SignalR invocation source.
Overview
Cirreum.Runtime.Invocation.SignalR is the L5 Runtime Extensions package that surfaces app-facing extension methods for wiring SignalR Hubs into Cirreum's unified IInvocationContext seam. It supplies three extension methods, each on the framework type that makes them most discoverable:
AddSignalRInvocation()onIHostApplicationBuilder— registers the SignalR invocation source (marker-dedup'd) and opens theIInvocationBuilderscope for per-instance Hub bindings.AddSignalR<THub>(instanceKey)onIInvocationBuilder— capturesTHubat the call site, stashes the(instanceKey, typeof(THub))pair as aSignalRHubMappingin DI for the L3 registrar to resolve.MapSignalRInvocation()onIEndpointRouteBuilder— invokes everyInvocationProviderMappingwhoseProviderNamematchesSignalRInvocationRegistrar.ProviderKey, walking enabled instances and mapping each Hub at its configured path.
Apps install this package directly. It transitively pulls the L3 Cirreum.Invocation.SignalR (registrar, settings, HubFilter, connection adapter that implements IInvocationConnection.SendAsync<T>) and the L4 Cirreum.Runtime.InvocationProvider (helper, scope object).
Architectural position
L2 Core
Cirreum.InvocationProvider ← abstractions: IInvocationContext, registrar base, ...
L3 Infrastructure
Cirreum.Invocation.SignalR ← registrar, settings, HubFilter, connection adapter
Cirreum.Invocation.WebSockets ← peer for raw WebSockets
L4 Runtime
Cirreum.Runtime.InvocationProvider ← IInvocationBuilder scope object, RegisterInvocationProvider helper
L5 Runtime Extensions
Cirreum.Runtime.Invocation.SignalR ← THIS PACKAGE — AddSignalRInvocation, AddSignalR<THub>, MapSignalRInvocation
Cirreum.Runtime.Invocation ← umbrella (AddInvocation, MapInvocation across all sources)
Mirrors the Identity track's Cirreum.Runtime.Identity.Oidc shape — same SRP-split, same per-protocol/umbrella relationship, same generic-method-with-key per-instance binding pattern.
What's in the box
| Extension | Lives on | Role |
|---|---|---|
AddSignalRInvocation(this IHostApplicationBuilder, Action<IInvocationBuilder>?) (Microsoft.Extensions.Hosting) |
IHostApplicationBuilder |
Top-level entry point. Marker-dedup'd registration of the SignalR invocation source; opens the IInvocationBuilder scope for per-instance Hub bindings. |
AddSignalR<THub>(this IInvocationBuilder, string instanceKey) (Cirreum.Invocation) |
IInvocationBuilder |
Per-instance Hub binding. Captures THub at the call site; stashes a SignalRHubMapping singleton in DI for the L3 registrar to resolve at endpoints-phase time. |
MapSignalRInvocation(this IEndpointRouteBuilder) (Microsoft.AspNetCore.Builder) |
IEndpointRouteBuilder |
Endpoints-phase entry point. Resolves SignalR-tagged InvocationProviderMapping records and invokes their deferred Map closures. Pairs naturally with ASP.NET's built-in MapHub<THub>(). |
How registration works
The AddSignalRInvocation() extension does two things:
- Marker-dedup'd: registers the SignalR invocation source by calling
builder.RegisterInvocationProvider<SignalRInvocationRegistrar, SignalRInvocationSettings, SignalRInvocationInstanceSettings>()from the L4 helper. The L4 helper:- Binds
Cirreum:Invocation:Providers:SignalRfromIConfigurationtoSignalRInvocationSettings. - Calls
registrar.Register(...)— services phase — which callsservices.AddSignalR()and registers theInvocationContextHubFilteragainst the globalHubOptions. - Stashes an
InvocationProviderMappingin DI capturing the deferredregistrar.Map(...)closure.
- Binds
- Opens the
IInvocationBuilderscope for the configure callback so apps can chainAddSignalR<THub>(instanceKey)calls per Hub-type.
Inside the configure callback, each AddSignalR<THub>(instanceKey) call:
- Captures
THubas a generic argument at the call site (this is the whole reason the per-instance call needs to be a generic method —THubcan't come from JSON). - Stashes a
SignalRHubMapping(instanceKey, typeof(THub))singleton in DI.
MapSignalRInvocation() resolves all InvocationProviderMapping records with ProviderName == SignalRInvocationRegistrar.ProviderKey and invokes their Map closures. The L3 registrar's MapSource:
- Resolves the
SignalRHubMappingfor each enabled instance from configuration. - Dispatches through reflection to
endpoints.MapHub<THub>(settings.Path)(ASP.NET ships only a generic-only overload). - Wires
RequireAuthorizationwithAuthenticationSchemes = settings.Schemeif aSchemeis set.
Configuration
SignalR exposes three configuration surfaces — Cirreum mirrors each as an explicit, named sub-section. Cirreum framework fields (Enabled, Path, Scheme) live at the instance-section root; SignalR-native options are namespaced under HubOptions and HttpOptions sub-sections so the three roles never collide.
{
"Cirreum": {
"Invocation": {
"Providers": {
"SignalR": {
"HubOptions": {
"KeepAliveInterval": "00:01:00",
"ClientTimeoutInterval": "00:02:00",
"EnableDetailedErrors": false
},
"Instances": {
"chat": {
"Enabled": true,
"Path": "/chat",
"Scheme": "oidc_primary",
"HubOptions": {
"MaximumReceiveMessageSize": 65536,
"MaximumParallelInvocationsPerClient": 4
},
"HttpOptions": {
"Transports": "WebSockets, LongPolling",
"ApplicationMaxBufferSize": 131072,
"TransportMaxBufferSize": 131072,
"LongPolling": {
"PollTimeout": "00:01:30"
},
"WebSockets": {
"CloseTimeout": "00:00:10"
}
}
},
"notifications": {
"Enabled": true,
"Path": "/notifications",
"Scheme": "oidc_primary"
}
}
}
}
}
}
}
| Sub-section | Binds to | Scope |
|---|---|---|
Providers:SignalR:HubOptions |
HubOptions (global) |
Defaults applied to every Hub in the host |
Providers:SignalR:Instances:{key}:HubOptions |
HubOptions<THub> (per-Hub) |
Per-Hub overrides for this instance's THub |
Providers:SignalR:Instances:{key}:HttpOptions |
HttpConnectionDispatcherOptions (per-mapping) |
Transport-level config for this instance's MapHub endpoint |
Precedence for Hub-level options is defaults → global HubOptions → per-Hub HubOptions<THub> — handled by ASP.NET's HubOptionsSetup<THub> automatically. HttpConnectionDispatcherOptions is per-mapping only — it has no global form in SignalR's design.
The instance key ("chat", "notifications") is what AddSignalR<THub>(instanceKey) binds against — match the call-site key to the configured instance name.
Scheme references a configured Authorization instance under Cirreum:Authorization:Providers:*:Instances:{Scheme}. Optional — leave unset for unauthenticated hubs (rare).
Why explicit sub-sections
HubOptions and HttpConnectionDispatcherOptions are different SignalR types configuring different layers (Hub method invocation vs. HTTP connection dispatch). They share zero property names today, but a flat-binding pattern would still leave intent ambiguous to readers and risk silent collisions if Microsoft ever ships overlapping properties. The explicit sub-sections make the layer split visible in the JSON itself and let JSON schemas validate each sub-section strictly.
One Hub type per instance
AddSignalR<THub>(instanceKey) allows each THub to be mapped to exactly one instance key per host. ASP.NET's HubOptions<THub> is per-Hub-type — there is no per-instance bucket — so mapping the same Hub class to multiple instance keys would silently accumulate HubOptions overrides across both sections (last write wins per property), which is confusing rather than meaningful.
To expose the same Hub at multiple paths with different settings, subclass it:
public sealed class PublicChatHub : ChatHub { }
public sealed class PrivateChatHub : ChatHub { }
builder.AddSignalRInvocation(b => b
.AddSignalR<PublicChatHub>("public")
.AddSignalR<PrivateChatHub>("private"));
Each subclass gets its own HubOptions<T> and its own DI registration; the inherited Hub method bodies are unchanged. Attempting to map the same THub twice throws InvalidOperationException with a message pointing to this workaround.
Server-initiated push
From a SignalR Hub method (or any code running inside the SignalR invocation pipeline — including Conductor command/query handlers triggered from a Hub method), push to the calling client through the ambient IInvocationContextAccessor.Current.Connection:
public sealed class ChatHub(IInvocationContextAccessor accessor) : Hub {
public async Task Echo(string text) {
var connection = accessor.Current?.Connection;
if (connection is not null) {
await connection.SendAsync("Echo", new { text, at = DateTime.UtcNow });
}
}
}
// AsyncLocal flows the invocation through Conductor — the same handler
// code can run from HTTP or SignalR; the connection is non-null only when
// there's a long-lived calling connection.
public sealed class GenerateReportHandler(
IInvocationContextAccessor accessor) : ICommandHandler<GenerateReportCommand> {
public async ValueTask<Result> Handle(GenerateReportCommand cmd, CancellationToken ct) {
var connection = accessor.Current?.Connection;
if (connection is not null) {
await connection.SendAsync("Progress", new { Percent = 0, Stage = "Loading" }, ct);
}
// ... work ...
if (connection is not null) {
await connection.SendAsync("Progress", new { Percent = 100, Stage = "Done" }, ct);
}
return Result.Success(/* ... */);
}
}
The no-method SendAsync<T>(payload) overload uses the runtime type name as the SignalR method-routing convention (e.g. SendAsync(new ChatMessage(...)) dispatches to client connection.on("ChatMessage", ...)); the keyed SendAsync<T>(method, payload) overload accepts an explicit method name. Serialization flows through SignalR's configured IHubProtocol (JSON or MessagePack — set via AddSignalR().AddJsonProtocol(...) / .AddMessagePackProtocol()).
Hub method bodies that already use the SignalR-native Clients.Caller.SendAsync(...) API are equivalent — both paths target the same SignalR pipeline.
What Connection.SendAsync does and doesn't do
The connection's typed SendAsync<T> is bound to the active invocation — it pushes to the connection that delivered the currently-executing Hub method (or downstream Conductor handler invoked from one). It is not a general server-to-client push mechanism for arbitrary connections.
| You want to | Use |
|---|---|
| Push extra messages to the client that triggered this Hub method (progress, streaming partial results, multi-message responses) | accessor.Current?.Connection?.SendAsync(...) (Cirreum-abstracted) |
Push to a different connected client by ConnectionId |
IHubContext<THub>.Clients.Client(id).SendAsync(...) (SignalR-native) |
| Broadcast to all connected clients | IHubContext<THub>.Clients.All.SendAsync(...) (SignalR-native) |
| Push to a SignalR group | IHubContext<THub>.Clients.Group(name).SendAsync(...) (SignalR-native) |
| Push from a background service, timer, or inbound webhook (no active invocation) | IHubContext<THub>.Clients.X.SendAsync(...) (SignalR-native) — the ambient connection is null when no active invocation exists |
| Push from a handler invoked via HTTP | n/a — HTTP is request/response. IInvocationContext.Connection is null for HTTP. Use the handler's return value instead. |
IHubContext<THub> is registered as a singleton by services.AddSignalR() — inject it anywhere, including code with no active invocation context. Cirreum doesn't abstract this surface because "push to arbitrary connection by id / group / all" varies wildly across long-lived transports (SignalR's rich Clients API, raw WebSocket's hand-rolled registries, gRPC streaming's one-stream-at-a-time model). The seam unifies identity, pipeline, and the calling-client reply path; transport-specific superpowers stay accessible through their native APIs.
Handlers that may run from both HTTP and SignalR
The null-check on accessor.Current?.Connection is the natural feature gate: when invoked from HTTP it's null and the push is skipped; when invoked from SignalR it's non-null and the push happens. Same handler, both transports, no special branching beyond the null check shown in the example above.
The HTTP caller gets the return value; the SignalR caller gets the progress stream and the return value.
Connection lifecycle
Implement IConnectionLifecycle (from Cirreum.Invocation.Connections) and register it in DI to receive OnConnectedAsync / OnDisconnectedAsync callbacks. The HubFilter dispatches both under a synthetic invocation scope so consumers like IUserStateAccessor work normally inside the callbacks. The DisconnectInfo parameter on OnDisconnectedAsync carries the disconnect circumstances — graceful close vs. abort, the underlying exception (if any), and a human-readable reason — populated by the L3 adapter from SignalR's optional Exception? parameter:
internal sealed class AuditConnectionLifecycle(ILogger<AuditConnectionLifecycle> logger)
: IConnectionLifecycle {
public ValueTask<bool> OnConnectedAsync(IInvocationConnection connection, CancellationToken ct) {
// Inspect connection.User, connection.ConnectionId, connection.Items, etc.
// Return false to reject the connection (the upgrade aborts; client sees normal rejection).
return ValueTask.FromResult(true);
}
public ValueTask OnDisconnectedAsync(
IInvocationConnection connection,
DisconnectInfo info,
CancellationToken ct) {
if (info.WasGraceful) {
logger.LogInformation("Connection {Id} closed cleanly", connection.ConnectionId);
} else if (info.Exception is not null) {
logger.LogWarning(info.Exception,
"Connection {Id} aborted: {Reason}", connection.ConnectionId, info.Reason);
}
return ValueTask.CompletedTask;
}
}
Per-transport mapping for DisconnectInfo: SignalR's Exception? parameter from OnDisconnectedAsync(HubLifetimeContext, Exception?) populates as WasGraceful = exception is null, Exception = exception, Reason = exception?.Message.
Deployment — works with Azure SignalR Service
Yes, this package works transparently with Azure SignalR Service. Azure SignalR Service is a transport-routing layer underneath SignalR — clients connect to Azure's edge, which forwards messages to your app server. Your Hub classes, IHubFilter, IHubContext<THub>, and Cirreum's invocation seam all use the same public SignalR contracts in either deployment model, so the same Cirreum-wired Hubs run unchanged on either.
The only difference is one extra service registration — the standard Microsoft AddAzureSignalR() extension from the Microsoft.Azure.SignalR package:
var builder = DomainApplication.CreateBuilder(args);
builder.AddSignalRInvocation(b => b
.AddSignalR<ChatHub>("chat"));
// Wire Azure SignalR Service. Connection string flows through the standard
// IConfiguration chain — Cirreum.Secrets.Azure transparently resolves from
// KeyVault / env vars / user secrets / appsettings.
builder.Services.AddSignalR()
.AddAzureSignalR(builder.Configuration.GetConnectionString("Azure:SignalR"));
using var app = builder.Build<MyDomainMarker>();
app.UseDefaultMiddleware();
app.MapSignalRInvocation();
await app.RunAsync();
Everything else — InvocationContextHubFilter publishing IInvocationContext per Hub method invocation, SignalRConnection materialization, IInvocationConnection.SendAsync for in-invocation push, IConnectionLifecycle for connect/disconnect callbacks, IHubContext<THub> for out-of-band push — keeps working identically. The Cirreum framework code doesn't know or care whether SignalR is self-hosted or routed through Azure SignalR Service.
There is intentionally no separate Cirreum.Invocation.SignalR.Azure package. Azure SignalR Service is a deployment topology, not a different SignalR implementation — the same Cirreum SignalR L3+L5 packages serve both. We add per-implementation Cirreum packages only when there's framework-specific integration code to write (the Authorization track has separate Oidc / Entra / External packages because OIDC and Entra have genuinely different claim shapes, trust roots, and validation paths to abstract). For Azure SignalR Service vs. self-hosted SignalR, Microsoft's own API already abstracts the difference.
Dependencies
- Cirreum.Runtime.InvocationProvider
1.1.0+— L4 helper (IInvocationBuilderscope object,RegisterInvocationProvider<>helper,InvocationProviderMappingrecord) - Cirreum.Invocation.SignalR
1.2.0+— L3 registrar, settings,HubFilter, connection adapter (with typedSendAsync<T>overloads),SignalRInvocationRegistrar.ProviderKeyconst - Microsoft.AspNetCore.App (framework reference) — SignalR (
Microsoft.AspNetCore.SignalR), endpoint routing
Versioning
Follows Semantic Versioning. Major bumps are coordinated with the L3 Cirreum.Invocation.SignalR and the L2 Cirreum.InvocationProvider packages.
License
MIT — see LICENSE.
Cirreum Foundation Framework
Layered simplicity for modern .NET
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- Cirreum.Invocation.SignalR (>= 1.2.1)
- Cirreum.Runtime.InvocationProvider (>= 1.2.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Cirreum.Runtime.Invocation.SignalR:
| Package | Downloads |
|---|---|
|
Cirreum.Runtime.Invocation
Runtime Extensions umbrella package for the Cirreum Invocation source family. Composes Cirreum.Runtime.Invocation.SignalR and Cirreum.Runtime.Invocation.WebSockets, and exposes app-facing AddInvocation() / MapInvocation() extensions that register all sources and map all endpoints. |
GitHub repositories
This package is not used by any popular GitHub repositories.