G9SignalRSuperNetCore.Client
2.4.0
See the version list below for details.
dotnet add package G9SignalRSuperNetCore.Client --version 2.4.0
NuGet\Install-Package G9SignalRSuperNetCore.Client -Version 2.4.0
<PackageReference Include="G9SignalRSuperNetCore.Client" Version="2.4.0" />
<PackageVersion Include="G9SignalRSuperNetCore.Client" Version="2.4.0" />
<PackageReference Include="G9SignalRSuperNetCore.Client" />
paket add G9SignalRSuperNetCore.Client --version 2.4.0
#r "nuget: G9SignalRSuperNetCore.Client, 2.4.0"
#:package G9SignalRSuperNetCore.Client@2.4.0
#addin nuget:?package=G9SignalRSuperNetCore.Client&version=2.4.0
#tool nuget:?package=G9SignalRSuperNetCore.Client&version=2.4.0
G9SignalRSuperNetCore
G9SignalRSuperNetCore is a strongly-typed, AOT-friendly, scale-aware wrapper around ASP.NET Core SignalR that lets you build real-time hubs and clients with less ceremony and more safety.
It bundles the things most SignalR projects end up reinventing — typed proxies, JWT authentication, per-user session state, declarative policy attributes (rate limit, connection limit, role/claim guards, telemetry), resumable file upload with progress and SHA-256 verification — and ships them as opt-in, AOT-safe, allocation-conscious primitives.
Drop reflection-based runtime proxies, get a build-time-generated typed client. Drop static per-process state, get a pluggable session store. Add
[G9AttrRateLimit]to a method and you're rate-limited; add[G9AttrTelemetry]and you have OpenTelemetry traces. Keep the SignalR programming model you already know.
Table of contents
- What's new
- Why this library
- Packages
- MAUI and NativeAOT support
- Architecture overview
- Getting started
- Quick sample (no auth)
- Sample with JWT authentication
- Sample with sessions
- Sample with JWT + sessions (recommended)
- Pluggable session store
- Auto-generated typed client
- Client features
- Policy attributes
- Resumable file upload
- Resumable file download
- Groups and presence
- Server streams with backpressure
- Resilient client reconnect
- App-level encryption (no TLS required)
- Distributed backplane (interface)
- Telemetry, metrics, and stable error codes
- JWT helper
- Scaling to many connections
- Thread safety contract
- Console test harness (Consolonia)
- Build and test
- Project layout
- Migration guide
- Roadmap
- Contributing
- License
What's new
2.2 / 2.3 / 2.4 — Groups & presence, streaming & resilience, distributed & secure
This release closes out Bundles 3, 4, and 5 in a single combined drop.
Bundle 3 — Groups & presence
G9CGroupManager<THub>— strongly-typed in-process group index built on top of SignalR's group machinery. Lock-free membership throughConcurrentDictionary<string, ConcurrentDictionary<string, byte>>, snapshots viaToArray, automatic prune of empty groups. Resolved throughservices.AddG9SignalRSuperNetCoreGroups<THub>().G9CPresenceTracker— per-user connection counts in a singleConcurrentDictionary<string, int>.OnConnected/OnDisconnectedare lock-free; transitions between online and offline are published to aChannel<G9DtPresenceEvent>so a hosted service or any subscriber can fan them out. Resolved throughservices.AddG9SignalRSuperNetCorePresence().[G9AttrPresenceTracked](class-level) — opt-in flag the central hub filter reads on connect/disconnect to invoke the tracker. Hubs without the attribute pay no extra work.[G9AttrAutoJoinGroup("name")](class-level, repeatable) — every new connection is added to the listed groups beforeOnConnectedAsyncruns. Implemented as a hub-typedG9CAutoJoinFilter<THub>, registered automatically when you callAddG9SignalRSuperNetCoreGroups<THub>(). AOT-safe (the generic argument is resolved at compile time).- The sample
ChatHubcarries[G9AttrPresenceTracked]plus[G9AttrAutoJoinGroup("lobby")], and exposesJoinRoom,LeaveRoom,SendToRoom,ListRoomMembers,ListOnlineUsers. The console harness has a Rooms tab that exercises every method.
Bundle 4 — Streaming & resilience
G9CResilientStream.Create<T>(options)— bounded producer/consumer pair that completes the channel deterministically (avoids the canonical SignalR streaming footgun where a thrown exception leaves the channel hanging open).G9DtStreamOptions— capacity plus drop policy:Wait,DropNewest,DropOldest.[G9AttrStreamBackpressure(capacity, dropPolicy)]— declarative backpressure metadata. Hub authors can read it back through reflection (GetCustomAttribute<…>().ToOptions()) when constructing the stream.G9CClientReconnectPolicy(client) — drop-in replacement forWithAutomaticReconnect()with exponential backoff plus ±15% jitter, configurable max delay, max elapsed time. Wired by default into the generated client base.- The sample
ChatHubexposesLiveTickerStream(count, intervalMs, ct)(server→clientIAsyncEnumerable<int>) decorated with[G9AttrStreamBackpressure(64, Wait)], plusBulkCounterStream(IAsyncEnumerable<int>)for client→server. The console harness has a Streaming tab with Start/Stop ticker plus a Push 100 → server button (expected total 5050).
Bundle 5 — Distributed & secure (no-TLS encryption)
G9CHandshake— managed ECDH P-256 + HKDF-SHA-256 + ChaCha20-Poly1305. PureSystem.Security.Cryptography: AOT-safe, FIPS-validated on every supported platform, hardware-accelerated on x86-AESNI / Arm-NEON. Why P-256 not X25519/Noise IK: the BCL ships P-256 ECDHE throughECDiffieHellmanand a vettedChaCha20Poly1305; pulling in third-party crypto would defeat the AOT/MAUI promise, and ECDHE-static + AEAD gives equivalent security guarantees.- 65-byte SEC1 uncompressed wire format for public keys, 12-byte nonce + ciphertext + 16-byte tag for sealed envelopes.
G9CSessionSealer— per-connection session-key cache. Once a connection has performedBeginSession, hub methods canSeal/Openpayloads without round-tripping the ephemeral public key on every call. Keys are zeroed on disconnect by the central hub filter.[G9AttrEncrypted]— declarative contract on hub methods that work inbyte[]envelopes.IG9DistributedBackplane— minimal publish/subscribe abstraction so the in-process group manager and presence tracker can be replaced with a Redis or NATS implementation when scaling out. Default registration is a no-opG9CInProcessBackplanefor single-process deployments. SignalR's own Redis backplane handles message fan-out; this interface handles the G9-specific membership state.- The sample
ChatHubexposesGetServerPublicKey(),EncryptedEcho(byte[] ephemeralPub, byte[] envelope)(one-shot),BeginSession(byte[] ephemeralPub)plusEncryptedEchoSession(byte[] envelope)(cached). The console harness has an Encrypted tab that runs the full round-trip and prints the SHA-256 fingerprint of the server's static public key.
Resumable downloads (symmetric to upload)
The 2.1 upload pipeline now has a peer for the other direction.
G9CFileDownloader(client) —DownloadAsync(serverFileName, localTargetPath, progress, ct). Resumes from a local.partialfile, verifies the SHA-256 the server reported inBeginDownload, atomically renames on commit. Idempotent fast path: if a fully-downloaded committed file already matches the server's hash, the call returnsCompletedwithout re-streaming.BeginDownloadAsync/StreamFileAsynconIG9UploadService— server-side.BeginDownloadAsyncrejects path traversal, caches the SHA-256 keyed by(path, length, lastWriteUtc)so it isn't recomputed on every begin call, and caps the chunk size at 4 MB.StreamFileAsyncreturnsIAsyncEnumerable<byte[]>for SignalR's documented server→client streaming pattern.- New error codes on the client side:
G9_DOWNLOAD_NOT_FOUND,G9_DOWNLOAD_HASH_MISMATCH,G9_DOWNLOAD_FAILED.
Console harness — File transfer tab
The former File-upload tab is now File transfer with both directions:
- Upload buttons:
[ Upload ],[ Upload then break ](cancels at 8 MB to simulate a drop),[ Resume upload ]. - Download buttons:
[ Download ],[ Download then break ],[ Resume download ]. - Two progress bars — one for each direction — with throughput and elapsed time.
- A single shared transfer log so retries, breaks, and resumes are visible together.
2.1 — Policy attributes + resumable file upload
Version 2.1 adds the production-grade policy surface and a complete resumable file-upload pipeline. Every new feature is opt-in; if you don't apply an attribute or call a helper, runtime cost is zero.
Declarative policy attributes (Bundle 2):
[G9AttrRateLimit(perSecond: 5, burst: 10)]— per-(connection, method) lock-free token bucket. Rejects withG9_RATE_LIMITEDand emits a metric.[G9AttrConnectionLimit(perUser: 5, perIp: 50)]— class-level cap on simultaneous connections. Rejects withG9_CONNECTION_LIMITbeforeOnConnectedAsyncreturns.[G9AttrConnectionRequired]— fail-fast guard for methods that should not run on aborted connections.[G9AttrRequireRole("admin")]/[G9AttrRequireRole("a","b")]— shorthand for role-gated methods, returningG9_ROLE_REQUIRED.[G9AttrRequireClaim("scope", "chat:write")]— claim-gated methods, returningG9_CLAIM_REQUIRED.[G9AttrTelemetry]/[G9AttrTelemetry("name")]— emitsActivitySourcespans (G9SignalRSuperNetCore) with connection id, user id, and outcome.
Stable error codes (G9CErrorCodes): G9_RATE_LIMITED, G9_CONNECTION_LIMIT, G9_CONNECTION_REQUIRED, G9_ROLE_REQUIRED, G9_CLAIM_REQUIRED, G9_UPLOAD_TOO_LARGE, G9_UPLOAD_HASH_MISMATCH, G9_UPLOAD_UNKNOWN_ID, G9_UPLOAD_FAILED. Clients switch on HubException.Message to render localized errors or trigger recovery.
Built-in metrics through System.Diagnostics.Metrics on the G9SignalRSuperNetCore meter: g9.signalr.rate_limited_invocations, g9.signalr.connection_limit_rejections, g9.signalr.authorization_rejections. Subscribe with OpenTelemetry's AddMeter("G9SignalRSuperNetCore").
Resumable file upload (real, not vapourware):
IG9UploadService+G9CUploadServiceon the server. Append-only writes, per-id semaphore for concurrency, atomic commit at SHA-256 verify, configurableRootDirectory/MaxBytes/PartialTtl/AckEveryNChunks.G9CFileUploaderon the client. Computes deterministic upload id from(path, length, lastWriteUtc), hashes the file once, callsBeginUploadto learn the resume offset, streams the remainder via SignalR client streaming. Exposes a unifiedIProgress<G9DtUploadClientProgress>with locally-countedBytesSent, server-acknowledgedBytesAcknowledged, throughput, and elapsed time. Auto-retries on transient failures with exponential backoff up toMaxRetries.- Safe resume on disconnect by design — the partial file is preserved, the next
UploadAsynccall re-runsBeginUpload, gets the new offset, seeks the source file, and continues. No duplicate bytes, no corruption. - Tamper-detection — declared SHA-256 is verified before the partial is committed; mismatch deletes the partial and returns
G9_UPLOAD_HASH_MISMATCH.
Generator improvements:
- Detects
IAsyncEnumerable<byte[]>(andIAsyncEnumerable<T>more generally) parameters as client streams and emits the rightInvokeCoreAsyncsignature. - Handles
ValueTaskandValueTask<T>return types throughout. - Threads
CancellationTokenparameters into the generated proxy without you wiring it.
Tabbed Consolonia console test harness:
- Five tabs: Connection, Chat, Recent messages, File upload, Rate-limit test.
- Connect/disconnect, send/receive chat with the typed
Serverproxy, fetch recent messages, upload a file with live progress bar (local + server-acked) and a Cancel button that resumes cleanly, run a 100-message burst that demonstrates the rate limiter rejecting calls withG9_RATE_LIMITED.
2.0 — Foundation hardening (Sept 2026)
Version 2.0 was the hardening release focused on correctness at scale and platform reach.
- MAUI and NativeAOT ready. Every library project declares
IsAotCompatible=trueandEnableTrimAnalyzer=true. Castle DynamicProxy is gone; the typedServerproxy is produced by a true RoslynIIncrementalGeneratorat compile time. Publishes cleanly underPublishAot=truewith no trim warnings attributable to G9 code. - No reflection on the client hot path. Server-method invocations and listener registrations are concrete typed calls; no
MethodInfo.Invoke, no boxing of return values. - Pluggable session storage.
IG9SessionStore<TSession>abstraction. The defaultG9CInMemorySessionStore<TSession>is registered through DI; swap it for a distributed implementation to scale across processes. - Lock-free, race-free session counters.
G9ASession.ConnectionCountsandLastActivityDateTimeare mutated throughInterlockedonly. - Encapsulated JWT route registry. Underscore-prefixed static dictionary on
G9GetJwtHubis replaced by a proper internal registry.JwtSecurityTokenHandleris reused process-wide. - Cleaner scale-out path. No correctness-critical state in static fields. The same hub source code runs unchanged behind a Redis backplane or Azure SignalR Service.
- Warning-clean Release build. Zero AOT, trim, or compiler warnings attributable to G9 code.
Why this library
Plain ASP.NET Core SignalR is excellent, but most teams end up writing the same plumbing: a typed proxy for server methods, a registration block for client callbacks, a JWT pipeline that plays nicely with WebSockets, a per-user session, a code-gen step so client and server never drift, and (eventually) rate limits, connection caps, telemetry hooks, and a chunked file-upload protocol. G9SignalRSuperNetCore covers all of that:
- Strongly-typed server hubs through a generic
Hub<TClientInterface>base. - Strongly-typed client proxy generated at compile time — call
client.Server.MyMethod(...)directly. - Listener wiring with no reflection — generated typed
Connection.On<...>calls match your interface. - JWT authentication out of the box — separate auth route exchanges credentials for a token, then the protected hub uses
[Authorize]. - Per-user session abstraction — thread-safe counters, last-activity tracking, cleanup helpers, swappable backend.
- Declarative policy attributes — rate limiting, connection caps, role/claim guards, telemetry. Opt-in, lock-free, zero cost when unused.
- Resumable file upload — chunk-streamed, SHA-256-verified, append-only, with live progress and safe resume on disconnect.
- Scale-out friendly — no static per-process state on the correctness path.
- MAUI and NativeAOT friendly — every hot path free of
Reflection.Emitand runtime proxy generation.
Packages
| Package | Purpose |
|---|---|
G9SignalRSuperNetCore.Server |
Hub bases, JWT pipeline, session abstraction, attributes, hub filter, file-upload service, embedded source generator |
G9SignalRSuperNetCore.Client |
Typed client bases (anonymous + JWT), resumable file uploader, progress DTOs |
The source generator ships inside the Server package under analyzers/dotnet/cs. Consumers don't need a separate code-gen package; just reference G9SignalRSuperNetCore.Server (server) and G9SignalRSuperNetCore.Client (client) and the typed client lights up automatically.
All packages target .NET 10.0 and are AOT-compatible and trim-safe.
MAUI and NativeAOT support
The library is designed for iOS, Android, MacCatalyst, Windows, and NativeAOT publishes from day one.
- No
Reflection.Emit. The Castle DynamicProxy runtime proxy is gone. The typedServerproxy is concrete code emitted by the Roslyn incremental source generator at build time. - No reflection on the client hot path. Listener registrations are typed
Connection.On<...>(...)calls in generated code. Server-method invocations go straight toHubConnection.InvokeCoreAsync/SendCoreAsync/StreamAsyncCore. - Trim and AOT analyzers enabled. Every library project sets
IsAotCompatible=true,EnableTrimAnalyzer=true, andIsTrimmable=true. Public APIs that capture interface generic parameters propagate[DynamicallyAccessedMembers]. - Source-generated JSON. The upload service uses
JsonSerializerContextsource generation for its metadata files, so the AOT publish has no reflection-based JSON path. - Single intrinsic AOT requirement. ASP.NET Core SignalR's typed
Hub<T>base requires dynamic code at runtime to materialize its strongly-typed client proxy on the server side. Hub base constructors are annotated with[RequiresDynamicCode]so the build cleanly surfaces this in any AOT-published server. Client-side AOT is unaffected.
To publish a MAUI client in Release with AOT:
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-ios' or '$(TargetFramework)' == 'net10.0-maccatalyst'">
<PublishAot>true</PublishAot>
<TrimMode>full</TrimMode>
</PropertyGroup>
Then dotnet publish -f net10.0-ios -c Release should complete without trim or AOT warnings attributable to G9 code.
Server-side AOT publish is supported, but ASP.NET Core SignalR itself emits trim/AOT warnings for typed hub proxies; the library's annotations make those warnings visible to you instead of hiding them.
Architecture overview
┌──────────────────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ G9AHubBase<THub, TClient> │
│ ├─ G9AHubBaseWithJWTAuth<THub, TClient> │
│ ├─ G9AHubBaseWithSession<THub, TClient, TSession> │
│ └─ G9AHubBaseWithSessionAndJWTAuth<THub, TClient, TSession> │
│ │
│ ┌───────── Policy & telemetry ─────────┐ │
│ │ G9CHubFilter (singleton, lock-free) │ enforces: │
│ │ • G9CTokenBucket per (conn,method) │ [G9AttrRateLimit] │
│ │ • G9CConnectionCounter per user/IP │ [G9AttrConnectionLimit] │
│ │ • role/claim guards │ [G9AttrRequireRole / Claim] │
│ │ • ActivitySource spans │ [G9AttrTelemetry] │
│ │ • G9CTelemetry.Meter (metrics) │ │
│ └──────────────────────────────────────┘ │
│ │
│ IG9SessionStore<TSession> (DI) IG9UploadService (DI) │
│ └─ G9CInMemorySessionStore (default) └─ G9CUploadService (default) │
│ • partials at /uploads/.partial/ │
│ • SHA-256 verified, atomic commit │
│ │
│ AddSignalRSuperNetCoreCore() │
│ AddG9SignalRSuperNetCoreSessionStore<TSession>() │
│ AddG9SignalRSuperNetCoreFileUpload(opt => …) │
│ AddSignalRSuperNetCoreJwt(hubPath, validationParameters) │
│ AddSignalRSuperNetCoreJwtHub<THub, TClient>(hubRoute, authRoute, …) │
│ │
│ /AuthHub ─► /SecureHub (Authorize) │
└──────────────────────────────────────────────────────────────────────────────┘
▲
│ WebSocket / SSE / LongPolling
▼
┌──────────────────────────────────────────────────────────────────────────────┐
│ CLIENT │
│ │
│ G9SignalRSuperNetCoreClient<TSelf, TServerMethods, TListeners> │
│ └─ G9SignalRSuperNetCoreClientWithJWTAuth<...> │
│ │
│ client.Server.MyMethod(args) ── source-generator typed proxy │
│ listener methods on derived class ── source-generator typed wiring │
│ AOT-safe — no Castle, no Reflection.Emit, no MethodInfo.Invoke │
│ │
│ G9CFileUploader(connection) │
│ • SHA-256 hash + deterministic upload id │
│ • BeginUpload → resume offset → stream chunks │
│ • IProgress<G9DtUploadClientProgress> (local + server-ack) │
│ • exponential-backoff retry, safe resume on disconnect │
└──────────────────────────────────────────────────────────────────────────────┘
Getting started
Prerequisites
- .NET 10.0 SDK or later
- ASP.NET Core 10 project for the server
- Any .NET 10 project for the client (console, MAUI, WPF, Blazor, ASP.NET, etc.)
Install
Server project:
dotnet add package G9SignalRSuperNetCore.Server
Client project:
dotnet add package G9SignalRSuperNetCore.Client
The source generator is shipped inside the Server package (analyzers/dotnet/cs). Both project-reference and NuGet-reference flows pick it up automatically.
Quick sample (no auth)
A minimal hub, a client interface for server-to-client callbacks, and a console client.
Shared library — IChatClient.cs
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string user);
Task UserLeft(string user);
}
Shared library — ChatHub.cs
using G9SignalRSuperNetCore.Server.Classes.Abstracts;
public class ChatHub : G9AHubBase<ChatHub, IChatClient>
{
public const string Route = "/chat";
public override string RoutePattern() => Route;
public Task SendMessage(string user, string message)
=> Clients.All.ReceiveMessage(user, message);
public override async Task OnConnectedAsync()
{
await Clients.Others.UserJoined(Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? ex)
{
await Clients.Others.UserLeft(Context.ConnectionId);
await base.OnDisconnectedAsync(ex);
}
}
Server — Program.cs
using G9SignalRSuperNetCore.Server;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalRSuperNetCoreCore(); // SignalR + deny policy + UserIdProvider + hub filter
var app = builder.Build();
app.MapGet("/", () => "Chat server");
app.AddSignalRSuperNetCoreServerHub<ChatHub, IChatClient>(routePattern: ChatHub.Route);
app.Run();
Client — generated typed client
The source generator emits ChatHubClient from your hub class. In the client project just subclass it and override the listener methods you care about:
var client = new MyChatClient("https://localhost:7159");
await client.ConnectAsync();
await client.Server.SendMessage("Iman", "Hello, world");
Console.ReadLine();
await client.DisconnectAsync();
public sealed class MyChatClient(string url) : ChatHubClient(url)
{
public override Task ReceiveMessage(string user, string message)
{
Console.WriteLine($"[{user}] {message}");
return Task.CompletedTask;
}
public override Task UserJoined(string user) { Console.WriteLine(user + " joined"); return Task.CompletedTask; }
public override Task UserLeft(string user) { Console.WriteLine(user + " left"); return Task.CompletedTask; }
}
The generator emits the ChatHubClient partial class with default no-op overrides; you supply the bodies you need.
Sample with JWT authentication
The library ships a dedicated authentication route (/AuthHub by convention) that exchanges arbitrary credentials for a JWT, then a protected hub route (/SecureHub) that requires the token. The token is sent on the access_token query string so it works over WebSockets out of the box.
using G9SignalRSuperNetCore.Server.Classes.Abstracts;
using G9SignalRSuperNetCore.Server.Classes.Helper;
using G9SignalRSuperNetCore.Server.Enums;
using Microsoft.AspNetCore.SignalR;
using Microsoft.IdentityModel.Tokens;
public class SecureHub : G9AHubBaseWithJWTAuth<SecureHub, IChatClient>
{
public const string HubRoute = "/SecureHub";
public const string AuthRoute = "/AuthHub";
private const string JwtSecret = "replace-with-a-strong-secret-of-at-least-32-bytes";
private static readonly G9JWTokenFactory TokenTemplate =
G9JWTokenFactory.GenerateJWTToken(JwtSecret, "G9TM", "G9TM",
DateTime.UtcNow.AddDays(3), G9ESecurityAlgorithms.HmacSha256);
public static TokenValidationParameters TokenValidationParameters => TokenTemplate.ValidationParameters!;
public override string RoutePattern() => HubRoute;
public override string AuthAndGetJWTRoutePattern() => AuthRoute;
public static Task<(G9JWTokenFactory factory, object? extra)> AuthenticateAsync(
object authorizeData, Hub authHub)
{
if (authorizeData?.ToString() == "valid-credentials")
{
var token = G9JWTokenFactory.GenerateJWTToken(
JwtSecret, "Iman", "admin", "G9TM", "G9TM", DateTime.UtcNow.AddDays(3));
return Task.FromResult<(G9JWTokenFactory, object?)>((token, new { Welcome = "Hi Iman" }));
}
return Task.FromResult<(G9JWTokenFactory, object?)>(
(G9JWTokenFactory.RejectAuthorize("Invalid credentials"), null));
}
public override Task<(G9JWTokenFactory, object?)> AuthenticateAndGenerateJwtTokenAsync(
object authorizeData, Hub authHub) => AuthenticateAsync(authorizeData, authHub);
public override TokenValidationParameters GetAuthorizeTokenValidationForHub() => TokenValidationParameters;
public Task SendMessage(string user, string message)
=> Clients.All.ReceiveMessage(user, message);
}
// Server registration
builder.Services.AddSignalRSuperNetCoreCore();
builder.Services.AddSignalRSuperNetCoreJwt(
hubPath: SecureHub.HubRoute,
validationParameters: SecureHub.TokenValidationParameters);
var app = builder.Build();
app.AddSignalRSuperNetCoreJwtHub<SecureHub, IChatClient>(
hubRoutePattern: SecureHub.HubRoute,
authRoutePattern: SecureHub.AuthRoute,
authenticate: SecureHub.AuthenticateAsync);
// Client
var client = new SecureHubClientWithJWTAuth("https://localhost:7159");
var auth = await client.AuthorizeAsync("valid-credentials");
if (!auth.IsAccepted)
{
Console.WriteLine($"Auth rejected: {auth.RejectionReason}");
return;
}
await client.ConnectAsync();
await client.Server.SendMessage("Iman", "Hello secure world");
AuthorizeAsync returns a G9DtAuthorizeResult with IsAccepted, JWToken, RejectionReason, and ExtraData. The token is cached in the client and used automatically on ConnectAsync(). To pass a token explicitly: await client.ConnectAsync(jwToken).
Sample with sessions
G9AHubBaseWithSession<THub, TClient, TSession> adds a thread-safe per-user session keyed by user identifier (or connection id when there is no user). Multiple connections from the same user share one session and a connection counter.
public class ChatSession : G9ASession
{
public int MessagesSent { get; set; }
public string? DisplayName { get; set; }
}
public class ChatHubWithSession :
G9AHubBaseWithSession<ChatHubWithSession, IChatClient, ChatSession>
{
public override string RoutePattern() => "/chat-session";
public Task SendMessage(string message)
{
Interlocked.Increment(ref _messagesSent);
return Clients.All.ReceiveMessage(Session.DisplayName ?? "anon", message);
}
public bool IsOnline(string userId) => IsUserConnected(userId);
}
Register the session store once during startup:
builder.Services.AddSignalRSuperNetCoreCore();
builder.Services.AddG9SignalRSuperNetCoreSessionStore<ChatSession>();
app.AddSignalRSuperNetCoreServerHub<ChatHubWithSession, IChatClient>("/chat-session");
Custom fields you add are not automatically thread-safe. The framework manages
ConnectionCountsandLastActivityDateTimeatomically; for your own counters useInterlockedor a lock.
You can call CleanupExpiredSessions(TimeSpan.FromMinutes(30)) from a background job to evict idle sessions.
Sample with JWT + sessions (recommended)
For most production hubs, you want both. G9AHubBaseWithSessionAndJWTAuth combines them, and the auth user identifier is used as the session key automatically.
public class AppHub :
G9AHubBaseWithSessionAndJWTAuth<AppHub, IChatClient, ChatSession>
{
public const string HubRoute = "/SecureHub";
public const string AuthRoute = "/AuthHub";
public static TokenValidationParameters TokenValidationParameters { get; } =
BuildTokenTemplate().ValidationParameters!;
public override string RoutePattern() => HubRoute;
public override string AuthAndGetJWTRoutePattern() => AuthRoute;
public static Task<(G9JWTokenFactory, object?)> AuthenticateAsync(
object authorizeData, Hub authHub) => /* your credential check */;
public override Task<(G9JWTokenFactory, object?)> AuthenticateAndGenerateJwtTokenAsync(
object authorizeData, Hub authHub) => AuthenticateAsync(authorizeData, authHub);
public override TokenValidationParameters GetAuthorizeTokenValidationForHub()
=> TokenValidationParameters;
public Task<List<string>> GetRecentMessages()
{
Session.MessagesSent++;
return Task.FromResult(new List<string> { "msg1", "msg2" });
}
private static G9JWTokenFactory BuildTokenTemplate() =>
G9JWTokenFactory.GenerateJWTToken("replace-me", "G9TM", "G9TM", DateTime.UtcNow.AddDays(3));
}
builder.Services.AddSignalRSuperNetCoreCore();
builder.Services.AddG9SignalRSuperNetCoreSessionStore<ChatSession>();
builder.Services.AddSignalRSuperNetCoreJwt(AppHub.HubRoute, AppHub.TokenValidationParameters);
var app = builder.Build();
app.AddSignalRSuperNetCoreJwtHub<AppHub, IChatClient>(
hubRoutePattern: AppHub.HubRoute,
authRoutePattern: AppHub.AuthRoute,
authenticate: AppHub.AuthenticateAsync);
Pluggable session store
Session storage is exposed as IG9SessionStore<TSession> and resolved through DI. The default registration uses G9CInMemorySessionStore<TSession>, which is thread-safe and zero-config:
builder.Services.AddG9SignalRSuperNetCoreSessionStore<ChatSession>();
The contract:
public interface IG9SessionStore<TSession> where TSession : G9ASession, new()
{
ValueTask<TSession> GetOrCreateAsync(string sessionId, Func<TSession> factory, CancellationToken ct = default);
ValueTask<int> ReleaseAsync (string sessionId, CancellationToken ct = default);
bool TryGet (string sessionId, out TSession? session);
bool IsConnected (string sessionId);
int CleanupExpiredSessions(TimeSpan threshold);
}
GetOrCreateAsync atomically creates a session on first connection and increments the connection counter. ReleaseAsync atomically decrements and removes the session when the counter hits zero. The in-memory implementation is lock-free; counters use Interlocked.
For horizontal scale-out, replace the registration with a distributed implementation. A Redis-backed store is on the 2.2 roadmap and ships in a separate package so the core library has no Redis dependency.
// 2.2 (preview):
builder.Services.AddG9SignalRSuperNetCoreRedisSessionStore<ChatSession>(
redisConnectionString: builder.Configuration.GetConnectionString("Redis"));
Auto-generated typed client
Reference G9SignalRSuperNetCore.Server (or have it transitively from a shared library) plus G9SignalRSuperNetCore.Client. A Roslyn IIncrementalGenerator runs on every keystroke and emits a typed client into obj/Generated/G9SignalRSuperNetCore.SourceGenerator/ containing:
I{HubName}Methods— methods interface mirroring your hub.Task,Task<T>,ValueTask,ValueTask<T>,IAsyncEnumerable<T>are all supported.I{HubName}Listeners— listener interface mirroring your client interface.{HubName}Client(or{HubName}ClientWithJWTAuth) — partial class wired to the hub's declared route, with a typedServerproxy (concreteHubConnection.InvokeCoreAsync/SendCoreAsync/StreamAsyncCorecalls — no Castle, no reflection) and default no-op listener overrides you can override in your own code.
public partial class ChatHubClient :
G9SignalRSuperNetCoreClient<ChatHubClient, IChatHubMethods, IChatHubListeners>,
IChatHubListeners
{
public ChatHubClient(string serverUrl, /* … */) : base($"{serverUrl}/chat", /* … */) { }
public override IChatHubMethods Server => _serverProxy ??= new ChatHubServerProxy(this);
protected override void RegisterListenerMethods()
{
Connection.On<string, string>(nameof(ReceiveMessage), (u, m) => InvokeListener_ReceiveMessage(u, m));
Connection.On<string>(nameof(UserJoined), u => InvokeListener_UserJoined(u));
Connection.On<string>(nameof(UserLeft), u => InvokeListener_UserLeft(u));
}
public virtual Task ReceiveMessage(string user, string message) => Task.CompletedTask;
public virtual Task UserJoined(string user) => Task.CompletedTask;
public virtual Task UserLeft(string user) => Task.CompletedTask;
}
internal sealed class ChatHubServerProxy : IChatHubMethods
{
private readonly ChatHubClient _owner;
public ChatHubServerProxy(ChatHubClient owner) => _owner = owner;
public Task SendMessage(string user, string message)
=> _owner.Connection.SendCoreAsync(nameof(SendMessage),
new object?[] { user, message }, CancellationToken.None);
public Task<List<string>> GetRecentMessages()
=> _owner.Connection.InvokeCoreAsync<List<string>>(nameof(GetRecentMessages),
Array.Empty<object?>(), CancellationToken.None);
}
The output is fully AOT-safe. Override the listener methods in a subclass (or in a partial declaration) to handle inbound messages.
Diagnostics: the generator publishes IDs G9001…G9007 so the IDE highlights bad signatures (unsupported return types, listeners that don't return Task/ValueTask, parameter overflow, generic methods, missing route patterns, missing listener interfaces, internal generator errors). To inspect the generated source, set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the consuming csproj.
To exclude a hub method from generation:
[G9AttrExcludeFromClientGeneration]
public Task InternalDiagnostic() => Task.CompletedTask;
To deny a method by policy (always rejected at the auth layer):
[G9AttrDenyAccess]
public Task DangerousAction() => Task.CompletedTask;
XML doc comments on hub methods and on the client interface are forwarded into the generated code.
Client features
Typed server proxy
await client.Server.SendMessage("Iman", "Hi");
List<string> result = await client.Server.GetRecentMessages();
// Streaming — generated from IAsyncEnumerable<T> hub method:
await foreach (var item in client.Server.SubscribePrices("AAPL"))
Console.WriteLine(item);
Each method on the server interface maps to a single typed call on the underlying HubConnection. Task methods use SendCoreAsync; Task<T> methods use InvokeCoreAsync<T>; IAsyncEnumerable<T> uses StreamAsyncCore<T>. No MethodInfo.Invoke, no boxing of return values.
Listener wiring
The generated client overrides RegisterListenerMethods() and registers each listener with a typed Connection.On<...>(...) overload. You override the handler methods in your subclass:
public sealed class MyClient(string url) : ChatHubClient(url)
{
public override Task ReceiveMessage(string user, string msg)
{
Console.WriteLine($"[{user}] {msg}");
return Task.CompletedTask;
}
}
Automatic reconnect and timeouts
Automatic reconnect (WithAutomaticReconnect()) is enabled by default and the server timeout is 60 seconds. Both can be customized through the customConfigureBuilder and configureHttpConnection constructor parameters of the generated client.
Connection access
If you need to drop down to raw SignalR APIs, the underlying HubConnection is exposed via client.Connection:
await foreach (var item in client.Connection.StreamAsync<int>("MyStream"))
Console.WriteLine(item);
IAsyncDisposable
The base client implements IAsyncDisposable. await using var client = new ChatHubClient(url); disposes the connection cleanly.
Policy attributes
All attributes are opt-in. Apply them per-method (or per-class for connection limits) to enforce production-grade policies declaratively. The hub filter (G9CHubFilter, registered automatically by AddSignalRSuperNetCoreCore()) reads them from a per-method MethodMeta cache, so methods with no G9 attributes pay only the cost of one dictionary lookup per call.
| Attribute | Target | Rejected with | Notes |
|---|---|---|---|
[G9AttrRateLimit(perSecond, burst)] |
method | G9_RATE_LIMITED |
Lock-free token bucket per (connectionId, method). Bursts up to burst, refills at perSecond. |
[G9AttrConnectionLimit(perUser, perIp)] |
class | G9_CONNECTION_LIMIT |
Caps simultaneous connections. 0 disables that dimension. Enforced on connect, before OnConnectedAsync. |
[G9AttrConnectionRequired] |
method | G9_CONNECTION_REQUIRED |
Fails fast when Context.ConnectionAborted is already cancelled. |
[G9AttrRequireRole("admin")] |
method or class | G9_ROLE_REQUIRED |
Caller must carry at least one of the listed roles. Stackable. |
[G9AttrRequireClaim("scope", "chat:write")] |
method or class | G9_CLAIM_REQUIRED |
Caller must carry the claim. Empty acceptedValues accepts any value. Stackable. |
[G9AttrTelemetry] / [G9AttrTelemetry("name")] |
method or class | n/a | Wraps invocation in an ActivitySource span tagging connection id, user id, and outcome (ok / cancelled / faulted). |
[G9AttrDenyAccess] (1.x carryover) |
method | (auth pipeline rejects) | Applies a deny-by-default policy; framework methods use this. |
[G9AttrExcludeFromClientGeneration] (1.x carryover) |
method | n/a | Tells the source generator to skip the method. |
Sample:
[G9AttrConnectionLimit(perUser: 5, perIp: 50)]
public class ChatHub : G9AHubBase<ChatHub, IChatClient>
{
[G9AttrTelemetry]
[G9AttrRateLimit(perSecond: 5, burst: 10)]
public Task SendMessage(string user, string message) =>
Clients.All.ReceiveMessage(user, message);
[G9AttrRequireRole("admin")]
[G9AttrTelemetry("admin.purge")]
public Task PurgeHistory() => /* ... */;
[G9AttrRequireClaim("scope", "chat:write")]
public Task SendDirect(string toUser, string message) => /* ... */;
}
The client sees rejections as HubException whose message equals the stable error code, so you can switch on it cleanly:
try { await client.Server.SendMessage(user, msg); }
catch (HubException ex) when (ex.Message.Contains("G9_RATE_LIMITED"))
{
ShowToast("You're sending too fast — slow down a bit.");
}
catch (HubException ex) when (ex.Message.Contains("G9_CONNECTION_LIMIT"))
{
ShowToast("Too many sessions open for your account.");
}
Resumable file upload
Upload large files over SignalR with chunk streaming, server-acknowledged progress, and safe resume on disconnect. The pipeline is opt-in: register the service on the server and use G9CFileUploader on the client.
Server side
builder.Services.AddSignalRSuperNetCoreCore();
builder.Services.AddG9SignalRSuperNetCoreFileUpload(opt =>
{
opt.RootDirectory = Path.Combine(builder.Environment.ContentRootPath, "uploads");
opt.MaxBytes = 5L * 1024 * 1024 * 1024; // 5 GB
opt.PartialTtl = TimeSpan.FromHours(24); // abandoned partials are purged after 24h
opt.AckEveryNChunks = 16; // server pushes UploadProgress every ~1 MB at 64 KB chunks
});
Add upload methods to your hub. The shape is two methods: BeginUpload to negotiate the resume offset, then UploadChunks for the streamed bytes:
public class ChatHub : G9AHubBase<ChatHub, IChatClient>
{
public async Task<G9DtBeginUploadResult> BeginUpload(
string uploadId, string fileName, long totalBytes, int chunkSize, string declaredSha256Hex)
{
var svc = ResolveUploadService(); // IG9UploadService from DI
return await svc.BeginAsync(uploadId, fileName, totalBytes, chunkSize, declaredSha256Hex,
Context.ConnectionAborted);
}
public async Task<G9DtUploadResult> UploadChunks(string uploadId, IAsyncEnumerable<byte[]> chunks)
{
var svc = ResolveUploadService();
var caller = Clients.Caller;
return await svc.AppendChunksAsync(
uploadId,
chunks,
onProgress: bytes => caller.UploadProgress(new G9DtUploadProgress
{
UploadId = uploadId,
BytesReceived = bytes,
TotalBytes = 0 // 0 means "use the total you already declared"
}),
Context.ConnectionAborted);
}
}
Add Task UploadProgress(G9DtUploadProgress progress) to your IChatClient listener interface so the server-acknowledged progress flows back to the typed client.
Where files end up:
- In flight:
{RootDirectory}/{PartialSubdirectory}/{uploadId}.bin(default./uploads/.partial/) - Sidecar metadata:
{uploadId}.meta(small JSON, source-generated serialization) - After commit: atomically renamed to
{RootDirectory}/{fileName}(timestamp-suffixed if the name collides)
The repo's .gitignore excludes **/uploads/ and **/.partial/ so committed files and partials never accidentally reach the repo.
Client side
using G9SignalRSuperNetCore.Client.FileUpload;
var uploader = new G9CFileUploader(client.Connection, new G9DtUploadClientOptions
{
ChunkSize = 64 * 1024, // 64 KB; capped at 4 MB by the server
MaxRetries = 5, // exponential backoff between retries
BeginMethod = "BeginUpload", // hub method names; defaults shown
UploadMethod = "UploadChunks"
});
var progress = new Progress<G9DtUploadClientProgress>(p =>
Console.WriteLine($"{100.0 * p.BytesSent / p.TotalBytes:F1}% " +
$"{p.BytesPerSecond / 1024.0:F0} KB/s " +
$"acked {p.BytesAcknowledged}/{p.TotalBytes}"));
var result = await uploader.UploadAsync("file_upload_test.zip", progress, serverAckProgress: true, ct);
switch (result.Status)
{
case G9EUploadStatus.Completed:
Console.WriteLine($"OK — {result.BytesWritten} B at {result.FinalPath}");
Console.WriteLine($" SHA-256 = {result.Sha256}");
break;
case G9EUploadStatus.Interrupted:
// The partial is preserved server-side; calling UploadAsync again resumes from the new offset.
Console.WriteLine($"Interrupted at {result.BytesWritten} B (resumable).");
break;
case G9EUploadStatus.Failed:
Console.WriteLine($"FAIL — {result.ErrorCode}: {result.ErrorMessage}");
break;
}
Resume guarantees
- Append-only writes server-side, so a partial file is never corrupted by retry.
- Idempotent: replaying the same chunk after a crash never duplicates bytes (server compares offset against current file length and refuses to over-write).
- Tamper-detection: the client's declared SHA-256 is verified before commit. Mismatch deletes the partial and returns
G9_UPLOAD_HASH_MISMATCH. - Per-id concurrency through a
SemaphoreSlimso two parallel callers for the sameuploadIdnever interleave bytes. - Cancellation-clean:
OperationCanceledExceptionpreserves the partial so a future call can resume.
Out of scope (today)
- Cross-server resume in a load-balanced cluster — the partial lives on whichever server node first received chunks. With sticky sessions you're fine; without them, a future call may land on a different node and start over. The Bundle 5 Redis package and a shared blob backend will close this.
- Bandwidth throttling / pause-resume from the UI — easy to add on top; not in 2.1.
Resumable file download
Symmetric to the upload pipeline. The server exposes BeginDownloadAsync(serverRelativePath, resumeFrom, chunkSize) and a streaming StreamFileAsync(...). The client uses G9CFileDownloader and gets the same resume guarantees.
// Server hub method (sample ChatHub):
[G9AttrTelemetry]
public IAsyncEnumerable<byte[]> DownloadChunks(string fileName, long resumeFrom, int chunkSize, CancellationToken ct)
=> _uploads.StreamFileAsync(fileName, resumeFrom, chunkSize, ct);
// Client:
var downloader = new G9CFileDownloader(connection, new G9DtDownloadClientOptions
{
ChunkSize = 64 * 1024,
OnRetry = info => Console.WriteLine($"resume from {info.BytesAlreadyOnServer} after {info.Backoff}")
});
var result = await downloader.DownloadAsync(
serverFileName: "file_upload_test.zip",
localTargetPath: @"C:\downloads\file_upload_test.zip",
progress: new Progress<G9DtDownloadClientProgress>(p =>
Console.WriteLine($"{100.0 * p.BytesReceived / p.TotalBytes:F1}%")));
if (result.Status == G9EUploadStatus.Completed) Console.WriteLine($"OK at {result.LocalPath}");
What you get for free:
- Resume: a
.partialfile on the client preserves bytes across cancels and disconnects; the next call seeks to the partial's length and continues. - Hash verification: server sends its SHA-256 in
BeginDownload; the client re-hashes before atomic rename. - Idempotent fast path: if a fully-downloaded committed file already matches the server's hash, the call returns
Completedwithout re-streaming. - Path-traversal safety: server rejects
.., absolute paths, and anything that escapesRootDirectory. - SHA cache: the server caches the hash keyed by
(path, length, lastWriteUtc)so it isn't recomputed on every begin call.
Groups and presence
Two opt-in services that ride on top of SignalR's group machinery without losing the typed-proxy ergonomics.
// Startup:
builder.Services.AddG9SignalRSuperNetCoreGroups<ChatHub>();
builder.Services.AddG9SignalRSuperNetCorePresence();
// Hub class:
[G9AttrPresenceTracked] // emits G9DtPresenceEvents on Connect/Disconnect
[G9AttrAutoJoinGroup("lobby")] // every new connection lands in "lobby"
public class ChatHub : G9AHubBase<ChatHub, IChatClient>
{
public Task JoinRoom(string roomName)
=> _groups.JoinAsync(Context.ConnectionId, roomName);
public Task SendToRoom(string roomName, string user, string message)
=> Clients.Group(roomName).ReceiveMessage($"#{roomName} {user}", message);
public List<string> ListRoomMembers(string roomName)
=> _groups.GetMembers(roomName).ToList();
}
G9CPresenceTracker.Events is a ChannelReader<G9DtPresenceEvent>. Drain it in a BackgroundService to fan out online/offline transitions to whichever clients you choose:
public sealed class G9CPresenceBroadcastService : BackgroundService
{
public G9CPresenceBroadcastService(G9CPresenceTracker tracker, IHubContext<ChatHub, IChatClient> hub) { ... }
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var evt in _tracker.Events.ReadAllAsync(ct))
await _hub.Clients.All.PresenceChanged(evt);
}
}
Performance contract: hubs without [G9AttrPresenceTracked] pay no extra work. Auto-join is implemented as a hub-typed G9CAutoJoinFilter<THub> registered automatically by AddG9SignalRSuperNetCoreGroups<THub>() — AOT-safe, no MakeGenericType on the hot path.
Server streams with backpressure
G9CResilientStream.Create<T>(...) returns a producer/consumer pair that completes the channel deterministically — no more "stream hangs because the writer was abandoned in a catch" footguns.
[G9AttrTelemetry]
[G9AttrStreamBackpressure(capacity: 64, dropPolicy: G9EStreamDropPolicy.Wait)]
public IAsyncEnumerable<int> LiveTickerStream(int count, int intervalMs, CancellationToken ct)
{
var attr = (G9AttrStreamBackpressureAttribute?)Attribute.GetCustomAttribute(
typeof(ChatHub).GetMethod(nameof(LiveTickerStream))!, typeof(G9AttrStreamBackpressureAttribute));
var (writer, reader) = G9CResilientStream.Create<int>(attr?.ToOptions());
_ = Task.Run(async () =>
{
try
{
for (var i = 0; i < count && !ct.IsCancellationRequested; i++)
{
await writer.WriteAsync(i, ct);
await Task.Delay(intervalMs, ct);
}
}
catch (OperationCanceledException) { /* client unsubscribed */ }
finally { writer.Complete(); }
}, ct);
return reader.AsAsyncEnumerable(ct);
}
The drop policies map onto BoundedChannelFullMode:
Wait— block the producer until the consumer drains an item (default).DropNewest— drop the new item; older queued items survive.DropOldest— drop the oldest queued item to make room for the new one.
Resilient client reconnect
G9CClientReconnectPolicy is wired into the generated client base by default. It keeps retrying with capped exponential backoff plus ±15% jitter and a configurable max-elapsed budget so a long outage doesn't trigger infinite retry storms.
var conn = new HubConnectionBuilder()
.WithUrl(url)
.WithAutomaticReconnect(new G9CClientReconnectPolicy(
baseDelay: TimeSpan.FromMilliseconds(200),
factor: 2.0,
maxDelay: TimeSpan.FromSeconds(30),
maxElapsed: TimeSpan.FromMinutes(5)))
.Build();
Pass Timeout.InfiniteTimeSpan for maxElapsed to retry forever.
App-level encryption (no TLS required)
For environments where you can't run TLS — internal networks, h2c reverse proxies, on-device IPC — G9CHandshake ships an ephemeral-static ECDH P-256 + HKDF-SHA-256 + ChaCha20-Poly1305 pipeline using only System.Security.Cryptography types.
// Startup:
builder.Services.AddG9SignalRSuperNetCoreHandshake();
// Server hub methods (in the sample ChatHub):
public Task<byte[]> GetServerPublicKey() // 65-byte SEC1 pubkey
=> Task.FromResult(_handshake.StaticPublicKey.ToArray());
public Task<bool> BeginSession(byte[] ephemeralPub) // caches the session key
{
var key = _handshake.DeriveSessionKey(ephemeralPub);
_sealer.SetSession(Context.ConnectionId, key);
return Task.FromResult(true);
}
[G9AttrEncrypted]
public Task<byte[]> EncryptedEchoSession(byte[] envelope)
{
var plaintext = _sealer.Open(Context.ConnectionId, envelope);
return Task.FromResult(_sealer.Seal(Context.ConnectionId, plaintext));
}
// Client side (one-shot per-call form):
var serverPub = await connection.InvokeAsync<byte[]>("GetServerPublicKey");
var (ephemeralPub, sessionKey) = G9CHandshake.ClientHandshake(serverPub);
var envelope = G9CHandshake.Seal(sessionKey, Encoding.UTF8.GetBytes("hello"));
var sealedReply = await connection.InvokeAsync<byte[]>("EncryptedEcho", ephemeralPub, envelope);
var plaintext = G9CHandshake.Open(sessionKey, sealedReply);
What you get:
- Confidentiality + integrity end-to-end against a network attacker.
- Forward secrecy through the per-session ephemeral keypair.
- Server authentication through the long-term static public key (TOFU; pin it on first connect, or hard-code for known deployments).
- Constant-time, FIPS-validated, hardware-accelerated (AES-NI / ARM CRYPTO).
- AOT/MAUI-safe — no third-party crypto dependencies.
What it isn't:
- Not a TLS replacement when you also need certificate revocation, downgrade protection, or origin authentication. Use TLS when you have the option.
Why P-256 instead of X25519 / Noise IK: the BCL ships ECDiffieHellman for NIST P-256 and ChaCha20Poly1305, but no managed X25519. Implementing X25519 by hand in a few hundred lines is a security footgun. The IETF-standard ECDHE-static handshake on P-256 gives equivalent security guarantees through vetted BCL code.
Distributed backplane (interface)
IG9DistributedBackplane is a minimal publish/subscribe abstraction so the in-process group manager and presence tracker can be replaced with a Redis or NATS implementation when scaling out.
builder.Services.AddG9SignalRSuperNetCoreBackplane(); // default: in-process no-op
// Or with a custom implementation:
builder.Services.AddG9SignalRSuperNetCoreBackplane(sp => new MyRedisBackplane(...));
The default G9CInProcessBackplane drops publishes (there are no other nodes to receive them) and returns an empty subscription. SignalR's own Redis backplane already handles message fan-out for hub invocations; this interface is for the G9-specific membership state.
Telemetry, metrics, and stable error codes
G9CTelemetry exposes a single ActivitySource and a single Meter named G9SignalRSuperNetCore. Subscribe with OpenTelemetry:
builder.Services.AddOpenTelemetry()
.WithTracing (t => t.AddSource("G9SignalRSuperNetCore"))
.WithMetrics(m => m.AddMeter ("G9SignalRSuperNetCore"));
Built-in counters:
g9.signalr.rate_limited_invocations— calls rejected by[G9AttrRateLimit].g9.signalr.connection_limit_rejections— connections rejected by[G9AttrConnectionLimit].g9.signalr.authorization_rejections— calls rejected by[G9AttrRequireRole]/[G9AttrRequireClaim].
Stable error codes (G9SignalRSuperNetCore.Server.Classes.Errors.G9CErrorCodes):
G9_RATE_LIMITED G9_CONNECTION_LIMIT G9_CONNECTION_REQUIRED
G9_ROLE_REQUIRED G9_CLAIM_REQUIRED
G9_UPLOAD_TOO_LARGE G9_UPLOAD_HASH_MISMATCH G9_UPLOAD_UNKNOWN_ID G9_UPLOAD_FAILED
The codes are part of the public API; switching on them in the client is supported and recommended.
JWT helper
G9JWTokenFactory covers the common token shapes:
// Username + role
var token = G9JWTokenFactory.GenerateJWTToken(
jwtSecret: secret,
username: "Iman",
role: "admin",
issuer: "G9TM",
audience: "G9TM",
expires: DateTime.UtcNow.AddHours(8));
// Custom claim list
var token2 = G9JWTokenFactory.GenerateJWTToken(secret, "G9TM", "G9TM",
new[] { new Claim("plan", "pro") },
expires: DateTime.UtcNow.AddDays(1));
// Reject the request
var rejected = G9JWTokenFactory.RejectAuthorize("Invalid credentials");
A single JwtSecurityTokenHandler instance is reused process-wide.
Supported algorithms (G9ESecurityAlgorithms): HmacSha256/384/512, RsaSha256/384/512, RsaSsaPssSha256/384/512, EcdsaSha256/384/512, Aes128/192/256KW, RsaOAEP, Rsa1_5, None.
Scaling to many connections
A single ASP.NET Core SignalR server typically handles up to ~100,000 WebSocket connections per process under proper tuning. To exceed that, scale out across servers:
- Pick a backplane. For most teams the simplest answer is Azure SignalR Service; for self-hosted clusters in the same data centre, a Redis backplane is fine. Both are first-class ASP.NET Core SignalR scale-out targets and compose cleanly with this library.
- Sticky sessions. With Redis, configure your load balancer for session affinity by
connectionId. With Azure SignalR Service, the service handles affinity for you. - Tune the OS. On Linux: raise
ulimit -n, increasenet.core.somaxconn, expand the ephemeral port range, and ensure ports are not exhausted by short-lived backplane connections. - Tune Kestrel. Set
KestrelServerLimits.MaxConcurrentConnectionsandMaxConcurrentUpgradedConnectionsin line with your hardware. - Move session state out of process. This is what
IG9SessionStore<TSession>is designed for. The default in-memory store works for a single process; for multi-process clusters, swap in a distributed implementation.
This library does not embed any correctness-critical state in static fields. The same hub source code that runs in a single dev process runs unchanged behind a backplane.
A coming G9SignalRSuperNetCore.Server.Redis package will provide a turnkey distributed IG9SessionStore<TSession> plus an AddG9SignalRBackplane(...) helper that wires Microsoft.AspNetCore.SignalR.StackExchangeRedis. The same package will offer a Redis-backed token-bucket rate limiter so the policy attributes can enforce cluster-wide caps.
Thread safety contract
The library is designed for high-concurrency hubs. Specifically:
- Sessions are linearizable on connect/disconnect.
G9ASession.ConnectionCountsis mutated only throughInterlocked.Increment/Interlocked.Decrement. Concurrent reconnect storms from the same user yield a final count equal to (#connects − #disconnects), and the counter is never negative at any observable point. LastActivityDateTimenever tears. It is read and written throughInterlocked.Read/Interlocked.Exchangeon the underlyinglongticks. You can read it from any thread and never see a partially-writtenDateTime.- Session removal is reference-equality safe. A session is removed from the in-memory store only if no other connect has raced in and replaced the instance.
- JWT route registry is read-mostly. Registrations occur during startup; reads on every authorize call are lock-free
ConcurrentDictionarylookups. G9JWTokenFactoryis process-shared. A singleJwtSecurityTokenHandleris reused; the handler is documented as thread-safe for issuance and validation.- Token bucket is wait-free in the uncontended case. State is encoded in a single 64-bit field and updated through
Interlocked.CompareExchangewith bounded retry. No locks, no allocations on the hot path. - Connection counter cells are reference-counted. A cell is removed from the dictionary only when its counter falls back to zero, so the dictionary doesn't grow unboundedly under churn.
- File-upload writes are serialized per
uploadIdthrough aSemaphoreSlim. Two racing append calls for the same upload never interleave bytes; two calls for different uploads run in parallel.
Custom fields you add to your TSession subclass are NOT automatically thread-safe. Synchronize them yourself with Interlocked or a lock.
Console test harness (Consolonia)
The G9SignalRSuperNetCore.ConsoleClient sample is a Consolonia (Avalonia-for-the-terminal) UI that exercises every Bundle 2 feature end-to-end against the WebServer sample.
Tabs:
- Connection — server URL, display name, Connect / Disconnect, connection log (joins/leaves, errors).
- Chat — Send a message (default text:
G9SignalRSuperNetCore), live receive log driven by the source-generatedChatHubClient.ReceiveMessageoverride. - Recent — Fetch the recent-messages list with
Server.GetRecentMessages(). - File upload — Pick a path (default:
file_upload_test.zip), choose chunk size, watch live percent + KB/s + acked-bytes update viaIProgress<G9DtUploadClientProgress>. A Cancel button preserves the partial; the next click of Upload resumes. - Rate limit — Fires 100
SendMessagecalls in a tight loop. The hub method is decorated with[G9AttrRateLimit(perSecond: 5, burst: 10)]so you can watch the burst go through, thenG9_RATE_LIMITEDrejections.
Run side-by-side:
# Terminal 1
dotnet run --project G9SignalRSuperNetCore/G9SignalRSuperNetCore.WebServer -c Release
# Terminal 2
dotnet run --project G9SignalRSuperNetCore/G9SignalRSuperNetCore.ConsoleClient -c Release
The console app uses Consolonia.UseAutoDetectedConsole() so it works in Windows Terminal, ConEmu, iTerm2, GNOME Terminal, Alacritty, etc.
Build and test
# Restore + Release build the whole solution
dotnet build G9SignalRSuperNetCore/G9SignalRSuperNetCore.sln -c Release
# Run the sample web server
dotnet run --project G9SignalRSuperNetCore/G9SignalRSuperNetCore.WebServer -c Release
# Run the Consolonia console test harness (in another terminal)
dotnet run --project G9SignalRSuperNetCore/G9SignalRSuperNetCore.ConsoleClient -c Release
The Release build is warning-clean across all six projects. CI runs through azure-pipelines.yml and publishes the two NuGet packages on main.
Project layout
G9SignalRSuperNetCore/
├── G9SignalRSuperNetCore.sln
├── .gitignore # excludes uploads/, partials, build.log
├── G9SignalRSuperNetCore.Server/ # NuGet: server library + embedded generator
│ ├── G9SignalRSuperNetCoreServer.cs # Add… extension methods (DI helpers)
│ ├── Classes/Abstracts/ # Hub bases (4 variants) + G9ASession
│ ├── Classes/Attributes/ # DenyAccess, Exclude, RateLimit,
│ │ # ConnectionLimit, ConnectionRequired,
│ │ # RequireRole, RequireClaim, Telemetry
│ ├── Classes/Errors/G9CErrorCodes.cs # G9_RATE_LIMITED, G9_CONNECTION_LIMIT, …
│ ├── Classes/Filters/ # G9CHubFilter + G9CTokenBucket +
│ │ # G9CConnectionCounter + G9CTelemetry
│ ├── Classes/FileUpload/ # IG9UploadService + G9CUploadService +
│ │ # DTOs (G9Dt…) and options
│ ├── Classes/Helper/ # G9JWTokenFactory + deny policy
│ ├── Classes/Hubs/ # G9GetJwtHub + G9CJwtRouteRegistry
│ ├── Classes/Sessions/ # IG9SessionStore + in-memory impl
│ └── Enums/G9ESecurityAlgorithms.cs
├── G9SignalRSuperNetCore.Client/ # NuGet: client library
│ ├── G9SignalRSuperNetCoreClient.cs # AOT-safe base (no Castle)
│ ├── G9SignalRSuperNetCoreClientWithJWTAuth.cs # adds AuthorizeAsync flow
│ └── FileUpload/ # G9CFileUploader + DTOs + options
├── G9SignalRSuperNetCore.SourceGenerator/ # Roslyn IIncrementalGenerator
│ ├── G9HubClientGenerator.cs # generator entry point
│ ├── G9Parser.cs # symbol -> HubModel
│ ├── G9Emitter.cs # HubModel -> C# source
│ ├── G9Diagnostics.cs # G9001..G9007 stable IDs
│ └── AnalyzerReleases.{Shipped,Unshipped}.md # release-tracking metadata
├── G9SignalRSuperNetCore.Sample.Shared/ # Sample hub + client interface
├── G9SignalRSuperNetCore.WebServer/ # Sample server
└── G9SignalRSuperNetCore.ConsoleClient/ # Sample client (Consolonia TUI)
Migration guide
2.0 → 2.1
2.1 is additive. Existing 2.0 code keeps working without changes. To opt into the new features:
Policy attributes: just decorate hub methods. The hub filter is registered by
AddSignalRSuperNetCoreCore()— no extra wiring.File upload: register the service once during startup and add the two upload methods to your hub.
builder.Services.AddG9SignalRSuperNetCoreFileUpload(opt => { opt.RootDirectory = Path.Combine(env.ContentRootPath, "uploads"); opt.MaxBytes = 5L * 1024 * 1024 * 1024; });Add
BeginUploadandUploadChunksto your hub (snippets in Resumable file upload) andTask UploadProgress(G9DtUploadProgress progress)to your client interface.Telemetry / metrics: add OpenTelemetry's
AddSource("G9SignalRSuperNetCore")andAddMeter("G9SignalRSuperNetCore")to your tracing/metrics pipelines.
1.x → 2.0
The 2.0 release was a hardening release. Most consumer code keeps working with small registration changes:
Service registration. The old single-call helper
builder.Services.AddSignalRSuperNetCoreServerService<MyHub, IMyClient>();becomes the explicit composable API:
builder.Services.AddSignalRSuperNetCoreCore(); builder.Services.AddG9SignalRSuperNetCoreSessionStore<MySession>(); // only if hub uses sessions builder.Services.AddSignalRSuperNetCoreJwt(MyHub.HubRoute, MyHub.TokenValidationParameters); // only for JWT hubsHub mapping. The old
app.AddSignalRSuperNetCoreServerHub<MyHub, IMyClient>()(which read the route from a synthesized hub instance) becomes:app.AddSignalRSuperNetCoreServerHub<MyHub, IMyClient>(routePattern: "/myhub"); // …or, for JWT-authenticated hubs: app.AddSignalRSuperNetCoreJwtHub<MyHub, IMyClient>( hubRoutePattern: MyHub.HubRoute, authRoutePattern: MyHub.AuthRoute, authenticate: MyHub.AuthenticateAsync);Reading the route pattern from a synthesized instance required
Reflection.Emit-style instance creation, which is incompatible with NativeAOT and full trim. Passing routes explicitly removes that dependency.Session lifecycle. The static per-process dictionary on
G9AHubBaseWithSession*is gone. Behavior is the same; the store comes from DI now.Client proxy. The runtime Castle DynamicProxy proxy is replaced by source-generator output. Rebuild and the typed client appears in
obj/Generated/. Override the listener methods on the generated class to receive callbacks.Removed client helpers.
AssignListenerEvent,ListenOnceAsync, andSendThenListenOnceAsyncfrom 1.x are removed (heavy in reflection, not AOT-safe).
The Castle.Core dependency is no longer present. Remove any direct references you had to it from your client project.
Roadmap
The 2.x line is shipped as a sequence of focused milestones.
- 2.0 — Foundation (shipped Sept 2026). AOT/MAUI safety, source-generator typed client, pluggable session store, lock-free session counters, encapsulated JWT registry, warning-clean Release build.
- 2.1 — Policy & file upload (this release).
[G9AttrRateLimit],[G9AttrConnectionLimit],[G9AttrConnectionRequired],[G9AttrRequireRole],[G9AttrRequireClaim],[G9AttrTelemetry].- Stable error codes (
G9_*) andSystem.Diagnostics.Metricscounters. - Resumable, hash-verified file upload with progress and safe resume on disconnect.
- Generator:
IAsyncEnumerable<T>parameters,ValueTask/ValueTask<T>returns,CancellationTokenthreading. - Tabbed Consolonia console test harness exercising every feature end-to-end.
- 2.2 — Groups & presence (shipped).
G9CGroupManager<THub>— process-local group index with snapshot queries.G9CPresenceTracker— per-user connection counts withChannel<G9DtPresenceEvent>.[G9AttrPresenceTracked]— opt-in lifecycle tracking on the central filter.[G9AttrAutoJoinGroup]— declarative group membership via a hub-typed AOT-safe filter.- Sample
ChatHubshipsJoinRoom,LeaveRoom,SendToRoom,ListRoomMembers,ListOnlineUsers.
- 2.3 — Streaming & resilience (shipped).
G9CResilientStream— bounded producer/consumer pair with deterministic completion.G9DtStreamOptions— capacity + drop policy (Wait / DropNewest / DropOldest).[G9AttrStreamBackpressure]— declarative backpressure metadata.G9CClientReconnectPolicy— drop-in replacement forWithAutomaticReconnect()with jittered exponential backoff and a max-elapsed budget.
- 2.4 — Distributed & secure (shipped, partial).
G9CHandshake— ECDH P-256 + HKDF-SHA-256 + ChaCha20-Poly1305, BCL-only.G9CSessionSealer— per-connection session-key cache, zeroed on disconnect.[G9AttrEncrypted]— declarative encrypted-method contract.IG9DistributedBackplane— pluggable cross-node coordination (default no-op for single-process; Redis/NATS implementations belong in optional packages).- Resumable downloads with SHA-256 verification and idempotent fast path.
- Still planned for a future minor:
G9SignalRSuperNetCore.Server.Redispackage, MessagePack hub-protocol opt-in package, BenchmarkDotNet suite.
The order can shift in response to consumer feedback; track progress in the issues board.
Contributing
Issues and pull requests are welcome.
- Open an issue describing the change before sending a large PR.
- Match the existing code style: file-scoped namespaces, XML docs on public members,
G9prefix on public types,G9C…for concrete classes,G9A…for abstracts,G9Dt…for DTOs,G9Attr…for attributes,G9E…for enums. dotnet build -c Releasemust finish with zero warnings — including AOT and trim warnings attributable to G9 code.- Update or add a sample under
G9SignalRSuperNetCore.WebServer/G9SignalRSuperNetCore.ConsoleClientwhen adding new public surface. - New cryptographic code (in 2.4+) must use BCL primitives only and ship with property-based tests.
License
Released under the MIT License.
| 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
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.8)
- Microsoft.Extensions.Http.Resilience (>= 9.10.0)
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 |
|---|---|---|
| 2.5.1 | 98 | 5/31/2026 |
| 2.5.0 | 92 | 5/30/2026 |
| 2.4.1 | 95 | 5/23/2026 |
| 2.4.0 | 101 | 5/23/2026 |
| 1.0.1.9 | 172 | 5/3/2025 |
| 1.0.1.8 | 180 | 1/15/2025 |
| 1.0.1.7 | 196 | 1/5/2025 |
| 1.0.1.6 | 175 | 12/29/2024 |
| 1.0.1.5 | 160 | 12/26/2024 |
| 1.0.1.3 | 199 | 12/26/2024 |
| 1.0.1.2 | 179 | 12/26/2024 |
| 1.0.1.1 | 188 | 12/25/2024 |
| 1.0.1 | 187 | 12/25/2024 |
| 1.0.0.9 | 173 | 12/23/2024 |
| 1.0.0.8 | 160 | 12/23/2024 |
| 1.0.0.1 | 161 | 12/21/2024 |