G9SignalRSuperNetCore.Client.MessagePack
2.7.0
dotnet add package G9SignalRSuperNetCore.Client.MessagePack --version 2.7.0
NuGet\Install-Package G9SignalRSuperNetCore.Client.MessagePack -Version 2.7.0
<PackageReference Include="G9SignalRSuperNetCore.Client.MessagePack" Version="2.7.0" />
<PackageVersion Include="G9SignalRSuperNetCore.Client.MessagePack" Version="2.7.0" />
<PackageReference Include="G9SignalRSuperNetCore.Client.MessagePack" />
paket add G9SignalRSuperNetCore.Client.MessagePack --version 2.7.0
#r "nuget: G9SignalRSuperNetCore.Client.MessagePack, 2.7.0"
#:package G9SignalRSuperNetCore.Client.MessagePack@2.7.0
#addin nuget:?package=G9SignalRSuperNetCore.Client.MessagePack&version=2.7.0
#tool nuget:?package=G9SignalRSuperNetCore.Client.MessagePack&version=2.7.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
- 2.7 — Awaited calls really wait, and four correctness fixes from an external review
- 2.6 — MessagePack hub protocol (opt-in), generated-client and registration fixes
- 2.5.3 — Client multi-targets netstandard2.1 (Unity)
- 2.5.1 — Hub-filter single-constructor fix
- 2.5 — Server policy-rejection logging
- 2.2 / 2.3 / 2.4 — Groups & presence, streaming & resilience, distributed & secure
- 2.1 — Policy attributes + resumable file upload
- 2.0 — Foundation hardening (Sept 2026)
- 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
- MessagePack hub protocol (opt-in)
- 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.7 — Awaited calls really wait, and four correctness fixes from an external review
An independent review of the library (and of G9SyncData, which sits on it) found five issues. All five are fixed here. One of them changes behaviour you may be relying on — read the first item.
Changed — await on a no-result method now waits for the server. A generated method returning Task or ValueTask was emitted as SendCoreAsync, which completes when the message has been written to the connection. Awaiting it therefore returned before the server had run the method, and an exception thrown there could not be observed through that task at all — while a method returning Task<T> was an acknowledged invocation, so two calls that look the same in your code behaved differently.
From 2.7.0 a no-result method is an acknowledged invocation: await client.Server.SaveAsync(row) returns when the server is done, and a server-side failure surfaces as a HubException at the caller.
// Keep the old fire-and-forget behaviour where you actually want it:
[G9AttrOneWay]
public Task Heartbeat(long ticks) => …;
Source change: none. Behaviour change: calls that used to return immediately now wait for the server. Add [G9AttrOneWay] to any hub method where that wait is not wanted (telemetry pings, presence beacons) and regenerate. If you use the MessagePack protocol, no action is needed — the library's shape providers now cover the result type an acknowledged no-result call binds.
Fixed — rate-limit buckets are released when the connection ends. [G9AttrRateLimit] allocated a token bucket per (connection, method) in a singleton dictionary and never removed it. OnDisconnectedAsync cleaned up every other per-connection structure but not this one, so on a server with mobile clients — where every tunnel change is a new connection — the dictionary grew for the life of the process. Buckets are now held per connection and dropped in one lookup when it disconnects, with an idle sweep as a fallback for a disconnect that never arrives. G9CHubFilter.TrackedRateLimitConnections exposes the live count for a health check.
Added — G9CResilientStream.Run owns the producer. Create<T> hands back a writer/reader pair and leaves the lifetime to you, which has two sharp edges: a producer that keeps writing after the consumer walked away blocks for ever on a Wait channel, and a producer that throws but only disposes its writer ends the stream as though it had succeeded — the consumer sees a short stream and no error.
public IAsyncEnumerable<Row> Rows(CancellationToken ct) =>
G9CResilientStream.Run<Row>(async (writer, token) =>
{
await foreach (var row in _db.ReadAsync(token))
await writer.WriteAsync(row, token); // throw here and the CONSUMER sees it
}, new G9DtStreamOptions { Capacity = 256 }, ct);
Run links cancellation to the consumer, so abandoning the stream releases a blocked producer; awaits the producer before returning; and completes the channel with the producer's exception. Create<T> is unchanged for callers that need the pair. Note that Capacity counts items, not bytes.
Added — stream-aware telemetry. [G9AttrTelemetry] on a streaming method could only time the invocation: the method returns as soon as its iterator exists, long before the first item, so the span described creating the iterator. The filter now says so (g9.stream, g9.outcome=stream_started) instead of implying it measured the stream, and G9CStreamTelemetry.Track<T>(stream, name, ct) measures the enumeration where the item type is known — time to first item, item count, full duration, and how it ended:
[G9AttrTelemetry("ChatHub.Subscribe")]
public IAsyncEnumerable<Message> Subscribe(CancellationToken ct) =>
G9CStreamTelemetry.Track(Produce(ct), "ChatHub.Subscribe", ct);
New metrics on the G9SignalRSuperNetCore meter: g9.signalr.stream_items, g9.signalr.stream_first_item_ms, g9.signalr.stream_duration_ms.
Fixed — a completed upload is verified, not assumed from its name. BeginAsync reported AlreadyCompleted = true whenever a file already existed under the requested name, without checking it against the declared size or SHA-256. Uploading a different file under a name already taken was therefore reported as complete though its bytes were never sent. The committed file must now match the declared length and hash; otherwise the call fails with G9_UPLOAD_NAME_CONFLICT. Resuming an upload id with different content (a different name, size or hash) fails with G9_UPLOAD_METADATA_CONFLICT rather than silently adopting the new numbers — a resume with a different chunk size is still fine, since that changes only how the bytes are carried.
Neither the upload service nor its hub methods carry an ownership parameter: authorizing who may begin, resume or download an upload remains the host's job, and path sanitisation is not object authorization.
2.6 — MessagePack hub protocol (opt-in), generated-client and registration fixes
Binary hub protocol, NativeAOT-safe. Two new opt-in packages, G9SignalRSuperNetCore.Server.MessagePack and G9SignalRSuperNetCore.Client.MessagePack, add SignalR's MessagePack protocol through Nerdbank.MessagePack. Its serializers come from PolyType type shapes generated at compile time, so it stays AOT- and trim-safe. Under Native AOT, Microsoft's SignalR supports only the JSON protocol.
- The server offers MessagePack next to JSON, and each connection picks its protocol in the handshake. Clients can therefore move one at a time.
- The JSON protocol base64-encodes every
byte[](+33%). MessagePack sends raw bytes. Measured:- a 2 MB file upload put 2.80 MB on the wire over JSON and 2.10 MB over MessagePack;
- G9SyncData's 500,000-row initial sync downloads 23.1 MB instead of 30.7 MB, with half the client allocations.
- The library adds shapes for its own hub types behind yours (file-transfer results and acknowledgements, presence events, binary chunks). You only declare shapes for your own types. See MessagePack hub protocol (opt-in).
Fixed — generated clients now use the client-library file-transfer types. A shared hub assembly references the server package, so hub signatures name G9SignalRSuperNetCore.Server.Classes.FileUpload.* DTOs. Up to 2.5.3 the generated client kept those server types.
SignalR binds a callback's arguments using the parameter types of the first handler registered for it. The generated UploadProgress(server DTO) listener therefore broke G9CFileUploader's own acknowledgement handler, which uses the client DTO: its cast failed and no server-acknowledged bytes were ever reported. Over MessagePack no acknowledgement arrived at all.
The generator now emits the identical G9SignalRSuperNetCore.Client.FileUpload.* twins (G9DtBeginUploadResult, G9DtUploadResult, G9DtBeginDownloadResult, G9DtUploadProgress, G9EUploadStatus). The wire format is unchanged. Source change: an override of a generated listener that takes one of these types must now use the client type (see 2.5 → 2.6).
Fixed — route constants in same-project hubs. For a hub declared in the consuming project, the generator only read a string literal from RoutePattern(). => Route (the convention the samples use) silently produced "/" + ClassName, so the typed client connected to the wrong path and received 404. The generator now resolves => Route / => MyHub.Route constants, and falls back to a const string Route before the class name, like it already did for hubs in referenced assemblies.
Fixed — service registration is idempotent per service collection, not per process. AddSignalRSuperNetCoreCore() and AddSignalRSuperNetCoreJwt(...) used process-wide flags. Every service collection after the first (a second host, a test server) silently ran without the hub filter, the user-id provider, the SignalR options or JWT authentication.
Tests and CI. A new G9SignalRSuperNetCore.Tests project runs a real Kestrel server with the typed client over both protocols. The pipeline now runs it before publishing; its old test step could never run, because a template expression tested a runtime variable.
2.5.3 — Client multi-targets netstandard2.1 (Unity)
Makes G9SignalRSuperNetCore.Client usable inside game engines and other runtimes that only expose the .NET Standard 2.1 API surface (notably Unity 6, whose scripting runtime cannot load net10 assemblies).
G9SignalRSuperNetCore.Clientnow multi-targetsnet10.0;netstandard2.1. The net10 build is unchanged (full feature set). The netstandard2.1 build is the engine/Unity-friendly surface.- Per-TFM dependencies: net10 keeps
Microsoft.AspNetCore.SignalR.Client10.x +Microsoft.Extensions.Http.Resilience; netstandard2.1 usesMicrosoft.AspNetCore.SignalR.Client8.x (ships a netstandard2.0 asset) and omitsHttp.Resilience(net8+ only). - netstandard2.1 build includes: the core typed client base (
G9SignalRSuperNetCoreClient), the JWT client base,G9CClientReconnectPolicy,G9DtConnectionState,G9DtAuthorizeResult— enough to connect, invoke, listen, auto-reconnect, and JWT-authenticate. - netstandard2.1 build omits (net10 only):
G9CHttpResilience(Polly negotiate hardening) and the resumable file upload/download client. - Portable rewrites: net7+ throw-helpers (
ArgumentException.ThrowIfNullOrEmpty,ArgumentNullException.ThrowIfNull) replaced with classic guards;ValueTask.CompletedTask→default; added anIsExternalInitpolyfill (under#if !NET5_0_OR_GREATER) soinit/record types compile on netstandard2.1. Behaviour on net10 is unchanged.
Unity usage: Unity does not restore NuGet directly and does not run the Roslyn source generator. Import the netstandard2.1 G9SignalRSuperNetCore.Client.dll and its transitive dependency closure into Assets/Plugins (e.g. via NuGetForUnity), and either hand-write the typed proxy/listeners against your shared hub interfaces or pre-generate them in a netstandard2.1 project and import the DLL. Add a link.xml to protect SignalR/G9 types from IL2CPP stripping.
Note for maintainers: packaged
LICENSE.md/ icon must use<None Include="..." Pack="true" PackagePath="" />(the<None Update=...>form does not pack under multi-target → NU5030/NU5046).
2.5.1 — Hub-filter single-constructor fix
Bug fix for 2.5.0.
- Fixed: registering the hub through
AddSignalRSuperNetCoreServerHub<…>()(or anyHubOptions.AddFilter<G9CHubFilter>()) threw at startup: "Multiple constructors accepting all given argument types have been found in typeG9CHubFilter. There should only be one applicable constructor." SignalR builds hub filters throughActivatorUtilities.CreateFactory, which rejects a filter type that exposes more than one DI-satisfiable constructor — and 2.5.0 added a second (parameterless) constructor alongside theILogger-taking one. - Resolution:
G9CHubFilternow has exactly one constructor with an optional logger argument —G9CHubFilter(ILogger<G9CHubFilter>? logger = null). DI resolution, the singleton registration,AddFilter<G9CHubFilter>(), and a manualnew G9CHubFilter()(unit tests) all keep working; the parameterless path still routes logging toNullLogger. No behaviour change to metrics or the 2.5.0 policy-rejection logging.
2.5 — Server policy-rejection logging
Additive, zero-config observability for the hub policy filter.
G9CHubFilternow emits a structuredWarning-level log entry on every policy rejection, so operators can see why a hub call or connect was refused instead of only observing the client-sideHubException. Previously rejections incremented a metric and threw, but produced no server log — a visibility gap for diagnostic / support scenarios.- The filter resolves an
ILogger<G9CHubFilter>from DI (wired automatically byAddSignalRSuperNetCoreCore()); a parameterless fallback routes toNullLoggersonew G9CHubFilter()andAddFilter<G9CHubFilter>()both keep working. TheG9CTelemetrymetrics are unchanged. - Event ids:
9100(per-invocation rejection — rate limit / role / claim / connection-required, withErrorCode,Method,ConnectionId,UserId,Detail) and9101(per-connect connection-limit rejection —Hub,Dimension,Key,Limit). Raise the minimum level for theG9SignalRSuperNetCore.Server.Classes.Filters.G9CHubFiltercategory to silence them. See Policy-rejection logging.
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.Run<T>(producer, options, ct)— runs a producer against a bounded channel and owns it: cancels it when the consumer stops, awaits it, and completes the channel WITH its exception so a failed producer faults the consumer instead of ending the stream quietly. Prefer it.G9CResilientStream.Create<T>(options)— the lower-level producer/consumer pair, for callers that hand the writer elsewhere. Completion and producer lifetime are then the caller's.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, released when the connection disconnects. 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. On a STREAMING method the span covers the invocation only (the method returns before the first item); wrap the stream withG9CStreamTelemetry.Track<T>to measure the enumeration.[G9AttrOneWay]— makes a generated no-result method fire-and-forget. Without it, awaiting one waits for the server (2.7.0).
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, g9.signalr.stream_items, g9.signalr.stream_first_item_ms, g9.signalr.stream_duration_ms. 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 |
G9SignalRSuperNetCore.Server.MessagePack |
Opt-in binary hub protocol for servers, offered next to JSON (2.6+) |
G9SignalRSuperNetCore.Client.MessagePack |
Opt-in binary hub protocol for clients (2.6+) |
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, the MessagePack packages included. G9SignalRSuperNetCore.Client additionally targets netstandard2.1 for Unity 6 / engine runtimes (see 2.5.3).
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
The library ships a Roslyn IIncrementalGenerator (G9SignalRSuperNetCore.SourceGenerator) that scans every project that consumes the library, finds your hubs, and emits a strongly-typed, AOT-safe client for each one. No reflection, no MethodInfo.Invoke, no Castle DynamicProxy.
How to wire it up
The generator ships embedded inside the G9SignalRSuperNetCore.Server package under analyzers/dotnet/cs. Three project layouts are supported:
Layout A — single project (rare; you can't do this if your client is a different platform than your server):
<ItemGroup>
<PackageReference Include="G9SignalRSuperNetCore.Server" Version="2.4.1" />
<PackageReference Include="G9SignalRSuperNetCore.Client" Version="2.4.1" />
</ItemGroup>
The generator runs on the project that defines the hub, emits the client into the same compilation, and you get Server, IChatHubMethods, IChatHubListeners, ChatHubClient all in one assembly.
Layout B — separate Server / Client projects with a Shared library (recommended):
MyApp.Shared/ // referenced by both Server and Client
ChatHub.cs // your hub class
IChatClient.cs // your listener interface
MyApp.Server/ // ASP.NET Core process
Program.cs
MyApp.Client/ // console / MAUI / WPF / Blazor
Program.cs
<ItemGroup>
<PackageReference Include="G9SignalRSuperNetCore.Server" Version="2.4.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Shared\MyApp.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyApp.Shared\MyApp.Shared.csproj" />
<PackageReference Include="G9SignalRSuperNetCore.Client" Version="2.4.1" />
</ItemGroup>
The generator runs in MyApp.Shared (because that's where the hub class lives) and the typed ChatHubClient is emitted into MyApp.Shared's compilation. MyApp.Client references the Shared library and gets the typed client for free.
Layout C — generator only on the client side (when the client project doesn't reference the shared library):
<ItemGroup>
<PackageReference Include="G9SignalRSuperNetCore.Server" Version="2.4.1"
PrivateAssets="all" />
<PackageReference Include="G9SignalRSuperNetCore.Client" Version="2.4.1" />
</ItemGroup>
PrivateAssets="all" keeps the analyzer (the generator) reachable but suppresses the runtime Server assembly so your client doesn't accidentally pull ASP.NET Core into a MAUI app.
What it generates
For a hub like:
public class ChatHub : G9AHubBase<ChatHub, IChatClient>
{
public const string Route = "/chat";
public override string RoutePattern() => Route;
/// <summary>Sends a message to everyone in the room.</summary>
public Task SendMessage(string user, string message)
=> Clients.All.ReceiveMessage(user, message);
public Task<List<string>> GetRecentMessages() => /* ... */;
}
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string user);
}
the generator emits (into obj/Generated/G9SignalRSuperNetCore.SourceGenerator/.../ChatHub.g.cs):
IChatHubMethods— methods interface mirroring the hub.Task,Task<T>,ValueTask,ValueTask<T>,IAsyncEnumerable<T>, andCancellationTokenparameters are all supported.IChatHubListeners— listener interface mirroringIChatClient.ChatHubClient— partial class wired toserverUrl + "/chat", with the typedServerproxy and default no-op listener overrides.ChatHubServerProxy— internal sealed class that turns each method onIChatHubMethodsinto a singleHubConnection.SendCoreAsync/InvokeCoreAsync<T>/StreamAsyncCore<T>call.
XML doc comments on hub methods and on the listener interface are forwarded into the generated code, so IntelliSense shows your descriptions on the client side.
How to use it
Subclass the generated {HubName}Client and override the listener methods you care about:
var client = new MyChatClient("https://localhost:7159");
await client.ConnectAsync();
await client.Server.SendMessage("Iman", "Hello");
List<string> recent = await client.Server.GetRecentMessages();
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;
}
}
Knobs and escape hatches
Exclude a hub method from generation:
[G9AttrExcludeFromClientGeneration]
public Task InternalDiagnostic() => Task.CompletedTask;
Deny a method by policy at the auth layer:
[G9AttrDenyAccess]
public Task DangerousAction() => Task.CompletedTask;
Inspect the generated source in your IDE: set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the consuming .csproj. The output appears under obj/Generated/G9SignalRSuperNetCore.SourceGenerator/G9SignalRSuperNetCore.SourceGenerator.G9HubClientGenerator/.
Diagnostics
The generator publishes stable diagnostic IDs so the IDE highlights bad hub signatures at design time:
| ID | Severity | Meaning |
|---|---|---|
G9001 |
Warning | Hub method returns an unsupported type. Use Task, Task<T>, ValueTask, ValueTask<T>, or IAsyncEnumerable<T>. |
G9002 |
Warning | Listener method returns something other than Task / ValueTask. |
G9003 |
Warning | Hub method has more than 16 parameters (SignalR limit). |
G9004 |
Warning | Generic hub methods are not supported. |
G9005 |
Warning | Hub class is missing a RoutePattern() override. |
G9006 |
Warning | Hub is missing the listener interface generic argument. |
G9007 |
Error | Internal generator error — please file an issue with the diagnostic message. |
The generator runs incrementally, so editing your hub updates the typed client on the next keystroke without a full rebuild.
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.
MessagePack hub protocol (opt-in)
Use it when payloads are large or binary: file transfer, sync pages, encrypted envelopes. The JSON protocol base64-encodes byte[] (+33%) and parses text. MessagePack sends bytes as bytes.
| Package | Adds |
|---|---|
G9SignalRSuperNetCore.Server.MessagePack |
services.AddG9SignalRSuperNetCoreMessagePack(shapes) / signalRBuilder.AddG9MessagePackProtocol(shapes). JSON stays available. |
G9SignalRSuperNetCore.Client.MessagePack |
hubConnectionBuilder.AddG9MessagePackProtocol(shapes), passed as a G9 client's customConfigureBuilder |
Both use Nerdbank.MessagePack, whose serializers are built from type shapes generated at compile time by PolyType. There is no reflection or dynamic code, so the protocol is NativeAOT- and trim-safe.
1. Declare shapes for every parameter, return and stream-item type of your hub methods and client callbacks. Do this once, in the shared assembly (it needs a PolyType package reference):
using PolyType;
[GenerateShapeFor<ChatMessage>]
[GenerateShapeFor<List<string>>]
[GenerateShapeFor<string>]
[GenerateShapeFor<int>]
public sealed partial class ChatWireShapes;
The library's own hub types need no entry: the packages chain their shapes behind yours. G9SignalRSuperNetCoreServerMessagePack.CombineShapes / G9SignalRSuperNetCoreClientMessagePack.CombineShapes build that chain, and LibraryShapes is what they add. The built-in types are:
- the file-transfer results and acknowledgements;
- presence events (server);
byte[],string,bool,intandlong.
2. Server — offer the protocol next to JSON:
builder.Services.AddSignalRSuperNetCoreCore();
builder.Services.AddG9SignalRSuperNetCoreMessagePack(ChatWireShapes.GeneratedTypeShapeProvider);
3. Client — opt in per connection:
var client = new ChatHubClient(serverUrl,
customConfigureBuilder: b => b.AddG9MessagePackProtocol(ChatWireShapes.GeneratedTypeShapeProvider));
Notes:
- Missing shape: a type without a shape fails when the first call using it is serialized, not at connect time. The test project shows how to assert that every type you exchange has a shape.
- Server without MessagePack: a MessagePack client connecting to a server that did not register it fails the handshake with
HubException: … The protocol 'messagepack' is not supported. - JWT authentication: keep the auth connection on JSON.
G9GetJwtHub.Authorizetakes an untypedobject, andG9DtAuthorizeResult.ExtraDatais anobject; neither has a type shape. Pass the MessagePack builder ascustomConfigureBuilderonly, notcustomConfigureBuilderForAuthServer. - Message size limits:
HubOptions.MaximumReceiveMessageSizecounts protocol bytes, so the same limit admits ~33% larger binary arguments over MessagePack. - Target framework: both packages target .NET 10 only. The netstandard2.1 (Unity) client build stays JSON-only.
- Wire format: it follows the SignalR MessagePack spec. DTOs are encoded as maps keyed by property name, so client and server types only need matching member names (the file-transfer twins rely on this).
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].
Policy-rejection logging
In addition to the metrics above, G9CHubFilter emits a structured Warning-level log entry every time a policy refuses an invocation, so an operator reading the server log can see why a call or connect was rejected (not just that the client received a HubException). The filter resolves an ILogger<G9CHubFilter> from DI; when constructed outside DI (new G9CHubFilter()), logging routes to NullLogger and only the metrics fire. Two event ids:
9100— per-invocation rejection (rate limit, role, claim, connection-required). Fields:ErrorCode,Method,ConnectionId,UserId,Detail.9101— per-connect rejection (connection limit). Fields:Hub,Dimension(per-user/per-ip),Key,Limit.
Filter these out by raising the minimum level for the G9SignalRSuperNetCore.Server.Classes.Filters.G9CHubFilter category if the warnings are noisy in your environment.
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
Start the client with --messagepack (or set G9_SAMPLE_PROTOCOL=messagepack) to connect over the binary protocol; the sample server offers both.
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 tests (real Kestrel server on loopback, typed client, JSON and MessagePack)
dotnet test G9SignalRSuperNetCore/G9SignalRSuperNetCore.Tests -c Release
# Run the sample web server
dotnet run --project G9SignalRSuperNetCore/G9SignalRSuperNetCore.WebServer -c Release
# Run the Consolonia console test harness (in another terminal; add -- --messagepack for the binary protocol)
dotnet run --project G9SignalRSuperNetCore/G9SignalRSuperNetCore.ConsoleClient -c Release
The Release build is warning-clean across all nine projects. CI runs through azure-pipelines.yml on main. It builds, runs the tests, and only then publishes the four NuGet packages.
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
│ ├── G9ClientTwins.cs # server file-transfer DTOs -> client twins
│ ├── G9Diagnostics.cs # G9001..G9007 stable IDs
│ └── AnalyzerReleases.{Shipped,Unshipped}.md # release-tracking metadata
├── G9SignalRSuperNetCore.Server.MessagePack/ # NuGet: opt-in MessagePack protocol (server) + library shapes
├── G9SignalRSuperNetCore.Client.MessagePack/ # NuGet: opt-in MessagePack protocol (client) + library shapes
├── G9SignalRSuperNetCore.Tests/ # xUnit: Kestrel + typed client, both protocols, registration
├── G9SignalRSuperNetCore.Sample.Shared/ # Sample hub + client interface + ChatWireShapes
├── G9SignalRSuperNetCore.WebServer/ # Sample server
└── G9SignalRSuperNetCore.ConsoleClient/ # Sample client (Consolonia TUI)
Migration guide
2.5 → 2.6
2.6 is additive apart from one source change in generated clients.
Generated file-transfer types. If you override a generated listener, or use a generated proxy method, whose signature names one of
G9SignalRSuperNetCore.Server.Classes.FileUpload.{G9DtBeginUploadResult, G9DtUploadResult, G9DtBeginDownloadResult, G9DtUploadProgress, G9EUploadStatus}, change it to the same name underG9SignalRSuperNetCore.Client.FileUpload. The typical case isUploadProgress:// before public override Task UploadProgress(G9SignalRSuperNetCore.Server.Classes.FileUpload.G9DtUploadProgress progress) { … } // after public override Task UploadProgress(G9SignalRSuperNetCore.Client.FileUpload.G9DtUploadProgress progress) { … }The wire format is unchanged, so 2.5 servers and 2.6 clients interoperate. After the change,
G9CFileUploaderreports server-acknowledged bytes again.Route constants. A hub declared in the same project as its typed client with
RoutePattern() => Routenow connects toRoute; before, it connected to/<ClassName>. If you worked around that by mapping the hub at/<ClassName>, map it atRouteinstead.Service registration now happens once per service collection. Nothing to change. Hosts built after the first one in a process now get the hub filter and JWT authentication.
MessagePack is opt-in; see MessagePack hub protocol (opt-in).
2.4 → 2.5
2.5 is additive and zero-config. Existing code keeps working unchanged. G9CHubFilter
gains a constructor that takes ILogger<G9CHubFilter>; DI supplies it automatically through
AddSignalRSuperNetCoreCore(), and a parameterless fallback (routing to NullLogger) keeps
new G9CHubFilter() working in tests. If you want the new policy-rejection warnings silenced,
raise the minimum level for the G9SignalRSuperNetCore.Server.Classes.Filters.G9CHubFilter
log category.
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, BenchmarkDotNet suite.
2.5 — Server policy-rejection logging (shipped).
G9CHubFilteremits structuredWarninglogs (event ids 9100 / 9101) on every policy rejection so operators can see why a call/connect was refused; DI-injectedILogger<G9CHubFilter>with aNullLoggerfallback. Metrics unchanged.
2.6 — MessagePack & fixes (shipped).
G9SignalRSuperNetCore.Server.MessagePack/.Client.MessagePack— opt-in, AOT-safe binary hub protocol with the library's own type shapes built in.- Generated clients use the client-library file-transfer twins (server acknowledgements reach
G9CFileUploaderagain); same-project hubs resolveRouteconstants. - Registration idempotent per service collection; test project run by CI before publishing.
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 — anddotnet testmust pass.- 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
- G9SignalRSuperNetCore.Client (>= 2.7.0)
- Nerdbank.MessagePack.SignalR (>= 1.3.85)
- PolyType (>= 1.4.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.