--- url: /reference/api.md description: >- The public API surface by package: server registration, HARRR, ServerMethods, ClientContext, ClientManager, IClientQuery, the backplane option builders, IClusterSubject, HARRRConnection on .NET and TypeScript, common types --- # API Overview This page lists the public API surface of SignalARRR across all packages. ## Server API (`Cocoar.SignalARRR.Server`) ### Registration ```csharp // IServiceCollection extension services.AddSignalARRR(options => options .AddServerMethodsFrom(assembly)); // Requires the Cocoar.SignalARRR.Server.Backplane.Redis package services.AddSignalARRRRedisBackplane(options => options .WithConnectionString("localhost:6379,abortConnect=false") .WithChannelPrefix("my-app") .WithNodeId("node-1")); // Requires the Cocoar.SignalARRR.Server.Backplane.Postgres package services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString("Host=db;Database=app;Username=app;Password=...") .WithSchema("signalarrr") .WithNodeId("node-1")); // An observable whose events reach subscribers on every node (cluster subjects) services.AddSignalARRRClusterSubject("orders"); // IEndpointRouteBuilder extension app.MapSignalARRRHub(path); app.MapSignalARRRHub(path, configureOptions); ``` ### HARRR (Hub base class) | Member | Type | Description | |--------|------|-------------| | `ServiceProvider` | `IServiceProvider` | DI container | | `Logger` | `ILogger?` | Logger instance | | `ClientContext` | `ClientContext` | Current client context | | `OnConnectedAsync()` | `Task` | Client connected (registers in ClientManager) | | `OnDisconnectedAsync(Exception?)` | `Task` | Client disconnected (unregisters) | **Hub methods** (wire protocol — called by clients internally): | Method | Description | |--------|-------------| | `InvokeMessage(ClientRequestMessage)` | Fire-and-forget | | `InvokeMessageResult(ClientRequestMessage)` | Returns result | | `SendMessage(ClientRequestMessage)` | Fire-and-forget (async void) | | `StreamMessage(ClientRequestMessage, CancellationToken)` | Server-to-client stream | | `StreamItemToServer(Guid, object)` | Client-to-server stream item | | `StreamCompleteToServer(Guid, string?)` | Client-to-server stream completion | ### ServerMethods / ServerMethods\ | Property | Type | Description | |----------|------|-------------| | `ClientContext` | `ClientContext` | Current client context | | `Context` | `HubCallerContext` | SignalR caller context | | `Clients` | `IHubCallerClients` | Client connections | | `Groups` | `IGroupManager` | Group management | | `Logger` | `ILogger` | Logger | ### ClientContext | Property | Type | Description | |----------|------|-------------| | `Id` | `string` | Connection ID | | `HARRRType` | `Type` | Hub type | | `RemoteIp` | `IPAddress?` | Client IP | | `User` | `ClaimsPrincipal` | Authenticated user | | `UserValidUntil` | `DateTime` | Token expiration | | `ConnectedAt` | `DateTime` | Connection time | | `ReconnectedAt` | `List` | Reconnection history | | `Attributes` | `ClientAttributes` | Custom key-value storage | | `ConnectedTo` | `Uri` | Hub URL | | Method | Description | |--------|-------------| | `GetTypedMethods()` | Get typed proxy to call this client | | `TryAuthenticate(MethodInfo)` | Validate token, challenge if expired | ### ClientManager | Method | Description | |--------|-------------| | `WithHub()` | Select hub — primary entry point for queries. Returns `IClientQuery` | | `GetClientById(string)` | Get client by connection ID | | `GetAllClients()` | All connected clients across all hubs — **this node only** | | `GetAllClients(predicate)` | Filter those clients | ### IClientQuery Returned by `WithHub()`. Describes a target set; with a backplane enabled, the filters and the send/invoke methods resolve across the whole cluster. | Method | Description | |--------|-------------| | `WithGroup(string)` | Narrow to a SignalR group — cluster-wide | | `WithUser(string)` | Narrow to a user's connections — cluster-wide | | `WithAttribute(string, string?)` | Narrow by connection attribute — cluster-wide | | `WithLocalFilter(predicate)` | Narrow by any predicate — confines the query to this node | | `SendAsync(action)` | Fire-and-forget to the whole target set | | `InvokeAllAsync(func)` | Invoke each client, collect all results | | `InvokeOneAsync(func)` | Invoke until the first success | | `LocalClients()` | The matching `ClientContext`s owned by this node | | `AddToGroupAsync(connectionId, groupName)` | Add client to SignalR group (tracked in `ClientContext.Groups`) | | `RemoveFromGroupAsync(connectionId, groupName)` | Remove client from group | | `GetConnectionsAsync()` | Connection snapshots for the selected hub | | `GetConnectionsByUserAsync(string)` | Connection snapshots for one user | | `GetConnectionsInGroupAsync(string)` | Connection snapshots for one group | | `GetConnectionsByAttributeAsync(string, string?)` | Connection snapshots matching an attribute | | `GetOnlineUsersAsync()` | User presence snapshots for the hub | | `IsUserOnlineAsync(string)` | Check whether a user is online | ### SignalARRRRedisBackplaneOptionsBuilder | Method | Description | |--------|-------------| | `WithConnectionString(string)` | Configure the Redis-compatible backend connection | | `WithChannelPrefix(string)` | Prefix all backplane keys/channels for app isolation | | `WithNodeId(string)` | Set a stable logical node identifier | | `WithInvokeTimeout(TimeSpan)` | Timeout for cross-node invoke aggregation | | `WithHeartbeatInterval(TimeSpan)` | Heartbeat interval for dead-node detection | | `WithNodeTimeout(TimeSpan)` | Time after which a node is considered stale | ### SignalARRRPostgresBackplaneOptionsBuilder | Method | Description | |--------|-------------| | `WithConnectionString(string)` | Npgsql connection string of the PostgreSQL primary | | `WithSchema(string)` | Schema for the backplane's tables; also names the notification channels, so it isolates applications sharing a database. Lowercase identifier, max 50 chars. Default `signalarrr` | | `WithAutoCreateSchema(bool)` | Create schema and tables on startup (default `true`); off, the tables must exist — see `SignalARRRPostgresBackplaneSchema.GetCreateScript` | | `WithCatchUp(bool)` | Replay messages missed while the subscription was down (default `true`); off, they are lost, as with Redis | | `WithMessageRetention(TimeSpan)` | How long messages stay replayable, and the longest outage catch-up covers in full. Default 5 minutes | | `WithNodeId(string)` | Set a stable logical node identifier | | `WithInvokeTimeout(TimeSpan)` | Timeout for cross-node invoke aggregation | | `WithHeartbeatInterval(TimeSpan)` | Heartbeat interval for dead-node detection | | `WithNodeTimeout(TimeSpan)` | Time after which a node is considered stale; must exceed the heartbeat interval | ### IClusterSubject\ Registered with `AddSignalARRRClusterSubject(name, configure?)`; resolved by `T`. See [Cluster-aware observables](/guide/server/backplane#cluster-aware-observables). | Member | Description | |--------|-------------| | `Name` | The cluster-wide name events are matched by | | `OnNext(T)` | Raise for local subscribers now and for the other nodes fire-and-forget | | `PublishAsync(T, CancellationToken)` | Raise and await the hand-off to the backplane | | `Subscribe(IObserver)` | It is an `IObservable`: use it with Rx operators or return it from a hub stream method | `ClusterSubjectOptions.SerializerOptions` sets the `JsonSerializerOptions` events cross the wire with (default: web defaults). ### SignalARRRPostgresBackplaneSchema | Member | Description | |--------|-------------| | `GetCreateScript(string schema = "signalarrr")` | The idempotent DDL (`IF NOT EXISTS`) for the backplane's schema, tables and indexes, for operators who apply it through migrations | ### Presence models | Type | Purpose | |------|---------| | `SignalARRRConnectionSnapshot` | Connection ID, node ID, user ID, groups, and attributes | | `SignalARRRUserPresenceSnapshot` | User ID plus aggregated connection IDs and node IDs | ## Client API (`Cocoar.SignalARRR.Client`) ### HARRRConnection | Static method | Description | |--------------|-------------| | `Create(Action, options?)` | Create from builder | | `Create(HubConnection, options?)` | Wrap existing connection | | Method | Description | |--------|-------------| | `GetTypedMethods()` | Get typed proxy for a contract interface | | `InvokeCoreAsync(message, ct)` | Call server, await typed result | | `SendCoreAsync(message, ct)` | Fire-and-forget | | `StreamAsyncCore(message, ct)` | Server-to-client stream | | `RegisterInterface(...)` | Register a server-to-client contract handler (instance, factory or type) | | `StartAsync(ct)` | Connect | | `StopAsync(ct)` | Disconnect | | `DisposeAsync()` | Dispose | | `AsSignalRHubConnection()` | Access raw HubConnection | | Property | Type | Description | |----------|------|-------------| | `ConnectionId` | `string?` | Connection ID | | `State` | `HubConnectionState` | Connection state | | `ServerTimeout` | `TimeSpan` | Server timeout | | `KeepAliveInterval` | `TimeSpan` | Keepalive interval | | `HandshakeTimeout` | `TimeSpan` | Handshake timeout | | Event | Description | |-------|-------------| | `Closed` | Fires when connection closes | | `Reconnecting` | Fires when reconnecting | | `Reconnected` | Fires when reconnected | ## TypeScript API (`@cocoar/signalarrr`) ### HARRRConnection ```ts class HARRRConnection { static create(builderOrConnection, options?): HARRRConnection invoke(methodName: string, ...args: unknown[]): Promise send(methodName: string, ...args: unknown[]): Promise stream(methodName: string, ...args: unknown[]): IStreamResult onServerMethod(methodName: string, handler: (...args) => unknown): this start(): Promise stop(): Promise asSignalRHubConnection(): signalR.HubConnection onClose(callback: (error?) => void): void onReconnecting(callback: (error?) => void): void onReconnected(callback: (connectionId?) => void): void connectionId: string | null state: HubConnectionState baseUrl: string serverTimeoutInMilliseconds: number keepAliveIntervalInMilliseconds: number } ``` ### Exported types ```ts export { HARRRConnection } from './harrr-connection.js' export { HARRRConnectionOptions } from './harrr-connection-options.js' export type { ClientRequestMessage } from './models/client-request-message.js' export type { ServerRequestMessage } from './models/server-request-message.js' export type { CancellationTokenReference } from './models/cancellation-token-reference.js' ``` ## Common types (`Cocoar.SignalARRR.Common`) ### ClientRequestMessage | Property | Type | Description | |----------|------|-------------| | `Method` | `string` | Method name (`ClassName.MethodName`) | | `Arguments` | `object[]` | Method arguments | | `Authorization` | `string?` | Bearer token | | `GenericArguments` | `string[]` | Generic type arguments | ### ServerRequestMessage | Property | Type | Description | |----------|------|-------------| | `Id` | `Guid` | Correlation ID for reply | | `Method` | `string` | Method name | | `Arguments` | `object[]` | Method arguments | | `GenericArguments` | `string[]` | Generic type arguments | | `CancellationGuid` | `Guid?` | Cancellation correlation ID | | `StreamId` | `Guid?` | Stream correlation ID | ### Attributes | Attribute | Description | |-----------|-------------| | `[SignalARRRContract]` | Marks an interface for proxy generation | | `[MessageName(string)]` | Override the default method name | ## Next steps * [Wire Protocol](/reference/wire-protocol) — message flow details * [Packages](/reference/packages) — package selection guide * [Getting Started](/guide/getting-started) — quick setup --- --- url: /guide/server/authorization.md description: >- [Authorize] at method, class and hub level; message-level token authentication with ChallengeAuthentication and the auth cache; transport-level authentication with client certificates, Negotiate and cookies; re-validation and mixed mode --- # Authorization SignalARRR integrates with ASP.NET Core's authorization system. Apply `[Authorize]` at the method, class, or hub level. SignalARRR supports two authentication modes: * **Message-Level** (Bearer, Basic, API Key) — token sent per message, automatic challenge/refresh on expiry * **Transport-Level** (client certificates, cookies, Windows/Negotiate) — authenticated at connection time, server-side re-validation on cache expiry ## Method-level authorization Apply `[Authorize]` to individual methods: ```csharp public class AdminMethods : ServerMethods, IAdminHub { [Authorize(Policy = "AdminOnly")] public Task DeleteUser(string userId) { ... } [Authorize(Roles = "Admin,Moderator")] public Task BanUser(string userId) { ... } [AllowAnonymous] public Task GetServerInfo() { ... } } ``` ## Class-level authorization Apply `[Authorize]` to the entire class — all methods require authentication: ```csharp [Authorize] public class SecureMethods : ServerMethods, ISecureHub { public Task GetSecret() { ... } // requires authentication [AllowAnonymous] public Task GetPublicData() { ... } // opt-out for this method } ``` ## Hub-level inheritance If the hub class itself has `[Authorize]`, all `ServerMethods` classes inherit it automatically: ```csharp [Authorize] public class SecureHub : HARRR { public SecureHub(IServiceProvider sp) : base(sp) { } } // All methods in this class require authentication, inherited from the hub public class SecureMethods : ServerMethods, ISecureHub { ... } ``` ## Message-Level Authentication (Tokens) ### Authentication setup Configure ASP.NET Core authentication as usual. SignalARRR reads the `Authorization` header from client requests: ```csharp builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidIssuer = "your-issuer", ValidAudience = "your-audience", IssuerSigningKey = new SymmetricSecurityKey(key) }; }); builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")); }); ``` ### Where the connection token travels The connection token — the one `[Authorize]` on the hub class checks — goes out with the negotiate request and with every request of the transport. Clients send it as an `Authorization` header wherever the platform lets them. Only a browser cannot: JavaScript can set no header on a WebSocket upgrade or an `EventSource`, so SignalR puts the token into the URL as `access_token` there. | Client | negotiate | WebSocket | Server-Sent Events | Long Polling | |--------|-----------|-----------|--------------------|--------------| | .NET | header | header | header | header | | .NET Framework | header | header | header | header | | TypeScript in the browser | header | URL | URL | header | | TypeScript under Node | header | header | URL | header | | Swift | header | header | header | header | | Kotlin | header | header | header | header | The .NET, .NET Framework and TypeScript rows are SignalR's own clients; SignalARRR hands them the token unchanged. Swift and Kotlin can be told to use the URL instead (`transportCredential` `.query` / `QUERY`) for a server that reads the token only there. Authentication handlers read the header, not the URL. To also accept a token from the URL, add the middleware before authentication: ```csharp app.UseSignalARRRAccessTokenValidation(); // access_token query → Authorization header app.UseAuthentication(); app.UseAuthorization(); ``` On SignalR endpoints it copies `access_token` into the `Authorization` header — only when the request carries no `Authorization` header of its own; a header the client sent always wins. Leave it out if no browser client connects, and a token in a URL is never accepted. ### The two credentials A client connection carries two credentials, checked by different things. Every SignalARRR client names them the same way and treats them the same way: | | .NET and .NET Framework | TypeScript | Swift | Kotlin | |---|---|---|---|---| | Connection credential | `WithConnectionCredential` | `connectionCredential` | `connectionCredential` | `connectionCredential` | | Message credential | `WithMessageCredential` | `messageCredential` | `messageCredential` | `messageCredential` | | One credential for both | `WithCredential` | `credential` | `credential` | `credential` | | Credential | Authenticates | Travels as | Checked by | When it expires | |---|---|---|---|---| | Connection | negotiate and the transport | `Authorization` header — see [where the connection token travels](#where-the-connection-token-travels) for the browser exception | `[Authorize]` on the hub class, `.RequireAuthorization()` on the mapping | fetched again on every connect and reconnect; a running connection keeps the identity it negotiated | | Message | every message, the answer to a challenge, file transfers | the `Authorization` field of the message; a header on file transfers | `[Authorize]` on a method or a `ServerMethods` class | fetched on every use; the server re-checks it once the [auth cache](#auth-cache) lapses and challenges a running stream for a fresh one | Nothing is coupled implicitly: a client that sets only the message credential connects anonymously, one that sets only the connection credential sends no message credential. Usually both are the same token, which is what the `credential` shortcut is for. They are separate because they are not always the same thing — a single-use connection ticket belongs on the connection and has no business being resent with every message. A credential set in two places — a former option next to a new one, or the connection credential through both SignalR and SignalARRR — is an error, not a silent choice. ```csharp // .NET var connection = HARRRConnection.Create( builder => builder.WithUrl("https://localhost:5001/apphub"), options => options.WithCredential(async () => await tokenStore.GetTokenAsync())); ``` ```ts // TypeScript const connection = HARRRConnection.create('https://localhost:5001/apphub', { credential: async () => await getAuthToken(), }); ``` The client guides show the details for each: [.NET](/guide/dotnet-client/connection-setup#connection-with-authentication), [TypeScript](/guide/typescript-client/setup#authentication), [Swift](/guide/swift-client/setup#authentication), [Kotlin](/guide/kotlin-client/setup#authentication). The .NET Framework client takes the same options as .NET: `HARRRConnection.Create(builder => builder.WithUrl(...), options => options.WithCredential(...))`. ### When the auth cache expires When a client's token expires during an active connection, SignalARRR doesn't disconnect it. What happens next depends on whether there is a message to carry a fresh credential. **On an ordinary call**, there is. Every client-to-server message already carries the credential in its `Authorization` field, so the server simply validates that one against the configured scheme and continues — no round trip, nothing for the client to do beyond having configured a credential. That validation runs against a context built for the purpose, since a message over an open socket is not an HTTP request. It carries the credential **and the connection's own request facts** — host, scheme, path, and the `HttpContext.Items` your middleware stamped before authentication ran — captured when the connection was established. So a handler that resolves anything from where the request arrived, a per-tenant issuer set being the common case, works per message exactly as it did at negotiate. **On a running stream**, there is not. Authorization is re-checked for every streamed element, and those elements are not messages the client sends — so the server has to ask: 1. Server detects the cached authentication has expired while the stream is running 2. Server sends `ChallengeAuthentication` to the client (via SignalR's native client results) 3. The client's message credential (`WithMessageCredential`, `messageCredential` — see [the two credentials](#the-two-credentials)) is called 4. Client returns the credential directly from the handler 5. Server validates it, extends the cache, and the stream continues This is the only path that challenges. It happens transparently — no client-side code beyond configuring the credential. ### If the client has no credential to give A client can legitimately authenticate its connection and nothing else — a certificate, a cookie, a bearer token passed only as the connection credential. When the cache expires there is nothing to validate and nothing to ask for, so SignalARRR falls back to the principal the connection was established with, exactly as SignalR would. Such a client is challenged once, answers with nothing, and is not asked again — otherwise a stream would cost a round trip per element for an answer that is never going to change. It is asked again as soon as a message does arrive carrying a credential, so a user signing in mid-connection is picked up. With one addition: **the expiry that principal states is honoured.** If it carries an `exp` claim in the past, the call is rejected. Plain SignalR never looks at `exp` again once negotiate is done, so a token that expired hours ago keeps working there until the socket drops; here it does not. That gives three levels, and none of them is weaker than SignalR: | What the client configures | On cache expiry | Catches | |---|---|---| | A message credential | validated fresh against the scheme | expiry **and** revocation | | A transport-level scheme (see below) | `ITransportAuthRevalidationService` — certificate chain, revocation, your own checks | whatever you check | | Neither | cached principal, `exp` enforced | expiry | Revocation cannot be caught in the third row: noticing it requires a credential to re-check, and there is none. If that matters, configure one of the first two. When `[Authorize]` is used without specifying a scheme (the common case), SignalARRR automatically uses the default authentication scheme configured via `AddAuthentication()`. ## Transport-Level Authentication (Certificates, Negotiate, Cookies) For scenarios where credentials exist at the transport layer (TLS client certificates, Windows/Negotiate, HTTP cookies), SignalARRR supports **transport-level authentication**. The client authenticates once at connection time, and the server re-validates credentials server-side when the auth cache expires — no challenge round-trip needed. ### How it works 1. Client connects with transport credentials (e.g., client certificate via mTLS) 2. ASP.NET Core authenticates during SignalR negotiate — `ClientContext.User` is set 3. SignalARRR **auto-detects** transport-level auth (client cert present, or Negotiate/NTLM/Kerberos/Windows auth type) 4. On cache expiry: server re-validates the stored credentials server-side (no challenge to client) 5. If re-validation fails (cert expired or revoked, ticket past its stated expiry) → request is rejected ### Cookies and other schemes are opt-in Auto-detection covers only credentials that are unmistakably bound to the connection. A cookie identity looks exactly like a bearer identity once it has become a `ClaimsPrincipal`, and treating a bearer one as connection-bound would let a token outlive its own expiry — so SignalARRR does not guess. Declare the scheme instead: ```csharp builder.Services.AddSignalARRR(options => options .WithConnectionBoundSchemes(CookieAuthenticationDefaults.AuthenticationScheme)); ``` Adding a scheme is a statement that the credential lasts as long as the connection. What it buys you is **active re-validation**: once the cache lapses the server runs `ITransportAuthRevalidationService` for that connection rather than falling back to the principal it negotiated with. ```csharp public class SessionRevalidation : ITransportAuthRevalidationService { public async Task RevalidateAsync( ClientContext client, CancellationToken cancellationToken = default) { var session = await _sessions.LookupAsync(client.UserIdentifier, cancellationToken); if (session is null || session.Revoked) return RevalidationResult.Abort(); // refuse, and drop the connection if (session.ExpiresAt < DateTimeOffset.UtcNow) return RevalidationResult.Deny(); // refuse this call, leave the socket up return RevalidationResult.ValidForDuration(TimeSpan.FromSeconds(30)); } } ``` Three things it can say: | | Meaning | |---|---| | `Valid()` / `ValidForDuration(...)` | the credential holds; the duration overrides `AuthCacheDuration` for this connection, which is how one hub can re-check a reference token every few seconds and a browser cookie every few minutes | | `Deny()` | refuse this call, leave the connection open | | `Abort()` | refuse it **and drop the connection** — for a push connection, a client that believes it is connected while the server serves it nothing is worse than a client that knows it is gone | Returning a `bool` still works and means `Valid()` or `Deny()`. `ClientContext.Abort()` is public, so an application that learns a credential died outside this pipeline — a background watchdog, a revocation webhook — can drop the connection itself. It is safe to call more than once and after the connection has already gone. Without it a cookie client is not broken — it takes the fallback described above and keeps working on its negotiated principal, with that principal's stated expiry enforced. Declaring the scheme is what lets you check more than an `exp` claim: a session store lookup, a revocation list, whatever your `ITransportAuthRevalidationService` implements. The built-in one checks the ticket's expiry, and the certificate chain when there is a certificate. ### Client certificate authentication #### Server setup Configure Kestrel for client certificates and add an authentication handler: ```csharp builder.WebHost.UseKestrel(kestrel => { kestrel.Listen(IPAddress.Any, 5001, listenOptions => { listenOptions.UseHttps(https => { https.ServerCertificate = serverCert; https.ClientCertificateMode = ClientCertificateMode.AllowCertificate; }); }); }); builder.Services.AddAuthentication("Certificate") .AddCertificate(options => { options.AllowedCertificateTypes = CertificateTypes.All; options.RevocationMode = X509RevocationMode.Online; }); ``` #### .NET Client Configure the client certificate on the connection — no connection credential needed: ```csharp var cert = new X509Certificate2("client.pfx", password); var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://server:5001/apphub", options => { options.ClientCertificates = new X509CertificateCollection { cert }; options.HttpMessageHandlerFactory = handler => { if (handler is SocketsHttpHandler socketsHandler) { socketsHandler.SslOptions.ClientCertificates = new X509CertificateCollection { cert }; } return handler; }; }); }); ``` ::: tip No token needed With transport-level auth, neither credential is required: SignalARRR detects that the client is authenticated by the connection and re-validates server-side instead of asking it for a token. A message credential (`WithMessageCredential` or `WithCredential`) is what a token-authenticated client needs; a certificate-authenticated one does not. ::: ### Certificate re-validation When the auth cache expires, the server re-validates the stored client certificate: * **Expiry check**: `NotBefore` / `NotAfter` dates * **Revocation check**: CRL/OCSP (configurable) * **Custom validation**: Pluggable callback for custom logic Configure via `SignalARRRServerOptions`: ```csharp builder.Services.AddSignalARRR(options => options .AddServerMethodsFrom(typeof(Program).Assembly) .WithCertificateRevocationCheck(true) // default: true .WithCertificateRevocationMode(X509RevocationMode.Online) // default: Online .WithCustomCertificateValidator(cert => // optional { // Custom logic, e.g., check against an internal revocation list return !IsRevoked(cert.Thumbprint); })); ``` ### Custom re-validation service For full control over transport-auth re-validation, implement `ITransportAuthRevalidationService`: ```csharp public class MyRevalidationService : ITransportAuthRevalidationService { public async Task RevalidateAsync( ClientContext clientContext, CancellationToken cancellationToken = default) { if (clientContext.ClientCertificate != null) { // Check your internal revocation database, OCSP responder, etc. return await CheckCertificateStatus(clientContext.ClientCertificate); // bool converts } // Non-cert transport auth (cookies, Negotiate) return clientContext.User.Identity?.IsAuthenticated == true; } } // Register before AddSignalARRR (TryAddSingleton won't override your registration) builder.Services.AddSingleton(); ``` ### Certificate rotation To rotate certificates without restarting the application: 1. Update the certificate file on disk 2. Reconnect the SignalR connection — the new TLS handshake uses the new certificate 3. Server validates the new certificate on connect ```csharp // Client-side cert rotation await connection.StopAsync(); // Certificate file has been updated on disk — reload it cert = new X509Certificate2("client.pfx", password); await connection.StartAsync(); // new TLS handshake with new cert ``` ### Mixed mode A single hub can serve both token-based and certificate-based clients simultaneously. SignalARRR detects the authentication mode per client: ```csharp [Authorize] public class AppHub : HARRR { public AppHub(IServiceProvider sp) : base(sp) { } } ``` * Client A connects with a token and a message credential → message-level auth: the credential travels with every call and is re-validated once the cache lapses, and a running stream is challenged for a fresh one * Client B connects with a client certificate → transport-level auth: re-validated server-side, no credential in the message, never challenged * Client C connects with a token but configures no message credential → runs on the principal it negotiated with, with that principal's stated expiry enforced ## Auth cache Authentication results are cached per client (default: 3 minutes). When a client connects to a hub with `[Authorize]`, the cache is initialized from the SignalR negotiate authentication, so the first method call uses the cached principal without triggering a challenge or re-validation. The cache duration is configurable: ```csharp builder.Services.AddSignalARRR(options => options .AddServerMethodsFrom(typeof(Program).Assembly) .WithAuthCacheDuration(TimeSpan.FromMinutes(5))); ``` The cache duration controls how often credentials are re-checked — for both token-based and transport-level auth. ## ClientContext user data Inside `ServerMethods`, access the authenticated user through `ClientContext`: ```csharp public Task GetUserInfo() { var user = ClientContext.User; var name = user.FindFirst(ClaimTypes.Name)?.Value; var roles = user.FindAll(ClaimTypes.Role).Select(c => c.Value); return Task.FromResult($"{name} ({string.Join(", ", roles)})"); } ``` ## Next steps * [Client Manager](/guide/server/client-manager) — query authenticated clients * [Connection Setup (.NET)](/guide/dotnet-client/connection-setup) — configure token providers * [TypeScript Setup](/guide/typescript-client/setup) — authentication in the TypeScript client --- --- url: /guide/server/backplane.md description: >- Multi-node clustering with the Redis-compatible or PostgreSQL backplane: choosing between them, catch-up after a subscription drop, schema and permissions, cluster subjects for cluster-wide observables, distributed operations, presence and cluster semantics --- # Backplane & Clustering SignalARRR runs in pure in-memory single-node mode by default. If you never configure a backplane, all client tracking, groups, and filters stay local to the current process. For multi-node deployments, add one of the two backplane packages: Redis-compatible, or PostgreSQL. ## Choosing a backplane Both packages provide the same cluster behaviour — every operation listed under [Supported distributed operations](#supported-distributed-operations) works identically on either. They differ in what they ask of your infrastructure and in how much traffic they carry. | | `Backplane.Redis` | `Backplane.Postgres` | |---|---|---| | Needs | A Redis, Valkey or Garnet instance | The PostgreSQL primary your application already uses | | Transport | Pub/Sub, in memory | `LISTEN`/`NOTIFY`, envelopes in an unlogged table | | Registry | Hash and sets per key | One row per connection, one per node | | After a subscription drop | Messages published in between are lost | Replayed from the node's cursor, in order (catch-up, on by default) | | Throughput ceiling | Hundreds of thousands of cross-node messages per second | Low thousands per second; `NOTIFY` serializes at commit and each envelope costs a row write | | Latency | Sub-millisecond | Single-digit milliseconds | | Housekeeping | Key TTLs | Sweeps run by the nodes themselves | | Best for | High realtime volume, or Redis already in the stack | Deployments whose only stateful dependency is Postgres, or that want a reconnect to miss nothing | Pick Redis when cross-node volume is a design parameter. Pick Postgres when you would otherwise be adding a second stateful service — its ceiling is two orders of magnitude above what an application-level backplane sees in practice, and one fewer service to run, back up, monitor and secure is a real saving — or when a node that briefly loses its subscription must not miss anything: Pub/Sub has no history to read back, a table does. Switching later is a package reference and one registration call; nothing else in the application changes. ## Redis-compatible backplane The backplane ships separately, so that single-node applications — the majority — do not carry `StackExchange.Redis` and its transitive closure for a feature they never switch on: ```xml ``` ::: warning Moved out of the server package in 5.0 In 4.x this came with `Cocoar.SignalARRR.Server`. If you use `AddSignalARRRRedisBackplane`, add the package reference above — nothing else changes, the method and its options are unchanged and stay in the same namespace. ::: ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddSignalR(); builder.Services.AddSignalARRR(b => b.AddServerMethodsFrom(typeof(ChatHub).Assembly)); builder.Services.AddSignalARRRRedisBackplane(options => options .WithConnectionString("localhost:6379,abortConnect=false") .WithChannelPrefix("chat-prod") .WithNodeId($"{Environment.MachineName}-api-1")); ``` The provider is **Redis-compatible**, not Redis-specific. It works with: * Redis * Valkey * Garnet `WithChannelPrefix` isolates one application's keys and channels from another's on a shared instance. ## PostgreSQL backplane ```xml ``` ```csharp builder.Services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString(builder.Configuration.GetConnectionString("Default")!) .WithSchema("signalarrr") .WithNodeId($"{Environment.MachineName}-api-1")); ``` That is the whole setup. On startup the backplane creates its schema and tables if they are missing, registers the node, and subscribes to its notification channels. ### How it works Every node holds one dedicated connection in `LISTEN` on two channels, `{schema}_commands` and `{schema}_responses`. A broadcast, a targeted call or a cluster query is a `NOTIFY` on the commands channel; the reply to a call travels back on the responses channel. Connection registrations and node heartbeats are rows in the `connections` and `nodes` tables, and liveness is judged on the database clock alone, so the nodes need not agree on the time. Every envelope is written to the unlogged `messages` table in the same transaction as the `NOTIFY`, which carries only the row id, the origin node and the target node. Each receiving node decides from that alone whether the row concerns it and, if so, reads it. Receivers never delete rows; a retention sweep on every heartbeat removes rows older than `MessageRetention`, five minutes by default. Postgres delivers notifications after commit, so a row is always visible by the time it is read. That write and read per message is why the throughput ceiling above is lower than Redis's, and it is what makes catch-up possible. A publish is one call to the `publish` function the schema script creates: it takes a transaction-scoped advisory lock, inserts the row and notifies. The lock serializes publishes so that message ids are assigned in commit order. Without it, a row inserted first could commit last, and a node whose cursor had already moved past its id would never replay it. Postgres serializes committing `NOTIFY` transactions anyway, so the lock costs no throughput — and it makes the cross-node order of messages match the id order exactly. With catch-up off, envelopes under 8 kB — the notification payload limit — travel inline and only larger ones take the table; a subscription drop then loses what was published in between, exactly as with Redis. ### Catch-up after a subscription drop Each node remembers the id of the last message it saw. When its listener connection drops — a failover, a proxy idle-timeout, a network blip — the node reconnects with backoff, resubscribes first, and then reads every row past its cursor that is addressed to it, in id order, before it resumes live delivery. Rows that show up both in that backlog and as a live notification are recognized by id and handed on once. Because ids are assigned in commit order, "every row past the cursor" is exactly what the node has not seen. Nothing is delivered twice, and the order a client sees is the order the messages were published in. This holds for every kind of traffic the backplane carries: a push that fell into the gap arrives late rather than never, a group command is applied, a cluster query is answered if the asking node is still waiting. A fresh node starts at the current end of the table; it serves no connections yet, so nothing before it subscribed can concern it. The retention is the limit. An outage longer than `MessageRetention` is reported as a gap — a warning naming the outage length and a `signalarrr.backplane.catch_up.gaps` counter — rather than passed over silently; that silence is the failure mode catch-up exists to remove. Size the retention to the longest subscription outage you want to survive in full: ```csharp builder.Services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString(connectionString) .WithMessageRetention(TimeSpan.FromMinutes(15))); // Or opt out and take the Redis contract: inline notifications, no replay. builder.Services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString(connectionString) .WithCatchUp(false)); ``` Two counters make the behaviour visible: `signalarrr.backplane.listener.reconnects` (each one a window in which messages were missed) and `signalarrr.backplane.messages.replayed` (what catch-up read back). Both are on the `Cocoar.SignalARRR` meter. ### Schema and permissions The backplane needs `CREATE` on the database the first time it starts, to create the schema and its tables. Afterwards, `SELECT`, `INSERT`, `UPDATE` and `DELETE` on those tables and the ability to `LISTEN` and `NOTIFY` are enough. If your database role does not have `CREATE`, or you apply every schema change through migrations, switch automatic creation off and run the script yourself: ```csharp builder.Services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString(connectionString) .WithAutoCreateSchema(false)); // The DDL, for your migration tooling. Idempotent: every statement is IF NOT EXISTS. var ddl = SignalARRRPostgresBackplaneSchema.GetCreateScript("signalarrr"); ``` With automatic creation off, startup fails with a clear message if the tables are missing. The schema is the unit of isolation: it names the tables *and* the notification channels, so two applications sharing one database must use two schemas. Schema names are lowercase letters, digits and underscores, at most 50 characters — the channel names are derived from it and Postgres caps identifiers at 63 bytes. ### What to know before running it * **Primary only.** `NOTIFY` is not replicated, so the connection string must point at the primary, not a read replica. * **Direct connection for the listener.** `LISTEN` needs a session that stays open. A transaction-pooling PgBouncer cannot provide one; point the backplane at Postgres directly, or use session pooling for it. Startup fails within 30 seconds if the subscription cannot be established, naming this as the likely cause. * **Connections per node.** One long-lived listener connection, plus ordinary pooled connections for publishing and registry lookups — count them when sizing `max_connections`. * **A subscription drop is recovered, not survived.** If the listener connection drops, the node reconnects with backoff and replays what it missed (see above); with catch-up off it misses it, as with Redis. The registry lives in tables and loses nothing either way. The health check reports the node unhealthy while its listener is down. * **Sweeps instead of TTLs.** Node timeouts, orphaned registrations and message retention are cleaned up by the nodes on every heartbeat; there is nothing for you to schedule. ::: info Custom backplanes are not a supported extension point The contracts behind this — `ISignalARRRBackplane` and `ISignalARRRConnectionRegistry` — are internal, so a backplane for NATS or RabbitMQ cannot be plugged in from outside the library today. They are registered with `TryAddSingleton`, which makes the order of `AddSignalARRR` and the backplane registration irrelevant; read that as order-independence, not as an invitation. Opening them up would mean freezing the inter-node envelope as public API, so it is a deliberate decision rather than a side effect. If you need another transport, say so — internal can be opened later without breaking anyone, the reverse cannot. The Redis and Postgres packages share one internal base for everything that is not transport or storage, so a third first-party transport is a smaller job than it looks. ::: ## Cluster-aware observables The backplane routes the *target* of a send: a push to a connection, a group or a user finds the node that holds it. It never saw the *source* of a server stream. A hub method that returns an `IObservable` fed by an in-process subject — an event dispatcher, a change feed — streams only what its own process raised, and a client on another node never sees those events. Applications built in that subscribe style got nothing from the backplane and had to relay events themselves. A **cluster subject** closes the gap. It is an observable whose events reach subscribers on every node, relayed over the backplane transport already in place: ```csharp // Registration — one subject per event type, the name is cluster-wide builder.Services.AddSignalARRRPostgresBackplane(...); builder.Services.AddSignalARRRClusterSubject("orders"); // Producer, anywhere in the application public sealed class OrderService(IClusterSubject orders) { public async Task ChangeAsync(Order order) { // ... orders.OnNext(new OrderChanged(order.Id, order.Version)); // local now, other nodes fire-and-forget } } // Hub: a server stream that is cluster-wide without knowing it public sealed class OrdersHub(IClusterSubject orders) : HARRR { public IObservable Subscribe(string tenant) => orders.Where(e => e.Tenant == tenant).Select(Map); } ``` What the subject guarantees: * **Once locally, once remotely, never echoed.** Local subscribers see an event from `OnNext`; subscribers on other nodes see it from the relay; a received event is never relayed again. Each browser sees every event exactly once, from the node its connection is pinned to. * **In order.** Events raised on one node arrive on the others in the order they were raised — one relay loop per subject, and sequential hand-off on the receiver. * **Fire-and-forget for the producer.** `OnNext` does not wait for the network; a relay failure is logged, it does not fail the request. `PublishAsync` is the awaited variant for callers that want to know the backplane has the event. * **No type names on the wire.** The event type is fixed at registration. A node deserializes into it or drops the event with a warning, so a rolling update with mixed builds cannot make a node materialize a type it does not know. Polymorphic payloads are yours to configure through `ClusterSubjectOptions.SerializerOptions`. * **Delivery is the backplane's.** Transient with Redis; replayed after a subscription drop with the Postgres catch-up. Without a backplane, the subject is a plain local one and nothing changes. One subject per event type: `IClusterSubject` is resolved by `T`. Two subjects with the same name are refused at startup, because the name is how the nodes match events to subjects. ## Supported distributed operations With the backplane enabled, the following become cluster-aware: | Operation | Cluster behavior | |---|---| | `GetTypedMethods(connectionId)` | Routes send/invoke to the node that owns the connection | | `WithHub().SendAsync(...)` | Broadcasts across all nodes | | `WithGroup(...)` | Resolves remote group members too | | `WithUser(...)` | Targets all connections for the user across nodes | | `WithAttribute(...)` | Resolves matching connections across nodes | | `InvokeAllAsync(...)` | Collects results from matching clients across the cluster | | `InvokeOneAsync(...)` | Returns the first successful result across the cluster | | `AddToGroupAsync(...)` / `RemoveFromGroupAsync(...)` | Works for local and remote connections | | `IClusterSubject.OnNext(...)` | Reaches the subject's subscribers on every node | ## Presence APIs `ClientManager` exposes cluster-aware presence snapshots: ```csharp var allConnections = await clients.GetConnectionsAsync(); var aliceConnections = await clients.GetConnectionsByUserAsync("alice"); var documentEditors = await clients.GetConnectionsInGroupAsync("doc-123"); var admins = await clients.GetConnectionsByAttributeAsync("role", "admin"); var onlineUsers = await clients.GetOnlineUsersAsync(); var isAliceOnline = await clients.IsUserOnlineAsync("alice"); ``` Without a backplane, these APIs fall back to local in-memory state only. ## Cluster semantics ### Single-node compatibility Backplane support is fully opt-in. Existing single-node applications keep the old in-memory behavior unless a backplane is configured. ### Transient delivery The backplane distributes **live** traffic. It is not a durable queue, event store, or replay log. ### Eventual convergence Connection metadata, groups, user mappings, and attributes propagate quickly, but not atomically. Right after: * a new connection, * a disconnect, * a remote group join/leave, * or an attribute/user change visible on reconnect there can be a short convergence window before every node sees the same routing state. ### Node failure cleanup Each node publishes a heartbeat. When a node stops heartbeating, other nodes actively sweep and remove its registrations. A node that starts under a node id the store still knows — a crashed predecessor that never said goodbye — clears those stale registrations first, so a stable node id across restarts is safe. You can tune cleanup behavior on either backplane: ```csharp builder.Services.AddSignalARRRRedisBackplane(options => options .WithConnectionString("localhost:6379") .WithHeartbeatInterval(TimeSpan.FromSeconds(5)) .WithNodeTimeout(TimeSpan.FromSeconds(20))); builder.Services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString(connectionString) .WithHeartbeatInterval(TimeSpan.FromSeconds(5)) .WithNodeTimeout(TimeSpan.FromSeconds(20))); ``` Lower values remove stale registrations faster after crashes, but also increase sensitivity to short pauses. The node timeout must be longer than the heartbeat interval; the Postgres backplane refuses a configuration where it is not. ## When to use it Use a backplane when you need: * multiple app instances behind a load balancer, * user targeting across nodes, * shared group membership, * cluster-wide presence, * or distributed `InvokeAllAsync` / `InvokeOneAsync`. Stay with in-memory mode when you only run a single app instance and want the simplest setup. --- --- url: /guide/advanced/cancellation.md description: >- Server-initiated cancellation of client operations: pass a CancellationToken to a client method and cancel it remotely, AbortSignal on the TypeScript client, and the CancelTokenFromServer flow underneath --- # Cancellation Propagation SignalARRR supports server-initiated cancellation of client operations. The server can pass a `CancellationToken` to a client method and cancel it remotely. On the TypeScript client, `CancellationToken` is converted to an `AbortSignal`. ## Server side When a server method calls a client method with a `CancellationToken` parameter, SignalARRR serializes a `CancellationTokenReference` marker and tracks it: ```csharp public async Task StartLongRunningTask() { using var cts = new CancellationTokenSource(); var client = ClientContext.GetTypedMethods(); // The CancellationToken is propagated to the client var task = client.ProcessData("data", cts.Token); // Cancel after 5 seconds cts.CancelAfter(TimeSpan.FromSeconds(5)); await task; } ``` When `cts.Cancel()` is called, the server sends a `CancelTokenFromServer` message to the client with the matching cancellation GUID. ## .NET client Declare the `CancellationToken` on the contract, and the handler receives a standard `CancellationToken`: ```csharp [SignalARRRContract] public interface IWorkerClient { Task ProcessData(string data, CancellationToken ct); } public class WorkerClientHandler : IWorkerClient { public Task ProcessData(string data, CancellationToken ct) { while (!ct.IsCancellationRequested) { // Process chunks... } return Task.FromResult("completed"); } } // Before StartAsync() connection.RegisterInterface(); ``` The token is also cancelled when the connection itself drops, so a handler notices its caller's world ending even if the server never sends an explicit cancellation. ## TypeScript client On the TypeScript client, `CancellationToken` parameters are converted to `AbortSignal`: ```ts // The contract's wire name, not the bare method name — see /guide/server/contracts-wire-names connection.onServerMethod('MyApp.Contracts.IWorkerClient|ProcessData', async (data: string, signal: AbortSignal) => { for (let i = 0; i < 100; i++) { if (signal.aborted) { throw new Error('Cancelled'); } await processChunk(data, i); } return 'done'; }); ``` ## How it works ```mermaid sequenceDiagram participant Server participant Client Server->>Client: InvokeServerRequest (args include CancellationTokenReference) Note over Client: Replaces CancellationTokenReference with AbortSignal/CancellationToken Client->>Client: Starts handler execution Server->>Client: CancelTokenFromServer (CancellationGuid) Note over Client: AbortController.abort() / CancellationToken cancels Note over Client: Handler throws (native SignalR client result returns error) Server-->>Server: InvokeCoreAsync completes with error ``` ### Internal flow 1. Server serializes `CancellationToken` parameters as `CancellationTokenReference { Id: guid }` 2. Server stores the `CancellationGuid` in the `ServerRequestMessage` 3. Client detects `CancellationTokenReference` in the arguments 4. Client creates an `AbortController` (TypeScript) or `CancellationTokenSource` (.NET) mapped to the GUID 5. Client passes the `AbortSignal`/`CancellationToken` to the handler 6. When the server cancels, it sends `CancelTokenFromServer` with the GUID 7. Client triggers the abort/cancellation ## CancellationManager (TypeScript internals) The `CancellationManager` class maps GUIDs to `AbortController` instances: | Method | Description | |--------|-------------| | `create(id)` | Creates an `AbortController`, returns its `AbortSignal` | | `cancel(id)` | Calls `abort()` on the controller, removes from map | | `remove(id)` | Removes the controller without aborting | ## Next steps * [Server Method Handlers (TypeScript)](/guide/typescript-client/server-methods) — registering handlers * [Server-to-Client Handlers (.NET)](/guide/dotnet-client/server-to-client) — .NET handler registration * [Wire Protocol](/reference/wire-protocol) — protocol message details --- --- url: /reference/client-comparison.md description: >- Feature matrix of the five clients — .NET, .NET Framework, TypeScript, Swift, Kotlin: platforms, RPC, item streaming, server-to-client RPC, file transfer, authorization, proxies, transports, side-by-side API samples --- # Client Comparison SignalARRR has five client implementations. This page shows what each client supports. ## Platform Support | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Target | net8.0 / net9.0 / net10.0 | net462+ (netstandard2.0) | Node 22 / browsers | iOS 14+ / macOS 11+ | Android 5+ (API 21) / JVM 8+ | | Package | `Cocoar.SignalARRR.Client` | `Cocoar.SignalARRR.Client.FullFramework` | `@cocoar/signalarrr` | `CocoarSignalARRR` | `dev.cocoar:signalarrr` | ## RPC | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Invoke (await result) | ✓ | ✓ | ✓ | ✓ | ✓ | | Send (fire & forget) | ✓ | ✓ | ✓ | ✓ | ✓ | | Generic arguments | ✓ | ✓ | ✓ | ✓ | ✓ | ## Item Streaming | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Server→Client | ✓ | ✓ | ✓ | ✓ | ✓ | | Client→Server | ✓ | ✓ | ✓ | ✓ | ✓ | | Stream method handlers | ✓ | ✓ | ✓ | ✓ | ✓ | ::: info .NET Framework streaming The FullFramework client supports streaming via polyfill packages (`Microsoft.Bcl.AsyncInterfaces` and `System.Threading.Channels`). `IAsyncEnumerable`, `ChannelReader`, and `await foreach` all work on .NET Framework 4.6.2+. ::: ## Server-to-Client RPC | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Method handlers | ✓ | ✓ | ✓ | ✓ | ✓ | | Interface registration | ✓ | ✓ | — | ✓ | ✓ | | CancellationToken | ✓ | ✓ | ✓ | ✓ | ✓ | ## File Transfer (HTTP Stream References) | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Download (`Stream` return) | ✓ | ✓ | ✓ | ✓ | ✓ | | Upload (`Stream` parameter) | ✓ | ✓ | ✓ | ✓ | ✓ | ## Authorization | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | `credential` / `connectionCredential` / `messageCredential` | ✓ (`With…`) | ✓ (`With…`) | ✓ | ✓ | ✓ | | Connection token on the transport | header | header | header — URL in a browser (WebSocket, SSE) and under Node (SSE) | header | header | | Auto challenge/refresh | ✓ | ✓ | ✓ | ✓ | ✓ | | Error codes (`HARRRErrorCodes`) | ✓ | ✓ | ✓ | ✓ | ✓ | | HTTP status of a rejected negotiate | `StatusCode` | `StatusCode` | `statusCode` | `statusCode` | `statusCode` | | Extra headers | SignalR options | SignalR options | `httpConnectionOptions` | `headers` | `headers` | The credential options are named and behave the same in every client — see [the two credentials](/guide/server/authorization#the-two-credentials). ## Proxy Generation | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Compile-time proxies | Source Generator | — | — | `@HubProxy` Macro | `@HubProxy` + KSP | | Runtime proxies | DispatchProxy | DispatchProxy | — | — | — | ::: info .NET Framework uses DispatchProxy only The Roslyn source generator requires projects to reference `Cocoar.SignalARRR.Contracts` which targets net8.0+. On .NET Framework, typed proxies are created at runtime via `DispatchProxy`. The interfaces don't need `[SignalARRRContract]` — any C# interface works. ::: ## Connection | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Auto-reconnect | ✓ | ✓ | ✓ | ✓ | ✓ | | Connection events | ✓ | ✓ | ✓ | ✓ | ✓ | | Raw SignalR access | ✓ | ✓ | ✓ | ✓ | ✓ | | Raw `on/off` overloads | 16 | — | — | 8 | 1 (typed accessors) | ## Concurrency Model | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | Async pattern | async/await | async/await | Promise | async/await | coroutines / Flow | | Cancellation | CancellationToken | CancellationToken | AbortSignal | Actor | Coroutine `Job` | | Serialization | System.Text.Json | System.Text.Json | JSON | Codable | kotlinx.serialization | | MessagePack | ✓ (optional) | ✓ (optional) | ✓ (optional) | ✓ (built-in) | ✓ (built-in) | ::: info MessagePack is optional MessagePack support is **not** included by default. Install the protocol package separately and register it on the connection builder: **.NET / .NET Framework:** ```bash dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack ``` ```csharp builder.AddMessagePackProtocol(); ``` **TypeScript:** ```bash npm install @microsoft/signalr-protocol-msgpack ``` ```ts builder.withHubProtocol(new MessagePackHubProtocol()); ``` **Server:** ```csharp builder.Services.AddSignalR().AddMessagePackProtocol(); ``` **Swift** has MessagePack built-in — use `hubProtocol: .messagepack` when creating the connection. **Kotlin** has MessagePack built-in — set `hubProtocol = HubProtocolKind.MESSAGE_PACK` in the options. SignalARRR auto-detects the active protocol and uses the correct serializer. No additional configuration needed. ::: ## Transport | | .NET | .NET Framework | TS | Swift | Kotlin | |-|:----:|:--------------:|:--:|:-----:|:------:| | WebSockets | ✓ | ✓ | ✓ | ✓ | ✓ | | Server-Sent Events | ✓ | ✓ | ✓ | ✓ | ✓ | | Long Polling | ✓ | ✓ | ✓ | ✓ | ✓ | | Transport fallback | ✓ | ✓ | ✓ | ✓ | ✓ | ## API Comparison ### Creating a connection ::: code-group ```csharp [.NET] var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://server/hub"); }); await connection.StartAsync(); ``` ```csharp [.NET Framework] var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://server/hub"); }); await connection.StartAsync(); ``` ```ts [TypeScript] const connection = HARRRConnection.create(builder => { builder.withUrl('https://server/hub'); }); await connection.start(); ``` ```swift [Swift] let connection = await HARRRConnection.create( url: "https://server/hub" ) try await connection.start() ``` ```kotlin [Kotlin] val connection = HARRRConnection.create("https://server/hub") connection.start() ``` ::: ### Typed proxies ::: code-group ```csharp [.NET — Source Generator] // Shared project: mark with [SignalARRRContract] [SignalARRRContract] public interface IChatHub { Task SendMessage(string user, string message); Task> GetHistory(); } // Client: get typed proxy var chat = connection.GetTypedMethods(); await chat.SendMessage("Alice", "Hello!"); ``` ```csharp [.NET Framework — DispatchProxy] // Define interface (no attribute needed) public interface IChatHub { Task SendMessage(string user, string message); Task> GetHistory(); } // Client: get typed proxy (runtime-generated via DispatchProxy) var chat = connection.GetTypedMethods(); await chat.SendMessage("Alice", "Hello!"); ``` ```swift [Swift — @HubProxy Macro] // Client: mark with @HubProxy @HubProxy protocol IChatHub { func sendMessage(user: String, message: String) async throws func getHistory() async throws -> [String] } // Client: get typed proxy let chat = connection.getTypedMethods(IChatHubProxy.self) try await chat.sendMessage(user: "Alice", message: "Hello!") ``` ```kotlin [Kotlin — @HubProxy + KSP] // Client: mark with @HubProxy, the KSP processor generates IChatHubProxy @HubProxy(name = "MyApp.Contracts.IChatHub") interface IChatHub { suspend fun sendMessage(user: String, message: String) suspend fun getHistory(): List } // Client: get typed proxy val chat = connection.getTypedMethods(IChatHubProxy) chat.sendMessage("Alice", "Hello!") ``` ::: TypeScript has no typed proxy generation — method names are passed as strings. ### Invoke / Send / Stream ::: code-group ```csharp [.NET] // Invoke (await result) var result = await connection.InvokeCoreAsync(message, ct); // Send (fire-and-forget) await connection.SendCoreAsync(message, ct); // Stream await foreach (var item in connection.StreamAsyncCore(message, ct)) Console.WriteLine(item); ``` ```csharp [.NET Framework] // Invoke (await result) var result = await connection.InvokeCoreAsync(message, ct); // Send (fire-and-forget) await connection.SendCoreAsync(message, ct); // Stream (via polyfill packages) await foreach (var item in connection.StreamAsyncCore(message, ct)) Console.WriteLine(item); ``` ```ts [TypeScript] // Invoke const result = await connection.invoke('Method.Name'); // Send await connection.send('Method.Name', arg1, arg2); // Stream connection.stream('Method.Name').subscribe({ next: item => console.log(item), }); ``` ```swift [Swift] // Invoke let result: String = try await connection.invoke("Method.Name") // Send try await connection.send("Method.Name", arguments: arg1, arg2) // Stream for try await item in try await connection.stream("Method.Name") as AsyncThrowingStream { print(item) } ``` ```kotlin [Kotlin] // Invoke val result: String = connection.invoke("Method.Name") // Send connection.send("Method.Name", arg1, arg2) // Stream connection.stream("Method.Name").collect { item -> println(item) } ``` ::: ### Server-to-client handlers ::: code-group ```csharp [.NET] connection.RegisterInterface(new ChatClientImpl()); ``` ```csharp [.NET Framework] connection.RegisterInterface(new ChatClientImpl()); ``` ```ts [TypeScript] // The contract's wire name: the .NET clients resolve the interface for you, TypeScript and Swift // address it directly. See /guide/server/contracts-wire-names connection.onServerMethod('MyApp.Contracts.IChatClient|ReceiveMessage', (user, message) => { console.log(`${user}: ${message}`); }); ``` ```swift [Swift] await connection.onServerMethod("MyApp.Contracts.IChatClient|ReceiveMessage") { args in print("\(args[0]): \(args[1])") return AnyCodable.nil } ``` ```kotlin [Kotlin] connection.onServerMethod("MyApp.Contracts.IChatClient|ReceiveMessage") { args -> println("${args.value(0)}: ${args.value(1)}") } ``` ::: ### CancellationToken handling | Client | Mechanism | Type | |--------|-----------|------| | .NET | Standard `CancellationToken` | Native | | .NET Framework | Standard `CancellationToken` | Native | | TypeScript | `AbortSignal` via `CancellationManager` (Map-based) | Web API | | Swift | Actor-based `CancellationManager` with continuations | Swift Concurrency | | Kotlin | The handler's coroutine `Job` is cancelled via `CancellationManager` | Structured concurrency | ### Packages | Client | Package | Install | |--------|---------|---------| | .NET | `Cocoar.SignalARRR.Client` | `dotnet add package` | | .NET Framework | `Cocoar.SignalARRR.Client.FullFramework` | `dotnet add package` | | TypeScript | `@cocoar/signalarrr` | `npm install` | | Swift | `CocoarSignalARRR` + `CocoarSignalARRRMacros` | Swift Package Manager | | Kotlin | `dev.cocoar:signalarrr` + `dev.cocoar:signalarrr-ksp` | Gradle (Maven Central) | --- --- url: /guide/server/client-manager.md description: >- ClientManager: query clients with IClientQuery, single-client calls, SendAsync / InvokeAllAsync / InvokeOneAsync, presence snapshots, groups, use from controllers and background services, ClientContext properties and custom attributes --- # Client Manager `ClientManager` tracks all connected clients and enables server-to-client RPC from anywhere — controllers, background services, or other hubs. Inject it from DI as a singleton. ## Inject ClientManager ```csharp public class NotificationService { private readonly ClientManager _clients; public NotificationService(ClientManager clients) => _clients = clients; public void NotifyUser(string connectionId) { var methods = _clients.GetTypedMethods(connectionId); methods.ReceiveMessage("System", "You have a new notification"); } } ``` ## Query clients Start with `WithHub()` to select the hub, then chain filters: ```csharp // Always start with the hub _clients.WithHub() // Then chain filters _clients.WithHub().WithGroup("dashboard") _clients.WithHub().WithAttribute("role", "oncall") _clients.WithHub().WithGroup("dashboard").WithAttribute("region", "eu") ``` `WithHub()` returns an **`IClientQuery`**. That is a *description* of a target set, not a list of clients: with a [backplane](/guide/server/backplane) enabled, the filters and the send/invoke methods below are resolved across every node in the cluster. | Method | On | Description | |--------|---|-------------| | `WithHub()` | `ClientManager` | Select hub — always start here | | `.WithGroup(groupName)` | `IClientQuery` | Filter by SignalR group — cluster-wide | | `.WithAttribute(key)` | `IClientQuery` | Filter by attribute existence — cluster-wide | | `.WithAttribute(key, value)` | `IClientQuery` | Filter by attribute key-value match — cluster-wide | | `.WithLocalFilter(predicate)` | `IClientQuery` | Filter by any predicate — **this node only** | | `.LocalClients()` | `IClientQuery` | The matching `ClientContext`s owned by this node | | `GetClientById(id)` | `ClientManager` | Single client by connection ID | ### Why predicates are node-local A predicate is a delegate over `ClientContext` objects. It cannot be shipped to another node, and the other nodes' `ClientContext` instances do not exist in this process to run it against — the connection registry stores snapshots, not live contexts. So `WithLocalFilter` narrows the query to this node, and everything chained after it stays local as well: ```csharp // This node's admins only — the name says so _clients.WithHub().WithLocalFilter(c => c.User.IsInRole("Admin")) ``` To narrow a query that should still reach the whole cluster, filter on something the registry can answer for every node — group, user, or a connection attribute: ```csharp // Every admin on every node _clients.WithHub().WithAttribute("role", "admin") ``` ::: warning Changed in 5.0 `WithHub()` used to return `IEnumerable`, so `.Where(...)` compiled — and silently turned a cluster-wide broadcast into a node-local one, because the LINQ result no longer carried the query's cluster scope. Enumerating the result had never shown clients from other nodes either. Both gaps are now closed by the type: `.Where(...)` no longer compiles, `WithLocalFilter` says what it does, and `LocalClients()` is the one way to get at `ClientContext` objects. ::: ## Single-client calls Single clients support full RPC with return values: ```csharp // By connection ID var client = _clients.GetClientById(connectionId); var methods = client.GetTypedMethods(); methods.ReceiveMessage("System", "Hello!"); string name = await methods.GetClientName(); // ← with return value // Or: filter down, then pick one of this node's matches var primary = _clients.WithHub() .WithGroup("dashboard") .WithAttribute("role", "primary") .LocalClients() .First(); string status = await primary.GetTypedMethods().GetStatus(); ``` ## Multi-client operations All broadcast and multi-client operations are methods on `IClientQuery`, so they are only reachable from a query — which is what keeps them cluster-aware. ::: info Return values are discarded on broadcasts When using `SendAsync`, methods with return values still work — the client executes the method — but the return value is discarded since there's no single caller to send it back to. A warning is logged. Use `InvokeAllAsync` if you need return values. ::: ::: warning Errors are discarded too `SendAsync` completes when the message reaches the transport, not when the clients have run the method. A handler that throws is logged **on the client** and nowhere else — the server sees a successful send either way. `InvokeAllAsync` and `InvokeOneAsync` do surface client-side failures, because they wait for an answer. See [what happens when a handler throws](/guide/dotnet-client/server-to-client#what-happens-when-a-handler-throws). ::: ### SendAsync — fire-and-forget, one SignalR call Collects ConnectionIds and sends a **single** `Clients.Clients(ids).SendCoreAsync` call. ```csharp // Notify all dashboard clients await _clients.WithHub().WithGroup("dashboard") .SendAsync(c => c.AlertUpdated(alertId)); // Notify all admins, on every node await _clients.WithHub().WithAttribute("role", "admin") .SendAsync(c => c.SecurityAlert(details)); // Notify iOS users await _clients.WithHub().WithAttribute("Platform", "iOS") .SendAsync(c => c.PushUpdate(version)); ``` ### InvokeAllAsync — call all, collect all results Invokes on **each** client individually (N calls), awaits all in parallel, returns results per client. ```csharp var results = await _clients.WithHub() .InvokeAllAsync(c => c.GetStatus()); foreach (var r in results) { Console.WriteLine($"Client {r.ClientId}: {r.Value}"); } ``` ### InvokeOneAsync — first responder wins Calls clients one by one until the **first** succeeds. ```csharp var result = await _clients.WithHub() .WithAttribute("role", "primary") .InvokeOneAsync(c => c.GetStatus()); // result.ClientId — which client responded // result.Value — the return value ``` ### API summary | Method | Calls | Returns | Use case | |--------|-------|---------|----------| | `.SendAsync(action)` | 1 (broadcast) | Nothing | Notifications, events | | `.InvokeAllAsync(func)` | N (parallel) | All results | Status polling, data collection | | `.InvokeOneAsync(func)` | 1–N (sequential) | First success | Failover, load distribution | ## Presence snapshots `ClientManager` can also return connection and user presence snapshots: ```csharp var allConnections = await _clients.GetConnectionsAsync(); var aliceConnections = await _clients.GetConnectionsByUserAsync("alice"); var dashboardConnections = await _clients.GetConnectionsInGroupAsync("dashboard"); var primaryNodes = await _clients.GetConnectionsByAttributeAsync("role", "primary"); var onlineUsers = await _clients.GetOnlineUsersAsync(); var isAliceOnline = await _clients.IsUserOnlineAsync("alice"); ``` When no backplane is configured, these APIs return data from the current node only. With the Redis-compatible backplane enabled, they return cluster-wide snapshots. #### On `ClientManager` — group management | Method | Description | |--------|-------------| | `.AddToGroupAsync(connectionId, groupName)` | Adds client to SignalR group AND tracks in `ClientContext.Groups` | | `.RemoveFromGroupAsync(connectionId, groupName)` | Removes from SignalR group AND `ClientContext.Groups` | ## Groups SignalARRR integrates SignalR groups directly into `ClientManager`. When you add a client to a group, it's tracked in both SignalR (for broadcasting) and `ClientContext.Groups` (for querying). ### Managing groups ```csharp // Add a client to a group — syncs both SignalR and ClientContext await _clients.AddToGroupAsync(connectionId, "dashboard"); await _clients.AddToGroupAsync(connectionId, "alerts"); // Remove from group await _clients.RemoveFromGroupAsync(connectionId, "dashboard"); // Query groups on a client var client = _clients.GetClientById(connectionId); var groups = client.Groups; // → IReadOnlyCollection { "alerts" } ``` ### Broadcasting to a group ```csharp await _clients.WithHub().WithGroup("dashboard") .SendAsync(c => c.AlertUpdated(alertId)); ``` ## Use in controllers ```csharp [ApiController] [Route("api/[controller]")] public class NotificationController : ControllerBase { private readonly ClientManager _clients; public NotificationController(ClientManager clients) => _clients = clients; [HttpPost("broadcast")] public async Task Broadcast([FromBody] string message) { await _clients.WithHub() .SendAsync(c => c.ReceiveMessage("API", message)); return Ok(); } [HttpPost("alert/{group}")] public async Task AlertGroup(string group, [FromBody] AlertData alert) { await _clients.WithHub().WithGroup(group) .SendAsync(c => c.AlertUpdated(alert.Id)); return Ok(); } } ``` ## Use in background services ```csharp public class HeartbeatService : BackgroundService { private readonly ClientManager _clients; public HeartbeatService(ClientManager clients) => _clients = clients; protected override async Task ExecuteAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { await _clients.WithHub() .SendAsync(c => c.ReceiveMessage("System", "heartbeat")); await Task.Delay(30_000, ct); } } } ``` ## ClientContext properties Each `ClientContext` provides detailed information about the connected client: | Property | Type | Description | |----------|------|-------------| | `Id` | `string` | Connection ID | | `RemoteIp` | `IPAddress?` | Client's IP address | | `User` | `ClaimsPrincipal` | Authenticated user claims | | `UserIdentifier` | `string?` | SignalR's user identifier, or the principal's name identifier | | `AuthMode` | `AuthenticationMode` | How this connection authenticates — see [Authorization](/guide/server/authorization) | | `ClientCertificate` | `X509Certificate2?` | The certificate presented at the TLS handshake, if any | | `ConnectedAt` | `DateTime` | Connection timestamp | | `ReconnectedAt` | `List` | Reconnection history | | `Groups` | `IReadOnlyCollection` | SignalR groups this client belongs to | | `Attributes` | `ClientAttributes` | Custom key-value storage | | `ConnectedTo` | `Uri` | Hub URL | ### Dropping a connection `Abort()` closes the connection. Use it when you learn out-of-band that a client should no longer be connected — a revoked session, a decommissioned device — rather than letting each of its calls fail while the socket stays up: ```csharp foreach (var client in _clients.WithHub().LocalClients()) { if (await _sessions.IsRevokedAsync(client.UserIdentifier)) client.Abort(); } ``` Safe to call more than once, and after the connection has already gone. Note `LocalClients()` — a connection can only be aborted by the node holding it. Re-validation can reach the same outcome from inside the authorization pipeline by returning `RevalidationResult.Abort()`; see [Authorization](/guide/server/authorization). ## Custom client attributes Clients can pass custom attributes via HTTP headers (prefixed with `#`) or query parameters (prefixed with `@`) during the initial handshake: ```csharp // Server: read custom attributes var version = client.Attributes["AppVersion"]; var platform = client.Attributes["Platform"]; // Check attribute existence bool hasPlatform = client.Attributes.Has("Platform"); bool isIOS = client.Attributes.Has("Platform", "iOS"); ``` ## Next steps * [Server Methods](/guide/server/server-methods) — server-to-client calls inside the hub * [Authorization](/guide/server/authorization) — filter clients by authentication state * [Backplane & Clustering](/guide/server/backplane) — enable multi-node routing and presence * [Connection Setup (.NET)](/guide/dotnet-client/connection-setup) — configure the .NET client --- --- url: /guide/streaming/client-to-server.md description: >- Stream items from a client to the server with StreamItemToServer and StreamCompleteToServer, handle the stream in a ServerMethods class, and deal with errors --- # Client-to-Server Streaming Clients can stream data to the server using `StreamItemToServer` and `StreamCompleteToServer` hub methods. This enables scenarios like uploading large files, sending telemetry, or live data feeds. ## How it works The client sends stream items one at a time using the `StreamItemToServer` hub method, identified by a stream GUID. When the stream is complete, the client sends `StreamCompleteToServer`. ```mermaid sequenceDiagram participant Client participant Server Client->>Server: StreamItemToServer(streamId, item1) Client->>Server: StreamItemToServer(streamId, item2) Client->>Server: StreamItemToServer(streamId, item3) Client->>Server: StreamCompleteToServer(streamId, null) Note over Server: Stream completed ``` ## Server-side handling The `ServerStreamManager` correlates stream items by their GUID and delivers them to the requesting `ServerMethods` class: ```csharp public class UploadMethods : ServerMethods { private readonly ServerStreamManager _streams; public UploadMethods(ServerStreamManager streams) => _streams = streams; public async Task ProcessStream(Guid streamId, CancellationToken ct) { await foreach (var item in _streams.ReadStream(streamId, ct)) { // Process each streamed item await ProcessItem(item); } } } ``` ## Error handling The client can signal an error by passing an error message to `StreamCompleteToServer`: ```ts // TypeScript connection.asSignalRHubConnection().send('StreamCompleteToServer', streamId, 'Upload cancelled'); ``` ```csharp // .NET connection.AsSignalRHubConnection().SendAsync("StreamCompleteToServer", streamId, "Upload cancelled"); ``` ## Next steps * [Server-to-Client Streaming](/guide/streaming/server-to-client) — stream from server to client * [Wire Protocol](/reference/wire-protocol) — stream protocol details --- --- url: /skill/SKILL.header.md description: > Typed bidirectional RPC over ASP.NET Core SignalR with Cocoar.SignalARRR. Use when working with HARRR hubs, ServerMethods, [SignalARRRContract] interfaces, HARRRConnection on .NET, the @cocoar/signalarrr npm client, the CocoarSignalARRR Swift package or the dev.cocoar:signalarrr Kotlin/Android client, server-to-client calls, ClientManager and IClientQuery, item streaming (IAsyncEnumerable, IObservable, ChannelReader), HTTP stream references, cancellation propagation, SignalARRR authorization, the Redis or PostgreSQL backplane, or cluster subjects. --- # Cocoar.SignalARRR SignalARRR extends ASP.NET Core SignalR with typed bidirectional RPC: server and client call each other's methods through shared interfaces, with compile-time proxies, item streaming, HTTP file transfer, cancellation propagation, ASP.NET Core authorization, and an optional multi-node backplane. This skill is the documentation, page by page, under `references/`. The index at the bottom says which page answers what. ## Packages | Package | Purpose | |---|---| | `Cocoar.SignalARRR.Server` | Server: `HARRR` hub base class, `ServerMethods`, authorization, `ClientManager`, streaming, cluster subjects | | `Cocoar.SignalARRR.Server.Backplane.Redis` | Multi-node scale-out over Redis, Valkey or Garnet (`AddSignalARRRRedisBackplane`) | | `Cocoar.SignalARRR.Server.Backplane.Postgres` | Multi-node scale-out over the PostgreSQL primary the app already has (`AddSignalARRRPostgresBackplane`), with catch-up after a subscription drop | | `Cocoar.SignalARRR.Client` | .NET client: `HARRRConnection`, typed proxies, server-to-client handlers | | `Cocoar.SignalARRR.Client.FullFramework` | .NET Framework 4.6.2+ client: typed proxies via `DispatchProxy`, no item streaming | | `Cocoar.SignalARRR.Contracts` | `[SignalARRRContract]` attribute; reference from shared interface projects (the source generator comes with Server and Client) | | `Cocoar.SignalARRR.DynamicProxy` | Opt-in runtime proxy fallback via `DispatchProxy`, for plugin scenarios | | `Cocoar.SignalARRR.Common`, `.ProxyGenerator`, `.SourceGenerator` | Referenced transitively; not added by hand | | `@cocoar/signalarrr` (npm) | TypeScript/JavaScript client: `HARRRConnection`, `invoke`, `send`, `stream`, `onServerMethod` | | `CocoarSignalARRR` (Swift Package) | Swift client for iOS, macOS, tvOS, watchOS with the `@HubProxy` macro | | `dev.cocoar:signalarrr`, `dev.cocoar:signalarrr-ksp` (Maven) | Kotlin client for Android and the JVM, coroutines and `Flow`, typed proxies generated by KSP from `@HubProxy` interfaces | ## Things an assistant gets wrong without the docs * **`send` is fire-and-forget, `invoke` awaits a result, `stream` returns items.** On the .NET client the return type of the contract method decides: `void`/`Task` → send, `Task` → invoke, `IAsyncEnumerable`/`IObservable`/`ChannelReader` → stream. On TypeScript and Swift the caller chooses the call explicitly; on Kotlin a `@HubProxy` interface decides by signature (`suspend fun` without result → send, with result → invoke, `Flow` → stream). * **Every interface a `ServerMethods` class implements is a public RPC surface**, whether or not it carries `[SignalARRRContract]`. Hold collaborators as fields; do not implement their interfaces on the class. * **`WithHub()` returns an `IClientQuery`, not a sequence.** Filter with `WithGroup`, `WithUser` and `WithAttribute`, which span the cluster; `WithLocalFilter(predicate)` and `LocalClients()` are node-local by name. * **Server-to-client calls use SignalR client results natively.** Register handlers with `RegisterInterface` (.NET), `onServerMethod` (TypeScript, Swift, Kotlin) or the interface handler before connecting; `On()` on the raw `HubConnection` is not the contract path. * **A `System.IO.Stream` parameter travels over HTTP, not the WebSocket.** Download endpoints are one-time; uploads go through `RequestUploadSlot` and `POST /hub/upload/{id}`; slots per connection are capped. * **Token clients configure the message credential.** Without it the server's `ChallengeAuthentication` gets no answer and the connection is denied at the first authorized call; transport-level auth (certificates, Negotiate, cookies) works without it. * **Unexpected exceptions do not carry their detail to the caller** — a fixed sentence plus a correlation id. Throw `HARRRException(code, message)` for errors the caller is meant to act on. * **A backplane is a package and one registration call.** Redis and Postgres provide the same cluster behaviour; the Postgres one replays messages missed during a subscription drop. Server streams fed by an in-process observable are node-local unless the source is an `IClusterSubject` — one per event type, registered by name. --- --- url: /guide/dotnet-client/connection-setup.md description: >- Create a HARRRConnection, authenticate with bearer tokens or client certificates, configure auto-reconnect, start and stop, handle errors and connection events, reach the underlying HubConnection --- # Connection Setup `HARRRConnection` wraps ASP.NET Core's `HubConnection` with typed RPC support. Create one using the static factory method. ## Create a connection Use the builder pattern to configure the underlying SignalR connection: ```csharp var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://localhost:5001/apphub"); }); ``` Or wrap an existing `HubConnection`: ```csharp var hubConnection = new HubConnectionBuilder() .WithUrl("https://localhost:5001/apphub") .Build(); var connection = HARRRConnection.Create(hubConnection); ``` ## Connection with authentication ### Token-based (Bearer, JWT) A connection has two credentials. Usually they are the same token, so one option sets both: ```csharp var connection = HARRRConnection.Create( builder => builder.WithUrl("https://localhost:5001/apphub"), options => options.WithCredential(async () => await tokenStore.GetTokenAsync())); ``` To give them different credentials — a single-use connection ticket, say, which has no business being resent with every message — set them separately: ```csharp options => options .WithConnectionCredential(async () => await GetConnectionTicketAsync()) .WithMessageCredential(async () => await tokenStore.GetTokenAsync()) ``` | Option | Authenticates | Travels as | Checked by | When it expires | |---|---|---|---|---| | `WithConnectionCredential` | negotiate and the transport | `Authorization` header | `[Authorize]` on the hub class, `.RequireAuthorization()` on the mapping | fetched again on every connect and reconnect | | `WithMessageCredential` | every message, the answer to a challenge, file transfers | the `Authorization` field of the message; a header on file transfers | `[Authorize]` on a method or a `ServerMethods` class | fetched on every use, so a refreshed token is sent from the next call on | | `WithCredential` | both of the above | | | | The options are named the same in every SignalARRR client, and nothing is coupled implicitly: `WithMessageCredential` alone leaves the connection anonymous, `WithConnectionCredential` alone sends no message credential. Each accepts an async factory, a `Func`, or a plain `string` for a credential that does not change. See [Authorization](/guide/server/authorization#the-two-credentials) for the same table across all clients. The message credential is what keeps a long-lived connection current. It travels with every call, and it is what answers a challenge while a stream is running — so the server can re-check the credential rather than trusting the one it saw at negotiate. A connection without it is not cut off: once the server's auth cache lapses it falls back to the principal established at negotiate, the way plain SignalR would, and the expiry stated on that principal is still enforced. What you lose is the refresh — the server can no longer catch a revoked credential, and cannot ask you for a new one. `WithConnectionCredential` becomes SignalR's `AccessTokenProvider`, so it only works with `Create(builder => ...)`, where SignalARRR builds the `HubConnection`. Setting `AccessTokenProvider` in `WithUrl` as well, or passing a connection credential to `Create(hubConnection)`, fails at `Create` rather than silently picking one. ::: tip Former names `WithAuthorization` is the message credential under its former name. It still works and is marked obsolete; replace it with `WithMessageCredential`, or with `WithCredential` if you also set SignalR's `AccessTokenProvider` to the same token. Using it together with the new options fails at `Create`. ::: ::: warning Changed in 5.0.0 SignalARRR used to take the message credential from SignalR's `AccessTokenProvider` automatically, by reflecting into two levels of its private fields. It no longer does. If your tokens are short-lived and refreshed — the usual reason for having them — set a message credential, or the connection will run on the identity it started with until that identity's stated expiry. ::: ### Certificate-based (mTLS) For client certificate authentication, configure the certificate on the connection. No `AccessTokenProvider` is needed — SignalARRR auto-detects transport-level auth: ```csharp var cert = new X509Certificate2("client.pfx", password); var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://server:5001/apphub", options => { options.ClientCertificates = new X509CertificateCollection { cert }; options.HttpMessageHandlerFactory = handler => { if (handler is SocketsHttpHandler socketsHandler) { socketsHandler.SslOptions.ClientCertificates = new X509CertificateCollection { cert }; } return handler; }; }); }); ``` When the auth cache expires, the server re-validates the certificate server-side (checking expiry and optionally CRL/OCSP) without sending a challenge to the client. See [Authorization](/guide/server/authorization) for server-side configuration. ## Auto-reconnect ```csharp var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://localhost:5001/apphub"); builder.WithAutomaticReconnect(); }); ``` ## Start and stop ```csharp await connection.StartAsync(); // ... use the connection ... await connection.StopAsync(); await connection.DisposeAsync(); ``` ## Error handling When a server call fails, the client receives a structured error carrying a machine-readable code: ```csharp try { var result = await chat.GetHistory(); } catch (HubException ex) { var error = HARRRError.Parse(ex); Console.WriteLine($"{error.Code}: {error.Message}"); // "argument_binding_failed: Invalid value provided" } ``` A connection the server rejects at negotiate — 401 or 403 for a missing or invalid connection credential — fails `StartAsync` with SignalR's `HttpRequestException`, whose `StatusCode` carries the HTTP status. The other clients expose the same as `statusCode`. `HARRRException` extends `HubException`, so the structured error always reaches the client — no `EnableDetailedErrors` configuration needed. **How much detail it carries depends on the code**, and the split is deliberate: | Code | What the client sees | |------|----------------------| | A code you threw yourself — `new HARRRException("room_full", "The room is full")` | Your code and your message, verbatim | | A framework code — `unauthorized`, `timeout`, `cancelled`, `argument_binding_failed`, `method_not_found`, `no_client_responded`, `upload_slot_limit_reached` | The message the pipeline produced, plus the nested cause chain | | `internal` — the invoked method threw something the pipeline does not recognize | A fixed sentence and a correlation id. Nothing else. | ::: warning Changed in 5.0 `internal` used to carry the exception's own type and message. That routinely put a `SqlException` naming the database server, or a `FileNotFoundException` naming an absolute path, in front of any caller authorized to make the call — which is what SignalR's `EnableDetailedErrors=false` default exists to prevent. The exception is now logged on the server under the same correlation id the client is shown, so nothing is lost — it moves from somewhere the caller can read to somewhere the operator can. If you want a specific failure to reach the client, say so explicitly by throwing `HARRRException(code, message)`. ::: ## Connection events ```csharp connection.Closed += error => { Console.WriteLine($"Connection closed: {error?.Message}"); return Task.CompletedTask; }; connection.Reconnecting += error => { Console.WriteLine($"Reconnecting: {error?.Message}"); return Task.CompletedTask; }; connection.Reconnected += connectionId => { Console.WriteLine($"Reconnected as {connectionId}"); return Task.CompletedTask; }; ``` ## Connection properties | Property | Type | Description | |----------|------|-------------| | `ConnectionId` | `string?` | Current connection ID (null when disconnected) | | `State` | `HubConnectionState` | `Disconnected`, `Connecting`, `Connected`, `Reconnecting` | | `ServerTimeout` | `TimeSpan` | Server keepalive timeout | | `KeepAliveInterval` | `TimeSpan` | Client keepalive ping interval | | `HandshakeTimeout` | `TimeSpan` | Handshake timeout | ## Access the raw HubConnection If you need SignalR features not exposed by `HARRRConnection`: ```csharp var hubConnection = connection.AsSignalRHubConnection(); ``` ## Next steps * [Typed Methods](/guide/dotnet-client/typed-methods) — call server methods through interfaces * [Server-to-Client Handlers](/guide/dotnet-client/server-to-client) — handle server calls * [Streaming](/guide/streaming/server-to-client) — stream data from the server --- --- url: /guide/server/contracts-wire-names.md description: >- Declare the wire name of a contract interface (the ClassName|Method halves), the rules for it, versioning contracts side by side, and where wire names do not apply --- # Contract Wire Names A call through a contract interface travels under a name made of two halves, separated by `|`: ``` MyApp.Contracts.IChatClient|ReceiveMessage └────── interface ────────┘ └── method ──┘ ``` By default those halves are the C# full name of the interface and the C# name of the method. That default is convenient and, if you only ever talk .NET to .NET, harmless: both sides compile against the same contract assembly, so a rename moves both ends at once. It stops being harmless the moment a TypeScript or Swift client exists. ## Why you would declare them Those clients write the name out as a string. Nothing checks it — not a compiler, not a test, not a log line. So with default names: * Renaming a C# method breaks every deployed non-.NET client, **silently, at runtime**. * Moving the interface to another namespace does the same, and so does renaming the interface. * Conversely, if the contract ships as a NuGet package, you cannot rename anything without it being a breaking change for .NET consumers either. Declaring the names decouples the two. The C# identifiers become internal API you can rename freely; the wire names become the contract you keep stable. ## How `[MessageName]` on the interface sets the first half, on a member the second: ```csharp [SignalARRRContract] [MessageName("chat.client")] public interface IChatClient { [MessageName("received")] void ReceiveMessage(string user, string message); // No attribute: keeps its C# name, so this one is "chat.client|GetClientName" Task GetClientName(); } ``` ```ts connection.onServerMethod('chat.client|received', (user, message) => { /* … */ }); ``` Both halves are independent, and both are optional — a contract with no attributes behaves exactly as it did before. ## Rules * **A declared name replaces the C# name, it does not add an alias.** Once `IChatClient` is `chat.client`, its full name no longer resolves. Aliasing would keep renames working and defeat the point. * **The name must not contain `|`.** The receiving side splits on the first one to tell the interface from the method. A separator inside either half is a registration error, not a surprise later. * **Two members cannot share a wire name and argument count.** That is the same collision rule as for C# names, now reachable by renaming two members onto each other. It fails at registration. * **Hiding follows the wire name.** A member declared on the registered interface hides an inherited one with the same *wire* name — which, with renames in play, need not be the one it shares a C# name with. ## Versioning There is no version mechanic, deliberately. A wire name is a name; if you want versions, put them in it: ```csharp [MessageName("getById.v2")] Task GetById(string id); ``` What `v2` means, and whether `v1` still exists alongside it as a second member, is your application's decision — the library does not parse, compare or resolve it. ## Where this does not apply Client-to-server calls on hub methods and `ServerMethods` classes have always honoured `[MessageName]`, on both the class and the method, and form their names as `Root.Method`. Nothing about that changed. ## Next steps * [Server-to-Client Handlers](/guide/dotnet-client/server-to-client) — the .NET side of a contract * [TypeScript: Server Method Handlers](/guide/typescript-client/server-methods) — registering by wire name * [Swift: Typed Proxies](/guide/swift-client/typed-proxies) — same, in Swift --- --- url: /roadmap/status.md description: >- Implementation status of every feature across the .NET server and the .NET, TypeScript, Swift and Kotlin clients --- # Feature Status Current implementation status across all platforms. See [Client Comparison](/reference/client-comparison) for a detailed side-by-side comparison with code examples. | Feature | .NET Server | .NET Client | TypeScript | Swift | Kotlin | |---------|:-----------:|:-----------:|:----------:|:-----:|:-----:| | Invoke (call & wait) | Done | Done | Done | Done | Done | | Send (fire & forget) | Done | Done | Done | Done | Done | | Server→Client item streaming | Done | Done | Done | Done | Done | | Client→Server item streaming | Done | Done | Done | Done | Done | | CancellationToken propagation | Done | Done | Done | Done | Done | | Authorization + token refresh | Done | Done | Done | Done | Done | | Compile-time proxies | Done | Done (Source Generator) | N/A | Done (@HubProxy Macro) | Done (@HubProxy + KSP) | | DynamicProxy fallback | Done | Done | N/A | N/A | N/A | | File transfer (HTTP stream refs) | Done | Done | Done | Done | Done | | Structured error types | Done | Done | Done | Done | Done | | MessagePack protocol | Done | Done | Done | Done | Done | | Observable/ChannelReader returns | Done | Done | — | N/A | N/A | | Auto-reconnect | Done | Done | Done | Done | Done | | Multiple transports (WS/SSE/LP) | Done | Done | Done | Done | Done | | Logging | Done | Done | Done | Done (os\_log) | Done (pluggable) | --- --- url: /guide/getting-started.md description: >- Install the packages, define a [SignalARRRContract] interface, implement it in a ServerMethods class on a HARRR hub, and call it from the .NET, TypeScript, Swift and Kotlin clients --- # Getting Started SignalARRR enables typed bidirectional RPC over ASP.NET Core SignalR. Define shared interfaces, implement them on the server, and call them from .NET or TypeScript clients with full type safety. ## Installation ::: code-group ```bash [Server] dotnet add package Cocoar.SignalARRR.Server ``` ```bash [.NET Client] dotnet add package Cocoar.SignalARRR.Client ``` ```bash [.NET Framework Client] dotnet add package Cocoar.SignalARRR.Client.FullFramework ``` ```bash [Shared Contracts] dotnet add package Cocoar.SignalARRR.Contracts ``` ```bash [TypeScript / JavaScript] npm install @cocoar/signalarrr ``` ```swift [Swift (Package.swift)] .package(url: "https://github.com/cocoar-dev/Cocoar.SignalARRR.git", from: "5.0.0") // Products: CocoarSignalARRR, CocoarSignalARRRMacros ``` ```kotlin [Kotlin (build.gradle.kts)] implementation("dev.cocoar:signalarrr:5.2.0") ksp("dev.cocoar:signalarrr-ksp:5.2.0") // typed proxies via @HubProxy ``` ::: ## 1. Define shared interfaces Create a shared project and reference `Cocoar.SignalARRR.Contracts`. Mark each interface with `[SignalARRRContract]` — the source generator that comes with the Server and Client packages will produce typed proxies at build time. ```csharp using Cocoar.SignalARRR.Common.Attributes; [SignalARRRContract] public interface IChatHub { Task SendMessage(string user, string message); Task> GetHistory(); IAsyncEnumerable StreamMessages(CancellationToken ct); } [SignalARRRContract] public interface IChatClient { void ReceiveMessage(string user, string message); Task GetClientName(); } ``` ## 2. Server setup Register SignalARRR and map the hub in `Program.cs`: ```csharp builder.Services.AddSignalR(); builder.Services.AddSignalARRR(options => options .AddServerMethodsFrom(typeof(Program).Assembly)); app.UseRouting(); app.MapSignalARRRHub("/chathub"); ``` Create an empty hub — the actual method implementations go into `ServerMethods` classes: ```csharp public class ChatHub : HARRR { public ChatHub(IServiceProvider sp) : base(sp) { } } public class ChatMethods : ServerMethods, IChatHub { public Task SendMessage(string user, string message) { // Broadcast to all clients var client = ClientContext.GetTypedMethods(); client.ReceiveMessage(user, message); return Task.CompletedTask; } public Task> GetHistory() => Task.FromResult(new List { "Hello", "World" }); public async IAsyncEnumerable StreamMessages( [EnumeratorCancellation] CancellationToken ct) { while (!ct.IsCancellationRequested) { yield return $"msg-{DateTime.Now:ss}"; await Task.Delay(1000, ct); } } } ``` ## 3. .NET client ```csharp var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://localhost:5001/chathub"); }); await connection.StartAsync(); // Typed calls through the shared interface var chat = connection.GetTypedMethods(); await chat.SendMessage("Alice", "Hello!"); var history = await chat.GetHistory(); // Streaming await foreach (var msg in chat.StreamMessages(cancellationToken)) { Console.WriteLine(msg); } ``` ## 4. TypeScript client ```ts import { HARRRConnection } from '@cocoar/signalarrr'; const connection = HARRRConnection.create(builder => { builder.withUrl('https://localhost:5001/chathub'); builder.withAutomaticReconnect(); }); await connection.start(); // Invoke with return value const history = await connection.invoke('ChatMethods.GetHistory'); // Fire-and-forget await connection.send('ChatMethods.SendMessage', 'Alice', 'Hello!'); // Stream connection.stream('ChatMethods.StreamMessages').subscribe({ next: msg => console.log(msg), complete: () => console.log('done'), }); // Handle server-to-client calls. The name is the contract's wire name, 'interface|method' — // a bare method name never matches a contract call. See /guide/server/contracts-wire-names connection.onServerMethod('MyApp.Contracts.IChatClient|GetClientName', () => navigator.userAgent); ``` ## 5. Swift client (iOS / macOS) ```swift import CocoarSignalARRR import CocoarSignalARRRMacros @HubProxy protocol IChatHub { func sendMessage(user: String, message: String) async throws func getHistory() async throws -> [String] func streamMessages() async throws -> AsyncThrowingStream } let connection = await HARRRConnection.create { builder in builder.withUrl(url: "https://localhost:5001/chathub") builder.withAutoReconnect() } try await connection.start() // Typed calls through @HubProxy macro let chat = connection.getTypedMethods(IChatHubProxy.self) try await chat.sendMessage(user: "Alice", message: "Hello!") let history = try await chat.getHistory() // Streaming for try await msg in try await chat.streamMessages() { print(msg) } // Handle server-to-client calls. The name is the contract's wire name, "interface|method". await connection.onServerMethod("MyApp.Contracts.IChatClient|GetClientName") { _ in AnyCodable(stringLiteral: UIDevice.current.name) } ``` ## 6. Kotlin client (Android / JVM) ```kotlin import dev.cocoar.signalarrr.HARRRConnection import dev.cocoar.signalarrr.HubProxy import kotlinx.coroutines.flow.Flow @HubProxy(name = "MyApp.Contracts.IChatHub") interface IChatHub { suspend fun sendMessage(user: String, message: String) suspend fun getHistory(): List fun streamMessages(): Flow } val connection = HARRRConnection.create("https://localhost:5001/chathub") connection.start() // Typed calls through the KSP-generated proxy val chat = connection.getTypedMethods(IChatHubProxy) chat.sendMessage("Alice", "Hello!") val history = chat.getHistory() // Streaming chat.streamMessages().collect { msg -> println(msg) } // Handle server-to-client calls. The name is the contract's wire name, "interface|method". connection.onServerMethod("MyApp.Contracts.IChatClient|GetClientName") { Build.MODEL } ``` ## Next steps * [Why SignalARRR?](/guide/why-signalarrr) — what problems SignalARRR solves compared to raw SignalR * [Hub Setup](/guide/server/hub-setup) — HARRR base class and configuration * [Server Methods](/guide/server/server-methods) — organizing hub logic across classes * [TypeScript Client](/guide/typescript-client/setup) — complete TypeScript/JavaScript guide * [Swift Client](/guide/swift-client/setup) — complete Swift/iOS/macOS guide * [Kotlin Client](/guide/kotlin-client/setup) — complete Kotlin/Android guide --- --- url: /guide/advanced/http-streams.md description: >- HTTP stream references: a System.IO.Stream parameter in a server-to-client or client-to-server call travels over HTTP instead of the WebSocket — how it works, the endpoints, upload slots, limitations --- # HTTP Stream References SignalARRR can transparently transfer large files through what looks like a normal RPC call. When a server-to-client method has a `Stream` parameter, SignalARRR automatically routes the data through HTTP instead of the WebSocket — no code changes needed. ## How it works When the server calls a client method with a `Stream` argument: 1. Server stores the stream and generates a download URI 2. Only a `StreamReference` (a small JSON object with the URI) is sent over WebSocket 3. Client detects the `Stream` parameter, fetches the actual data via HTTP GET 4. Client receives the stream and passes it to the handler The developer sees a regular method call with a `Stream` parameter on both sides. The HTTP transfer is invisible. ```mermaid sequenceDiagram participant Server participant WebSocket participant HTTP participant Client Server->>Server: Store stream in ServerPushStreamManager Server->>WebSocket: InvokeServerRequest (StreamReference with URI) WebSocket->>Client: Receive StreamReference Client->>HTTP: GET /hub/download/{guid} HTTP->>Client: Stream data (128KB chunks) Client->>Client: Pass Stream to handler Note over Client: Handler returns result (native SignalR client result) Server-->>Server: InvokeCoreAsync completes with result ``` ## Server side Define an interface with a `Stream` parameter: ```csharp [SignalARRRContract] public interface IFileClient { long ProcessFile(string filename, Stream fileStream); } ``` Call it from a `ServerMethods` class or controller — the stream is automatically routed through HTTP: ```csharp public class FileMethods : ServerMethods { public async Task SendFileToClient(string filename) { var fileStream = File.OpenRead($"/data/{filename}"); var client = ClientContext.GetTypedMethods(); return await client.ProcessFile(filename, fileStream); } } ``` You can also pass an HTTP request body directly as a stream: ```csharp [ApiController] [Route("api/files")] public class FileController : ControllerBase { private readonly ClientManager _clients; public FileController(ClientManager clients) => _clients = clients; [HttpPost("push/{connectionId}")] public async Task PushFile(string connectionId) { var client = _clients.GetTypedMethods(connectionId); var size = await client.ProcessFile("upload.bin", HttpContext.Request.Body); return Ok(new { size }); } } ``` ## .NET client side The client handler receives a regular `Stream` — no awareness of the HTTP transfer needed: ```csharp public class FileClientHandler : IFileClient { public long ProcessFile(string filename, Stream fileStream) { using (fileStream) { // Process the stream — even multi-GB files work return fileStream.Length; } } } // Before StartAsync() connection.RegisterInterface(); ``` ::: tip Streaming vs Buffered By default, the resolved stream is buffered in memory. For large files, use the streaming API directly: On **.NET** there is nothing to call: the client resolves the reference before it invokes your handler, and the `Stream` you receive is already unbuffered (`ReadAsStreamAsync` with `ResponseHeadersRead`). The other SDKs hand you the reference and let you choose: ```ts // TypeScript import { resolveStreamReference, resolveStreamReferenceAsStream } from '@cocoar/signalarrr'; const buffer = await resolveStreamReference(ref); // ArrayBuffer (buffered) const stream = await resolveStreamReferenceAsStream(ref); // ReadableStream (not buffered) ``` ```swift // Swift let data = try await StreamReferenceResolver.resolve(ref) // Data (buffered) let bytes = try await StreamReferenceResolver.resolveAsStream(ref) // AsyncBytes (not buffered) ``` ::: ## Client → Server (Stream as argument) Clients can also send `Stream` data TO the server. When a `Stream` (.NET), `Blob`/`ArrayBuffer` (TypeScript), or `Data` (Swift) is passed as an argument to a server method, the client automatically uploads it via HTTP before the call: ::: code-group ```csharp [.NET] var fileStream = File.OpenRead("/data/report.pdf"); await connection.InvokeCoreAsync( new ClientRequestMessage("FileMethods.ProcessUpload", new object[] { fileStream }), ct); ``` ```ts [TypeScript] const data = new Blob([fileContent], { type: 'application/octet-stream' }); await connection.invoke('FileMethods.ProcessUpload', data); ``` ```swift [Swift] let data = try Data(contentsOf: URL(fileURLWithPath: "/data/report.pdf")) try await connection.invoke("FileMethods.ProcessUpload", arguments: data) ``` ::: The server method receives a regular `Stream`: ```csharp public class FileMethods : ServerMethods { public string ProcessUpload(Stream data) { using var reader = new StreamReader(data); return reader.ReadToEnd(); } } ``` ::: warning One Stream per Client A `Stream` can only be read once. Do NOT pass the same `Stream` instance to multiple clients — only the first client would receive data, the others would get empty responses. SignalARRR throws an `InvalidOperationException` if you try. To send the same file to multiple clients, open a separate `Stream` for each: ```csharp foreach (var client in clients.WithHub()) { // Each client gets its own FileStream — do NOT reuse the same stream using var fileStream = File.OpenRead("/data/movie.mp4"); var methods = client.GetTypedMethods(); methods.ProcessFile("movie.mp4", fileStream); } ``` ::: ## Why not just use WebSocket? WebSocket connections have practical limits for large binary transfers: | Concern | WebSocket | HTTP Stream Reference | |---------|-----------|----------------------| | Multi-GB files | Message buffer limits, memory pressure | Streamed in 128KB chunks | | Backpressure | Blocks the hub connection for all clients | Independent HTTP connection | | Timeout | Hub timeout applies | Separate HTTP timeout | | Concurrent transfers | Shares the single WebSocket | Parallel HTTP connections | ## Under the hood ### StreamReference The only thing sent over WebSocket is a small reference object: ```json { "Uri": "http://server/apphub/download/550e8400-e29b-41d4-a716-446655440000" } ``` ### ServerPushStreamManager Manages both download streams (server → client) and upload slots (client → server). Registered as a singleton. Streams are automatically disposed after transfer or after a 10-minute cleanup timeout. ## Endpoints `MapSignalARRRHub()` registers the hub and two HTTP endpoints: ``` /apphub ← SignalR hub /apphub/download/{id} ← HTTP stream downloads (server → client) /apphub/upload/{id} ← HTTP stream uploads (client → server) ``` Both endpoints inherit the hub's protection, whichever way you applied it — an `[Authorize]` attribute on the hub class, or anything chained onto `MapSignalARRRHub()`: ```csharp // Both of these protect the transfer endpoints too. app.MapSignalARRRHub("/apphub").RequireAuthorization("files"); [Authorize(Policy = "files")] public class AppHub : HARRR { ... } ``` The clients authenticate these requests with their message credential — the one that travels with every hub call: `WithMessageCredential` in .NET and .NET Framework, `messageCredential` in TypeScript, Swift and Kotlin, or the `credential` shortcut that sets it (see [the two credentials](/guide/server/authorization#the-two-credentials)). The connection credential is not used here. A credential without a space is sent as `Bearer `; one that carries its own scheme (`Basic …`) is sent verbatim — the same rule the server applies to the credential in a message. If you protect the endpoints with something the connection's credential cannot satisfy — a separate policy requiring a claim the hub token does not carry, say — the transfer will 401 while the hub calls keep working. Either widen the policy or hand the clients a credential that satisfies both. ## Limitations * **`Stream` / `Blob` / `Data` types only** — other large types (e.g., `byte[]`) are not automatically intercepted * **In-memory storage** — download streams are held in memory on the server until the client fetches them (10 minute timeout) * **One Stream per client** — the same `Stream` instance cannot be sent to multiple clients (see warning above) * **The transfer URL is a capability** — the id is an unguessable GUID and the endpoint carries the hub's authorization, but the HTTP request itself cannot be tied to the connection that requested the transfer: an HTTP request carries no connection identity. Consuming an upload slot *is* restricted to the connection that asked for it, so a leaked URL cannot be used to read another client's upload back. Still: do not log these URLs — they routinely end up in reverse-proxy and ingress access logs otherwise. * **Upload slots are capped per connection** — 32 unused slots at a time by default (`WithMaxUploadSlotsPerConnection`, 0 to disable). Beyond that a client gets the `upload_slot_limit_reached` error code until it completes or abandons one. ## Next steps * [Item Streaming: Server-to-Client](/guide/streaming/server-to-client) — IAsyncEnumerable patterns (different from file transfer) * [Client Manager](/guide/server/client-manager) — push files from controllers * [Wire Protocol](/reference/wire-protocol) — protocol details --- --- url: /roadmap/http-streams.md description: >- Status of HTTP stream references per client: buffered and streaming downloads, uploads, and the open items --- # HTTP Stream References File transfer via `Stream` parameters works in both directions for all three clients, with both buffered and streaming download options. ## Streaming Download (Not Buffered) **Status:** Implemented All three clients now offer both buffered and streaming download options: | Client | Buffered (default) | Streaming | |--------|-------------------|-----------| | .NET | `ProcessStreamArgument()` → `Stream` | Same — already streaming via `ResponseHeadersRead` | | .NET | `ProcessStreamArgumentBuffered()` → `byte[]` | — | | TypeScript | `resolveStreamReference()` → `ArrayBuffer` | `resolveStreamReferenceAsStream()` → `ReadableStream` | | Swift | `StreamReferenceResolver.resolve()` → `Data` | `StreamReferenceResolver.resolveAsStream()` → `URLSession.AsyncBytes` | The default `_prepareArgs` in all clients uses the buffered variant for backward compatibility. Developers can use the streaming variants directly when handling large files. **.NET** additionally uses `HttpCompletionOption.ResponseHeadersRead` so even the default `Stream` return doesn't wait for the full response body before returning. ## Extensible Reference Type Registry **Status:** Future consideration Currently, the reference type system is hard-coded for `Stream` → `StreamReference`. If more types need the "send reference, resolve on the other side" pattern, the system should be made extensible with an `IReferenceTypeHandler` interface and a registry. Build this when a second use case emerges — not before. --- --- url: /guide/server/hub-setup.md description: >- Create a HARRR hub, register it with AddSignalARRR and MapSignalARRRHub, enable MessagePack, run several hubs, tune connection options, and use the hub lifecycle and properties --- # Hub Setup The `HARRR` class is the SignalARRR hub base class. It extends ASP.NET Core's `Hub` with typed method dispatch, authorization, client context tracking, and streaming support. ## Create a hub Every SignalARRR application needs at least one hub. The hub itself can be empty — actual method implementations go into [ServerMethods](/guide/server/server-methods) classes. ```csharp public class AppHub : HARRR { public AppHub(IServiceProvider sp) : base(sp) { } } ``` ## Register and map In `Program.cs`, register SignalARRR services and map the hub endpoint: ```csharp builder.Services.AddSignalR(); builder.Services.AddSignalARRR(options => options .AddServerMethodsFrom(typeof(Program).Assembly)); var app = builder.Build(); app.UseRouting(); app.MapSignalARRRHub("/apphub"); ``` ### MessagePack support For better performance with many clients, add MessagePack alongside JSON: ```csharp builder.Services.AddSignalR() .AddMessagePackProtocol(); // clients can choose JSON or MessagePack ``` Both protocols run simultaneously — JSON clients and MessagePack clients connect to the same hub. `AddServerMethodsFrom()` scans the assembly for all `ServerMethods` classes and registers them in DI. `MapSignalARRRHub()` maps the hub at the specified path and also registers a download endpoint at `{path}/download/{id}` for file stream references. ## Multiple hubs You can have multiple hubs in the same application: ```csharp public class ChatHub : HARRR { public ChatHub(IServiceProvider sp) : base(sp) { } } public class AdminHub : HARRR { public AdminHub(IServiceProvider sp) : base(sp) { } } ``` ```csharp app.MapSignalARRRHub("/chathub"); app.MapSignalARRRHub("/adminhub"); ``` ServerMethods classes are scoped to a specific hub type via the generic parameter: ```csharp public class ChatMethods : ServerMethods, IChatHub { ... } // only on ChatHub public class AdminMethods : ServerMethods, IAdminHub { ... } // only on AdminHub ``` ## Connection options `MapSignalARRRHub` accepts SignalR's `HttpConnectionDispatcherOptions` for configuring transports, buffer sizes, and authorization: ```csharp app.MapSignalARRRHub("/apphub", options => { options.Transports = HttpTransportType.WebSockets; options.ApplicationMaxBufferSize = 64 * 1024; }); ``` ## Hub lifecycle `HARRR` provides the standard SignalR lifecycle hooks. Override them to run logic when clients connect or disconnect: ```csharp public class AppHub : HARRR { public AppHub(IServiceProvider sp) : base(sp) { } public override async Task OnConnectedAsync() { await base.OnConnectedAsync(); // registers client in ClientManager Logger?.LogInformation("Client {Id} connected", Context.ConnectionId); } public override async Task OnDisconnectedAsync(Exception? exception) { Logger?.LogInformation("Client {Id} disconnected", Context.ConnectionId); await base.OnDisconnectedAsync(exception); // unregisters client } } ``` ::: warning Always call `base.OnConnectedAsync()` and `base.OnDisconnectedAsync()` — they manage the client registration in [ClientManager](/guide/server/client-manager). ::: ## Hub properties | Property | Type | Description | |----------|------|-------------| | `ServiceProvider` | `IServiceProvider` | DI container for the current request | | `Logger` | `ILogger?` | Logger instance (resolved from DI) | | `ClientContext` | `ClientContext` | Enhanced context for the calling client | | `Context` | `HubCallerContext` | Standard SignalR caller context | | `Clients` | `IHubCallerClients` | Access to client connections | | `Groups` | `IGroupManager` | Group management | ## Next steps * [Server Methods](/guide/server/server-methods) — split hub logic across classes * [Authorization](/guide/server/authorization) — protect methods with `[Authorize]` * [Client Manager](/guide/server/client-manager) — call clients from outside the hub --- --- url: /guide/kotlin-client/setup.md description: >- The Kotlin client for Android and the JVM: Gradle setup, create a connection, invoke / send / stream with coroutines and Flow, errors and error codes, MessagePack, token authentication, reconnection, transports, logging, and using the plain SignalR layer --- # Kotlin Client Setup The `dev.cocoar:signalarrr` package is a native client for Android and the JVM. It is a full SignalR client built from scratch — no dependency on Microsoft's Java client — with coroutines and `Flow` as its async model. ::: info Requirements Kotlin 2.0+, JVM 8+ bytecode; Android API 21+. Dependencies: `kotlinx-coroutines`, `kotlinx-serialization-json`, OkHttp 5 and `msgpack-core`. No Android SDK dependency, so the same artifact serves server-side Kotlin and desktop apps. ::: ## Installation ```kotlin // build.gradle.kts plugins { kotlin("plugin.serialization") version "2.4.20" id("com.google.devtools.ksp") version "2.3.12" // only for @HubProxy typed proxies } dependencies { implementation("dev.cocoar:signalarrr:5.2.0") ksp("dev.cocoar:signalarrr-ksp:5.2.0") // only for @HubProxy typed proxies } ``` The serialization plugin is what makes your own data classes `@Serializable`; the client encodes arguments and decodes results with `kotlinx.serialization`. ## Create a connection ```kotlin import dev.cocoar.signalarrr.HARRRConnection val connection = HARRRConnection.create("https://localhost:5001/apphub") ``` ## Authentication A connection has two credentials. Usually they are the same token, so one option sets both: ```kotlin val connection = HARRRConnection.create("https://localhost:5001/apphub") { credential = { tokenStore.currentToken() } } ``` To give them different credentials — a single-use connection ticket, say, which has no business being resent with every message — set them separately: ```kotlin val connection = HARRRConnection.create("https://localhost:5001/apphub") { connectionCredential = { connectionTicket() } messageCredential = { tokenStore.currentToken() } } ``` | Option | Authenticates | Travels as | Checked by | When it expires | |---|---|---|---|---| | `connectionCredential` | negotiate and the transport | `Authorization` header | `[Authorize]` on the hub class, `.RequireAuthorization()` on the mapping | fetched again on every connect and reconnect | | `messageCredential` | every message, the answer to a challenge, file transfers | the `Authorization` field of the message; a header on file transfers | `[Authorize]` on a method or a `ServerMethods` class | fetched on every use, so a refreshed token is sent from the next call on | | `credential` | both of the above | | | | The options are named the same in every SignalARRR client, and nothing is coupled implicitly: `messageCredential` alone leaves the connection anonymous, `connectionCredential` alone sends no message credential. A `connectionCredential` or `messageCredential` next to `credential` takes its part over. A value without a space is sent as a bearer token; one with a space carries its own scheme. See [Authorization](/guide/server/authorization#the-two-credentials) for the same table across all clients. The connection credential travels as a header on the negotiate request and on every request of the transport — the WebSocket upgrade, the SSE stream and its posts, every Long Polling request — never in a URL, where proxy logs and error reports would keep it. For a server that reads it only from the URL, set `transportCredential = TransportCredential.QUERY`; see [where the connection token travels](/guide/server/authorization#where-the-connection-token-travels). Token challenges are handled automatically — while a stream is running the server may send a `ChallengeAuthentication` message, and the client calls the message credential to answer it. A credential set in two places fails at `create` with an `IllegalArgumentException`: `accessTokenProvider` (the SignalR-level connection token) next to `connectionCredential` or `credential`, or the former `messageAccessTokenProvider` next to any of the new options. ::: tip Former name `messageAccessTokenProvider` is the message credential under its former name, and it also covered the connection when `accessTokenProvider` was not set. It still works that way and is marked deprecated; replace it with `credential` — or with `messageCredential` if the connection should stay anonymous. ::: ## All options ```kotlin val connection = HARRRConnection.create("https://localhost:5001/apphub") { hubProtocol = HubProtocolKind.JSON // or MESSAGE_PACK credential = { tokenStore.currentToken() } // or connectionCredential / messageCredential serverTimeout = 30.seconds keepAliveInterval = 15.seconds handshakeTimeout = 15.seconds reconnectPolicy = ReconnectPolicy.Default // immediate, 2s, 10s, 30s — then give up allowedTransports = listOf(TransportType.WEB_SOCKETS, TransportType.SERVER_SENT_EVENTS, TransportType.LONG_POLLING) transportCredential = TransportCredential.HEADER // or QUERY: connection token in the transport URL headers = mapOf("X-Api-Key" to apiKey) // extra headers on negotiate and transport requests httpClient = sharedOkHttpClient // reuse the app's OkHttp client json = Json { ignoreUnknownKeys = true } // the kotlinx.serialization configuration logger = LogcatLogger(LogLevel.INFO) // your SignalARRRLogger implementation } ``` ## Start and stop ```kotlin connection.start() // ... use the connection ... connection.stop() ``` Both are `suspend` functions. `stop()` fails pending calls with `DisconnectedException` and fires `onClosed` without an error. ## Invoke (call with return value) ```kotlin val history: List = connection.invoke("ChatMethods.GetHistory") val user = connection.invoke("UserMethods.GetUser", userId) ``` The result type is reified, so generic results like `List` or `Map` decode directly. Arguments are encoded structurally: primitives, `UUID`, `Instant`, collections and maps need nothing, and any other class must be `@Serializable`. ## Send (fire-and-forget) ```kotlin connection.send("ChatMethods.SendMessage", "Alice", "Hello!") ``` ## Stream ```kotlin connection.stream("ChatMethods.StreamMessages").collect { msg -> println(msg) } ``` `stream` returns a cold `Flow`. Cancelling the collector — leaving the scope, `take(n)`, `first()` — sends `CancelInvocation` so the server stops producing. ## Errors A failed call throws `HARRRException` carrying the server's structured error. Branch on `code`, never on the message: ```kotlin try { connection.invoke("RoomMethods.Join", roomId) } catch (e: HARRRException) { when (e.code) { HARRRErrorCodes.UNAUTHORIZED -> promptLogin() HARRRErrorCodes.METHOD_NOT_FOUND -> reportContractMismatch(e) else -> when (e.error.code) { // application codes travel verbatim "room_full" -> showRoomFull() else -> showGeneric(e.error.message) } } } ``` `e.code` is the code folded to the set this client knows (unknown codes become `internal`); `e.error.code` is the raw wire value, which is where an application's own `HARRRException("room_full", ...)` codes appear. `e.error.innerError` nests the cause chain. The codes are the same in every SignalARRR client. A connection the server rejects at negotiate — 401 or 403 for a missing or invalid connection credential — fails `start()` with `NegotiationFailedException`, whose `statusCode` carries the HTTP status: ```kotlin try { connection.start() } catch (e: NegotiationFailedException) { if (e.statusCode == 401) promptLogin() else throw e } ``` ## Connection events ```kotlin connection.onClosed { error -> println("Connection closed: ${error?.message ?: "clean"}") } connection.onReconnecting { error -> println("Reconnecting: ${error?.message}") } connection.onReconnected { connectionId -> println("Reconnected as $connectionId") } // Or observe the state as a StateFlow — handy for Compose/ViewModels connection.client.stateFlow.collect { state -> render(state) } ``` ## Connection properties | Property | Type | Description | |----------|------|-------------| | `connectionId` | `String?` | Current connection ID | | `state` | `HubConnectionState` | `DISCONNECTED`, `CONNECTING`, `CONNECTED`, `RECONNECTING` | | `client.stateFlow` | `StateFlow` | The state as a flow | | `client.transportType` | `TransportType?` | The transport in use | | `serverTimeout`, `keepAliveInterval`, `handshakeTimeout` | `Duration` | The configured timeouts | ## MessagePack protocol ```kotlin val connection = HARRRConnection.create("https://localhost:5001/apphub") { hubProtocol = HubProtocolKind.MESSAGE_PACK } ``` MessagePack is built in (`msgpack-core`); nothing else to install. Server-Sent Events cannot carry binary frames, so the transport list falls back to WebSockets and Long Polling. ::: info Server setup The server needs `.AddMessagePackProtocol()`. See [MessagePack](/roadmap/messagepack) for details. ::: ## Reconnection policy ```kotlin // Default: immediate retry, then 2s, 10s, 30s — then give up reconnectPolicy = ReconnectPolicy.Default // Custom delays reconnectPolicy = ReconnectPolicy(listOf(Duration.ZERO, 1.seconds, 5.seconds, 10.seconds, 30.seconds)) // Disable reconnection reconnectPolicy = ReconnectPolicy.Disabled ``` A reconnect is a new connection: pending calls fail, `onReconnecting` fires, and after a successful attempt `onReconnected` reports the new connection id. Server-to-client handlers registered before stay registered. The server also treats the client as gone when it receives nothing for `serverTimeout`; the client pings every `keepAliveInterval` on every transport to prevent that, and closes the connection itself when the server has been silent for `serverTimeout`. ## Transport selection The client tries transports in preference order and connects via the first one the server supports; a transport that fails to connect is skipped for the next: ```kotlin // Default: WebSockets → SSE → Long Polling allowedTransports = listOf(TransportType.WEB_SOCKETS, TransportType.SERVER_SENT_EVENTS, TransportType.LONG_POLLING) // Force WebSockets only allowedTransports = listOf(TransportType.WEB_SOCKETS) ``` ## Logging The client has no logging dependency. It writes to `System.err` by default; on Android, plug in Logcat: ```kotlin class LogcatLogger(override val level: LogLevel) : SignalARRRLogger { override fun log(level: LogLevel, message: String, throwable: Throwable?) { when (level) { LogLevel.DEBUG -> Log.d("SignalARRR", message, throwable) LogLevel.INFO -> Log.i("SignalARRR", message, throwable) LogLevel.WARNING -> Log.w("SignalARRR", message, throwable) LogLevel.ERROR -> Log.e("SignalARRR", message, throwable) LogLevel.NONE -> Unit } } } ``` `NoopLogger` silences the client entirely. ## Android notes * Add ``. * Cleartext `http://` URLs need `android:usesCleartextTraffic="true"` (or a network security config) on API 28+; emulators reach the host machine at `10.0.2.2`. * Keep the connection in a `ViewModel` or a foreground service scope, not an `Activity`; the connection's own coroutines run on `Dispatchers.IO`, and handlers are dispatched there too — switch to `Dispatchers.Main` before touching views. * With R8/ProGuard, keep the `@Serializable` classes' serializers as the kotlinx.serialization guide describes; the client itself needs no rules. ## Using SignalRClient directly `HARRRConnection` is the SignalARRR-specific wrapper. To talk to any standard SignalR hub (without the SignalARRR server library), use `SignalRClient` directly. It speaks in `JsonElement`s: ```kotlin val client = SignalRClient("https://any-signalr-server.com/hub") { hubProtocol = HubProtocolKind.MESSAGE_PACK } client.start() val result = client.invoke("MyMethod", listOf(JsonPrimitive("param"))) client.on("OnMessage") { args -> println(args) null } ``` `connection.client` exposes the same object underneath a `HARRRConnection`, and `connection.on(...)` registers raw SignalR event handlers with typed argument access. ## Next steps * [Typed Proxies & Server Methods](./typed-proxies) — `@HubProxy` and server-to-client handlers * [Getting Started](../getting-started) — full setup walkthrough * [Packages](../../reference/packages) — all available packages --- --- url: /roadmap/messagepack.md description: >- Status of the MessagePack hub protocol across the .NET, TypeScript and Swift clients --- # MessagePack Protocol **Status:** Fully implemented across all clients (.NET, TypeScript, Swift). ## Overview SignalARRR supports MessagePack alongside JSON. Both protocols run simultaneously — different clients can use different protocols on the same hub. **Architecture:** * `IProtocolSerializer` abstraction in `Cocoar.SignalARRR.Common.Serialization` * `JsonProtocolSerializer` handles both `JsonElement` (JSON) and plain .NET objects (MessagePack) via its fallback * All `JsonElement`/`JsonSerializer` direct usage removed from `MessageHandler` and `ServerStreamManager` ## Server Setup ```csharp builder.Services.AddSignalR() .AddMessagePackProtocol(); // Add this line — that's it builder.Services.AddSignalARRR(options => options .AddServerMethodsFrom(typeof(Program).Assembly)); ``` ## .NET Client ```bash dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack ``` ```csharp var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://server/hub"); builder.AddMessagePackProtocol(); }); ``` ## TypeScript Client ```bash npm install @microsoft/signalr-protocol-msgpack ``` ```ts import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack'; const connection = HARRRConnection.create(builder => { builder.withUrl('https://server/hub'); builder.withHubProtocol(new MessagePackHubProtocol()); }); ``` ## Swift Client No external dependencies — MessagePack is implemented natively in the Swift client. ```swift let connection = await HARRRConnection.create( url: "https://server/hub", hubProtocol: .messagepack ) ``` Or using `SignalRWebSocketClient` directly: ```swift let client = SignalRWebSocketClient( url: "https://server/hub", hubProtocol: .messagepack ) ``` ## Tests * 5 .NET MessagePack integration tests (invoke, send, echo, guid, multi-param) * 5 TypeScript MessagePack integration tests (same scenarios) * 5 Swift MessagePack integration tests (invoke, guid, send, echo, streaming with multiple int params) * All running alongside JSON tests on the same server instance --- --- url: /guide/migration/from-v2.md description: >- Upgrade from 2.x to 4.x: target frameworks, source-generated proxies instead of ImpromptuInterface, the authentication model, hub-level authorization inheritance, removed APIs and new packages --- # Migration from v2.x SignalARRR v4 is a major release with significant architectural changes. This guide covers all breaking changes and the steps to upgrade. ## Target framework **v2.x:** `netstandard2.0` **v4:** `net10.0` (except SourceGenerator which targets `netstandard2.0` per Roslyn requirements) Update your project target frameworks: ```xml net10.0 ``` ## Proxy generation **v2.x:** `ImpromptuInterface` for runtime proxy creation **v4:** Roslyn source generator + optional `DispatchProxy` fallback ### Steps 1. Remove `ImpromptuInterface` package references 2. Add `Cocoar.SignalARRR.Contracts` to shared interface projects 3. Mark interfaces with `[SignalARRRContract]`: ```csharp // Before (v2.x) — no attribute needed public interface IChatHub { ... } // After (v4) — attribute required [SignalARRRContract] public interface IChatHub { ... } ``` 4. If you need runtime proxy generation (plugin scenarios), add `Cocoar.SignalARRR.DynamicProxy` ## Authentication **v2.x:** Custom `IAuthenticator` interface with `TryAuthenticate()` and `SetAuthData()` **v4:** Standard ASP.NET Core `[Authorize]` attributes and authentication handlers ### Steps 1. Remove `IAuthenticator` implementations 2. Configure ASP.NET Core authentication: ```csharp builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { ... }); ``` 3. Use `[Authorize]` on methods and classes instead of custom auth logic ## Hub-level authorization inheritance **v2.x:** Hub-level `[Authorize]` was disabled — ServerMethods didn't inherit it **v4:** Hub-level `[Authorize]` is inherited by all ServerMethods classes If you had a hub with `[Authorize]` but relied on individual ServerMethods classes being unauthenticated, add `[AllowAnonymous]` to those classes: ```csharp [Authorize] public class SecureHub : HARRR { ... } [AllowAnonymous] // opt out of hub-level auth public class PublicMethods : ServerMethods { ... } ``` ## HTTP proxy pass-through **v2.x:** Available for large response streaming **v4:** Removed The `UseHttpResponse()` option and all related code has been removed. For large data transfer, use [HTTP Stream References](/guide/advanced/http-streams) instead — `Stream` parameters are automatically routed through HTTP. ## Removed APIs | Removed | Replacement | |---------|-------------| | `ImpromptuInterface` | `[SignalARRRContract]` + source generator | | `IAuthenticator` | ASP.NET Core `[Authorize]` | | `RegisterMethods()` | `RegisterInterface()` or typed proxies | | `TryAuthenticate()` / `SetAuthData()` | `ClientContext.User` / `[Authorize]` | | `netstandard2.0` polyfill packages | Native `net10.0` APIs | | Non-generic `Invoke(Type, ...)` overloads | Generic `Invoke(...)` | ## New packages | Package | Purpose | |---------|---------| | `Cocoar.SignalARRR.Contracts` | `[SignalARRRContract]` + source generator — add to shared projects | | `Cocoar.SignalARRR.DynamicProxy` | Optional runtime proxy fallback | ## New features * **Source generator** — compile-time proxies, AOT-friendly * **CancellationToken propagation** — server can cancel client operations * **Server stream requests** — server can request `IAsyncEnumerable` from clients * **`StreamItemToServer` / `StreamCompleteToServer`** — client-to-server streaming * **`ClientManager` typed extensions** — `GetTypedMethods(connectionId)` for server-to-client RPC outside hub context * **Authorization tests** — comprehensive test coverage for auth scenarios ## Next steps * [Getting Started](/guide/getting-started) — fresh start with v4 * [Proxy Generation](/guide/advanced/proxy-generation) — source generator details * [Authorization](/guide/server/authorization) — new authorization system --- --- url: /guide/migration/from-v4.md description: >- Upgrade from 4.x to 5.0: WithHub() returns IClientQuery, the message credential for token clients, the moved namespaces, the separate Redis backplane package, removed APIs, and exception detail that no longer reaches the caller --- # Migration from v4.x v5 is the result of a full code review of the server and the .NET client. Most of it is fixes you get for free. This page covers only what you have to change, grouped by whether it is likely to affect you at all. If your application uses the typed contract API and nothing else, the first section is probably the whole job. ## Affects almost everyone ### `WithHub()` returns `IClientQuery` It used to return `IEnumerable`, which made `.Where(...)` compile. That was a trap: LINQ dropped the query's cluster scope, so a broadcast that looked cluster-wide silently reached only the local node. The old docs even recommended it. ```csharp // Before — compiles, and under a backplane reaches one node await _clients.WithHub().Where(c => c.User.IsInRole("Admin")) .SendAsync(c => c.SecurityAlert(details)); // After — the same node-local behaviour, but the name says so await _clients.WithHub().WithLocalFilter(c => c.User.IsInRole("Admin")) .SendAsync(c => c.SecurityAlert(details)); // After — what you probably meant: every admin on every node await _clients.WithHub().WithAttribute("role", "admin") .SendAsync(c => c.SecurityAlert(details)); ``` A predicate cannot be evaluated on another node — it is a delegate over local objects — so `WithLocalFilter` is honest about its reach. Group, user and attribute filters are the ones the connection registry can answer cluster-wide. Enumerating a query also needs a name now, because it was never showing you remote connections: ```csharp foreach (var c in _clients.WithHub()) { … } // Before foreach (var c in _clients.WithHub().LocalClients()) { … } // After ``` `SendAsync`, `InvokeAllAsync` and `InvokeOneAsync` are instance methods on `IClientQuery` now, so they are no longer visible on arbitrary `IEnumerable` values, and the fluent API no longer needs `using Cocoar.SignalARRR.Server.ExtensionMethods;`. See [Client Manager](/guide/server/client-manager). ### Token clients must configure the message credential *Only if your clients authenticate with a token. Certificate, Negotiate and Windows clients are unaffected.* There are two credentials in play, and they used to be one setting by accident. SignalR's `AccessTokenProvider` (`accessTokenFactory` in TypeScript) authenticates the **connection** — that is what `[Authorize]` on a hub class checks at negotiate. SignalARRR sends its own credential with **each message**, which is what `[Authorize]` on a method or a `ServerMethods` class checks, and what answers a challenge when the server's auth cache expires. The clients used to fill the second from the first by reflecting into SignalR's private fields. They no longer do, so say it explicitly. Usually that means handing the same factory to both: ```csharp // Before var connection = HARRRConnection.Create(builder => { builder.WithUrl(url, o => o.AccessTokenProvider = () => Task.FromResult(GetToken())); }); // After var connection = HARRRConnection.Create( builder => { builder.WithUrl(url, o => o.AccessTokenProvider = () => Task.FromResult(GetToken())); }, options => options.WithAuthorization(() => Task.FromResult(GetToken()))); ``` ```ts // After — TypeScript const connection = HARRRConnection.create( builder => builder.withUrl(url, { accessTokenFactory: () => getToken() }), { authorization: () => getToken() }, ); ``` **How you notice if you miss it:** mostly you do not, and that is deliberate. A connection that authenticates only its transport keeps working on the principal it negotiated with, the way it would under plain SignalR — the expiry stated on that principal is still enforced, so an `exp` in the past stops it. What you lose without a message credential is the refresh: the server can no longer catch a revoked credential, and cannot ask for a new one, so a long-lived connection runs on the identity it started with until its stated expiry. Configure it if your tokens are short-lived and refreshed, which is the usual reason for having them. **Why it is worth the change:** the two credentials are not always the same. A single-use connection ticket belongs on the connection and has no business being resent with every message — which is what the old behaviour did, with no way to stop it short of reaching into the client's private state. And the reflection it relied on was reading field names that are not API; the TypeScript half of it had already produced a silent wire bug in this release. The .NET Framework client always took its provider explicitly and needs no change. Swift takes one on `HARRRConnection.create` and now has a separate one on `SignalRWebSocketClient` for the connection. ### The entry points moved to the conventional namespaces Registration and endpoint mapping now live where the rest of ASP.NET Core puts them. If your `using` directives were already the conventional ones, nothing changes; otherwise the compiler will point at each site. ## Only if you use that feature ### Redis backplane: add a package The backplane moved out of `Cocoar.SignalARRR.Server` so that single-node applications stop carrying `StackExchange.Redis`. ```xml ``` `AddSignalARRRRedisBackplane`, its options builder and their namespaces are unchanged — the package reference is the entire migration. See [Backplane & Clustering](/guide/server/backplane). ### `OnServerRequest` is gone It registered handlers into a table nothing read, so a server-to-client call by bare method name always failed — with "Method 'X' not found!", or silently on the fire-and-forget path. There is no ad-hoc replacement; `RegisterInterface` with a contract interface is the working path, and was. ```csharp // Before — never worked connection.OnServerRequest("ReceiveMessage", (string user, string message) => { … }); // After connection.RegisterInterface(); ``` Removed from the .NET Framework client too, where it had the same defect. ### `ITransportAuthRevalidationService` returns a result, not a bool *Only if you implemented it. Most applications use the built-in one and change nothing.* ```csharp // Before public Task RevalidateAsync(ClientContext c, CancellationToken ct = default) => Task.FromResult(StillGood(c)); // After public Task RevalidateAsync(ClientContext c, CancellationToken ct = default) => Task.FromResult(StillGood(c)); // bool converts implicitly ``` The signature change is the whole migration; a `bool` still converts, meaning `Valid()` or `Deny()`. What the result adds is a third outcome, `Abort()`, which drops the connection instead of only refusing the call, and an optional validity window that overrides `AuthCacheDuration` for that connection. See [Authorization](/guide/server/authorization). ### `ClientRequestMessage.WithAuthorization(Func>)` is gone The blocking overload behind a UI deadlock: it resolved the token provider with `GetAwaiter().GetResult()`, which deadlocks on any single-threaded `SynchronizationContext` — WPF, WinForms, MAUI. Use `WithAuthorizationAsync`, or `WithAuthorization(string)` for a token already at hand (that one is untouched). ### `ClientManager.GetHARRRClients()` is gone `[Obsolete]` since 4.x with a message naming this release. `WithHub()` replaces it and reaches the whole cluster rather than the local node. For the predicate overload see `WithLocalFilter` above. ### The MVC result clones on `HttpContext` are gone `Ok`, `NotFound`, `BadRequest`, `File` in 24 overloads and about 70 more — a reimplementation of `ControllerBase` that landed on every `HttpContext` in any file importing `Cocoar.SignalARRR.Server.ExtensionMethods`. `Microsoft.AspNetCore.Http.Results` and `ControllerBase` are the framework's own answers. ### `HARRRConnectionExtensions`: at most four arguments The file was decompiled Microsoft code and has been rewritten. Handler registration went from `On` to `On`, and calls from 0–10 arguments to 0–4. Past four, use the `object[]` core methods on `HARRRConnection` — or a typed contract via `GetTypedMethods()`, which the removed overloads had buried in the completion list. Also gone: the `InvokeCoreAsync` and `StreamAsChannelCoreAsync` *extension* methods. Identically shaped instance methods always won overload resolution, so nothing ever called them. ### `ClientAttributes` no longer derives from `Dictionary` It inherited `Dictionary` and hid the indexer with one returning `string?`, so the same object answered the same key two ways — `null` through `ClientAttributes`, `KeyNotFoundException` through the dictionary it *was*. It is now a sealed `IReadOnlyDictionary` with one indexer that keeps the interface's contract. The old `string?` lookup lives on as `GetString(key)` — use it wherever you relied on `attributes[key]` being `null` for an absent attribute. `Has(key)`, `Has(key, value)`, enumeration and case-insensitive matching are unchanged. `Add`, `Remove` and `Clear` are gone: this is a read model of the connection request. ### `HARRRException(string, string)` changed meaning The second parameter is now the error **code**, not a second message — errors travel with a small, stable set of codes, and an application code you pass travels verbatim. Relevant only if you construct these yourself. ### `GetClientById` is nullable now — and always could return null `ClientManager.GetClientById` was declared as returning `ClientContext` while returning `null` for a connection this node does not have. The signature now says so: ```csharp // Before: compiles, throws at runtime when the client is gone. var user = clients.GetClientById(id).User; // After: CS8602 points at exactly that line. var user = clients.GetClientById(id)?.User; ``` Nothing about the runtime behaviour changed — only whether the compiler tells you. If you get new warnings, each one marks a place that could already throw. A miss is ordinary: the connection may have dropped a moment ago, or live on another node, which this method does not see. For the cluster-wide question use `WithHub()` and its filters. `HARRR.ClientContext` is unchanged and stays non-nullable — inside a hub method the connection is registered by definition. Reading it before `OnConnectedAsync` has run now throws with an explanation instead of a bare `NullReferenceException` somewhere further along. ### `MapSignalARRRHub` returns `IHubEndpointConventionBuilder` It used to return SignalR's concrete `HubEndpointConventionBuilder`. Chaining is unchanged — `RequireAuthorization`, `RequireCors` and the rest are extensions on the interface — so this only matters if you assigned the result to an explicitly typed variable: ```csharp HubEndpointConventionBuilder hub = app.MapSignalARRRHub("/apphub"); // before IHubEndpointConventionBuilder hub = app.MapSignalARRRHub("/apphub"); // after, or use var ``` The reason is a fix, not tidiness: the returned builder now applies what you chain onto it to the hub **and** to the `/download/{id}` and `/upload/{id}` endpoints that belong to it. Previously `.RequireAuthorization()` configured the hub alone and left the transfer endpoints anonymous. **Check your setup:** if you secured a hub by chaining rather than with an `[Authorize]` attribute, its file-transfer endpoints were open until now. Nothing in your code needs to change — they are covered from this version on — but it is worth knowing they were reachable. ### Upload slots are capped per connection A connection may hold 32 unused upload slots at a time. Beyond that `RequestUploadSlot` fails with the new `upload_slot_limit_reached` code. Slots are consumed as soon as their upload completes, so this only bites code that requests slots without using them; raise or remove it with `WithMaxUploadSlotsPerConnection(n)` (`0` disables the cap) if your application genuinely needs more in flight at once. An upload slot is also now bound to the connection that requested it — another connection naming that URL as a `Stream` argument gets "upload slot not found". This is only a change for code that deliberately passed a slot URL between clients, which was never intended to work. ### An unexpected exception no longer reaches the caller with its detail If a hub method throws something the pipeline does not recognize, the client used to receive the exception's .NET type name, its `Message` and its cause chain. It now receives a fixed sentence and a correlation id, under the same `internal` code as before: ``` The server failed to handle this call. Correlation id: 3f9a1c74b2e0 ``` The exception is logged on the server under that id, so the detail is still there — just not on the client. Recognized codes (`unauthorized`, `timeout`, `cancelled`, `argument_binding_failed`, the resolution codes, `no_client_responded`) are unchanged and keep their message and cause chain. **What to change:** if a failure was meant to be actionable for the caller, throw it as an application error instead of letting it escape: ```csharp // Before: the caller saw "Room 'lobby' has 50 of 50 participants." throw new InvalidOperationException($"Room '{id}' has {count} of {max} participants."); // After: say explicitly that this one is for the client. throw new HARRRException("room_full", $"Room '{id}' is full."); ``` If you were relying on `error.Type` to branch, use `error.Code` — that is what it is for, and it is the only field a TypeScript or Swift client could ever act on anyway. ## Only if you have TypeScript or Swift clients ### Remote reference arguments carry a `__type` marker An argument that is a handle rather than a value — a cancellation token, a stream — now travels as `{ "__type": "cancellationToken", "Id": … }` or `{ "__type": "stream", "Uri": … }`. Receivers used to guess from the shape and guessed wrong on ordinary data: the TypeScript client treated *any* object with a string `Id` as a cancellation token, so a payload like `{ Id: "user-42", Name: "Ada" }` was swapped for a token and never reached the handler. Update `@cocoar/signalarrr` and the Swift package together with the server. ### Handler names are contract wire names — check yours Not a v5 change, but worth verifying while you are here, because the guides had it wrong: the name you register must be the **full contract name**, `interface|method`, and it is matched exactly. A miss is dropped without a word. ```ts connection.onServerMethod('MyApp.Contracts.IChatClient|ReceiveMessage', handler); // fires connection.onServerMethod('ReceiveMessage', handler); // never fires ``` New in v5: you can declare those names explicitly with `[MessageName]` so a C# rename stops breaking deployed clients. See [Contract Wire Names](/guide/server/contracts-wire-names). ## Only if you touched infrastructure types These were never meant to be application-facing and are now `internal`, or changed shape. If you did not reference them, there is nothing to do. | Type or member | Change | |---|---| | `MessageHandler`, `StreamReferenceResolver`, `HubConnectionExtensions` (client) | `internal` | | `SignalARRRAuthentication`, `MethodArgumentPreparer`, `EndpointExtensions`, the file-transfer handlers (server) | `internal` | | `HARRRConnection`'s constructor | `internal` — `HARRRConnection.Create(...)` is and was the way to build one | | `HARRRContext` (client) | renamed `ClientConnectionContext`, and `internal` | | `MethodArgumentPreperator` | renamed `MethodArgumentPreparer` | | `ClientCollectionResult` | renamed `ClientResult` — it holds one result | | `ObservableExtensions` (server) | renamed `SignalARRRObservableExtensions`, which collided with `System.ObservableExtensions` | | `MethodNames` | `static class`, all names `const` — they had public setters, so any consumer could rewrite the wire protocol process-wide | | `ServerStreamManager`, `ServerPushStreamManager.WaitForUpload` | signature changes | | `MethodInfoExtensions.GetAuthorizeData()` | signature change | | `ISignalARRRMethodsCollection.GetMethodInformations`, `ISignalARRRInterfaceCollection.GetInvokeInformation` | signature changes | ## Next steps * [Changelog](/changelog) — the full list, including everything fixed that needs no action from you * [Client Manager](/guide/server/client-manager) — the reshaped query API * [Contract Wire Names](/guide/server/contracts-wire-names) — new in v5 --- --- url: /reference/packages.md description: >- Every NuGet package, the npm package, the Swift package and the Kotlin packages: purpose and target frameworks, which project references what, typical setups per project role, the dependency graph, and the bundled agent skill --- # Packages SignalARRR is distributed as multiple NuGet packages, one npm package, one Swift package and two Kotlin (Maven) packages. Choose the packages that match your project's role. ## .NET Packages | Package | Target | Purpose | |---------|--------|---------| | `Cocoar.SignalARRR.Contracts` | net8.0 / net9.0 / net10.0 | `[SignalARRRContract]` attribute. Reference from shared interface projects. | | `Cocoar.SignalARRR.Server` | net8.0 / net9.0 / net10.0 | Server-side: `HARRR` hub, `ServerMethods`, authorization, `ClientManager`, streaming. | | `Cocoar.SignalARRR.Server.Backplane.Redis` | net8.0 / net9.0 / net10.0 | Multi-node scale-out: `AddSignalARRRRedisBackplane`. Add only when running more than one node — this is where the `StackExchange.Redis` dependency lives. | | `Cocoar.SignalARRR.Server.Backplane.Postgres` | net8.0 / net9.0 / net10.0 | Multi-node scale-out over PostgreSQL `LISTEN`/`NOTIFY`: `AddSignalARRRPostgresBackplane`. For deployments whose only stateful dependency is Postgres — this is where the `Npgsql` dependency lives. Requires Npgsql 8.0.6 or later on every target framework, so it fits whatever version the rest of your stack (Marten, EF Core) pins. | | `Cocoar.SignalARRR.Client` | net8.0 / net9.0 / net10.0 | Client-side: `HARRRConnection`, typed proxies, server-to-client handlers. | | `Cocoar.SignalARRR.Client.FullFramework` | netstandard2.0 (.NET Framework 4.6.2+) | Client for .NET Framework — typed proxies via `DispatchProxy`, streaming via polyfills. | | `Cocoar.SignalARRR.DynamicProxy` | net8.0 / net9.0 / net10.0 | Optional runtime proxy fallback via `DispatchProxy`. For plugin/dynamic scenarios. | ### Internal packages These packages are referenced transitively — you normally don't need to reference them directly: | Package | Purpose | |---------|---------| | `Cocoar.SignalARRR.Common` | Shared types, wire protocol constants, message models | | `Cocoar.SignalARRR.ProxyGenerator` | Base classes for proxy creation (`ProxyCreator`, `ProxyCreatorHelper`) | | `Cocoar.SignalARRR.SourceGenerator` | Roslyn incremental source generator — a dependency of Server and Client, so it runs in the projects that use proxies | ## npm Package | Package | Version | Purpose | |---------|---------|---------| | `@cocoar/signalarrr` | 5.0.0 | TypeScript/JavaScript client: `HARRRConnection`, `invoke`, `send`, `stream`, `onServerMethod` | ### Peer dependency The npm package requires `@microsoft/signalr` ^10.0.0: ```bash npm install @cocoar/signalarrr @microsoft/signalr ``` ## Swift Package | Package | Purpose | |---------|---------| | `CocoarSignalARRR` | Core Swift client: `HARRRConnection`, `invoke`, `send`, `stream`, `onServerMethod`, stream references | | `CocoarSignalARRRMacros` | `@HubProxy` macro for compile-time proxy generation from Swift protocols | ### Dependencies * `signalr-client-swift` (1.0.0-preview.1+) — Microsoft's SignalR Swift client * `swift-syntax` (510.0.0+) — for macro code generation ### Swift Package Manager ```swift dependencies: [ .package(url: "https://github.com/cocoar-dev/Cocoar.SignalARRR.git", from: "5.0.0"), ], targets: [ .target( name: "MyApp", dependencies: [ .product(name: "CocoarSignalARRR", package: "Cocoar.SignalARRR"), .product(name: "CocoarSignalARRRMacros", package: "Cocoar.SignalARRR"), ] ), ] ``` **Platforms:** iOS 14+, macOS 11+, tvOS 14+, watchOS 7+ ## Kotlin Packages | Package | Purpose | |---------|---------| | `dev.cocoar:signalarrr` | Kotlin client for Android and the JVM: `HARRRConnection`, `invoke`, `send`, `stream`, `onServerMethod`, stream references, plus the plain `SignalRClient` underneath | | `dev.cocoar:signalarrr-ksp` | KSP processor generating typed proxies from `@HubProxy` interfaces | ### Dependencies * `kotlinx-coroutines-core` and `kotlinx-serialization-json` (exposed as API) * OkHttp 5 — WebSocket, SSE and long-polling transports, file transfer * `msgpack-core` — the MessagePack hub protocol, built in ### Gradle ```kotlin plugins { kotlin("plugin.serialization") version "2.4.20" id("com.google.devtools.ksp") version "2.3.12" } dependencies { implementation("dev.cocoar:signalarrr:5.2.0") ksp("dev.cocoar:signalarrr-ksp:5.2.0") } ``` **Platforms:** Android 5+ (API 21), JVM 8+. The library is a plain Kotlin/JVM artifact with no Android SDK dependency. ## Typical project setup ### Shared interfaces (class library) ```xml net8.0 ``` ### Server (ASP.NET Core) ```xml ``` Optional multi-node scale-out — separate packages, so single-node applications pull in neither `StackExchange.Redis` nor `Npgsql`. Pick one: ```xml ``` ```csharp builder.Services.AddSignalARRRRedisBackplane(options => options .WithConnectionString("localhost:6379,abortConnect=false")); ``` This backplane is Redis-compatible and works with Redis, Valkey, and Garnet. ```xml ``` ```csharp builder.Services.AddSignalARRRPostgresBackplane(options => options .WithConnectionString("Host=db;Database=app;Username=app;Password=...")); ``` This backplane uses the PostgreSQL primary your application already has; see [Backplane & Clustering](/guide/server/backplane) for how to choose between the two. ### .NET Client (Console / WPF / etc.) ```xml ``` ### .NET Framework Client (4.6.2+ / 4.8) For legacy .NET Framework projects (e.g., SCCM/SCSM integration, WinForms, WPF on .NET Framework): ```xml ``` ::: info No shared interface project needed The FullFramework client uses `DispatchProxy` at runtime. Define the same interface in your .NET Framework project (same namespace and method names) — no `[SignalARRRContract]` attribute needed. ::: ### TypeScript Client ```json { "dependencies": { "@cocoar/signalarrr": "^5.0.0", "@microsoft/signalr": "^10.0.0" } } ``` ## Agent skill `Cocoar.SignalARRR.Server` ships an [Agent Skill](https://agentskills.io/) — a `SKILL.md` plus this documentation page by page as reference files — so a coding assistant in your project knows the library's API and the mistakes it would otherwise make. It sits in a `skills/` folder at the package root and takes no part in your build or your IDE: nothing is copied or shown anywhere unless you install it. With [agentskills-cli](https://mysticmind.github.io/agentskills-cli/): ```bash dotnet tool install --global agentskills-cli agentskills-cli add Cocoar.SignalARRR.Server ``` That places the skill in `.claude/skills/` for Claude Code and `.agents/skills/` for Cursor, Codex, Copilot and other agents that read the standard; `-g` installs it globally instead. Without the tool, copy `skills/signalarrr/` out of the package into the same folders by hand. The skill is generated from these docs, so it says what the docs say for the version you reference. The same content is available online as [llms.txt](/llms.txt) (an index with one line per page) and [llms-full.txt](/llms-full.txt) (everything in one file) for assistants that fetch documentation by URL. ## Dependency graph ```mermaid graph TD Contracts["Cocoar.SignalARRR.Contracts"] Server["Cocoar.SignalARRR.Server"] Client["Cocoar.SignalARRR.Client"] Common["Cocoar.SignalARRR.Common"] ProxyGen["Cocoar.SignalARRR.ProxyGenerator"] SourceGen["Cocoar.SignalARRR.SourceGenerator"] DynProxy["Cocoar.SignalARRR.DynamicProxy"] npm["@cocoar/signalarrr"] signalr["@microsoft/signalr"] Contracts --> ProxyGen Server --> Common Client --> Common Client --> ProxyGen Server --> ProxyGen Server --> SourceGen Client --> SourceGen DynProxy --> ProxyGen npm --> signalr ``` The source generator comes with `Server` and `Client` as a package dependency, so a project that references both (an ASP.NET integration test project, for example) still runs it once. A contract project that references only `Contracts` gets no generator. ## Next steps * [Getting Started](/guide/getting-started) — install and set up * [API Overview](/reference/api) — public API surface * [Proxy Generation](/guide/advanced/proxy-generation) — how the source generator works --- --- url: /guide/advanced/proxy-generation.md description: >- The Roslyn source generator behind typed proxies: setup, [SignalARRRContract], generated code and naming, return type classification, multi-assembly support, the DynamicProxy fallback, the ProxyCreator API --- # Proxy Generation SignalARRR uses a Roslyn source generator to produce typed proxy classes at compile time. This enables zero-reflection RPC calls and is AOT-compatible. ## How it works 1. Mark an interface with `[SignalARRRContract]` 2. The source generator finds it during build 3. A proxy class is generated that implements the interface 4. A module initializer registers the proxy in `ProxyCreator` 5. `GetTypedMethods()` returns the generated proxy ## Setup Reference `Cocoar.SignalARRR.Contracts` in your shared interface project: ```xml ``` This package brings: * The `[SignalARRRContract]` attribute * The `ProxyCreator` and `ProxyCreatorHelper` base classes The Roslyn source generator is not part of it. It ships as `Cocoar.SignalARRR.SourceGenerator`, a dependency of `Cocoar.SignalARRR.Server` and `Cocoar.SignalARRR.Client`, so it runs in your server and client projects, where the proxies are used. The shared interface project contains only its interfaces. A project that references both Server and Client still runs the generator once, because NuGet resolves the shared package once. ## Mark interfaces ```csharp [SignalARRRContract] public interface IChatHub { Task SendMessage(string user, string message); Task> GetHistory(); IAsyncEnumerable StreamMessages(CancellationToken ct); } ``` ## Generated code For `IChatHub`, the generator produces: **Proxy class** (`IChatHub.SignalARRRProxy.g.cs`): ```csharp internal sealed class ChatHubProxy : IChatHub { private readonly ProxyCreatorHelper _helper; private const string Prefix = "MyNamespace.IChatHub"; public ChatHubProxy(ProxyCreatorHelper helper) => _helper = helper; public Task SendMessage(string user, string message) => _helper.SendAsync(Prefix + "|SendMessage", new object[] { user, message }, Array.Empty()); public Task> GetHistory() => _helper.InvokeAsync>(Prefix + "|GetHistory", Array.Empty(), Array.Empty()); public IAsyncEnumerable StreamMessages(CancellationToken ct) => _helper.StreamAsync(Prefix + "|StreamMessages", Array.Empty(), Array.Empty()); } ``` **Registration** (`SignalARRRProxyRegistration.g.cs`): ```csharp internal static class SignalARRRProxyRegistration { [ModuleInitializer] internal static void Initialize() { ProxyCreator.RegisterFactory( helper => new ChatHubProxy(helper)); } } ``` The module initializer runs when the assembly loads, making the proxy available immediately. ## Proxy naming The generator strips the leading `I` from interface names: | Interface | Proxy class | |-----------|-------------| | `IChatHub` | `ChatHubProxy` | | `IAdminService` | `AdminServiceProxy` | | `IMyContract` | `MyContractProxy` | ## Return type classification The generator classifies return types to determine the correct proxy method: | Return type | Proxy call | Protocol | |-------------|------------|----------| | `void` | `Send()` | `SendMessage` | | `Task` | `SendAsync()` | `SendMessage` | | `T` (sync) | `Invoke()` | `InvokeMessageResult` | | `Task` | `InvokeAsync()` | `InvokeMessageResult` | | `IAsyncEnumerable` | `StreamAsync()` | `StreamMessage` | | `IObservable` | `StreamAsync()` → `ToObservable()` | `StreamMessage` | | `ChannelReader` | `StreamAsync()` → `ToChannelReader()` | `StreamMessage` | ## Multi-assembly support The generator covers `[SignalARRRContract]` interfaces declared in the project itself and in every assembly it references at compile time. A server or client project that references a shared contracts library generates the proxies for that library's interfaces and registers them in its own module initializer: ``` SharedContracts.dll → IChatHub, IAdminHub (interfaces only) MyServer.dll → ChatHubProxy, AdminHubProxy (generated and registered here) ``` Interfaces that are only known at runtime, for example from plugins loaded later, are not covered; for those, use the DynamicProxy fallback below. ## DynamicProxy fallback For scenarios where compile-time generation isn't possible (e.g., plugin systems loading interfaces at runtime), add the `Cocoar.SignalARRR.DynamicProxy` package: ```xml ``` This registers a fallback factory in `ProxyCreator` that uses `DispatchProxy` for runtime proxy creation. ::: warning `DynamicProxy` requires `System.Reflection.Emit` and is **not AOT-compatible**. Use the source generator for AOT scenarios. ::: ## ProxyCreator API | Method | Description | |--------|-------------| | `RegisterFactory(factory)` | Register a compiled proxy factory | | `RegisterFallbackFactory(factory)` | Register a runtime fallback (e.g., DispatchProxy) | | `HasFactory()` | Check if a proxy factory exists for `T` | | `CreateInstanceFromInterface(helper)` | Create a proxy instance | ## Next steps * [Typed Methods](/guide/dotnet-client/typed-methods) — use generated proxies on the client * [Getting Started](/guide/getting-started) — full setup walkthrough * [Packages](/reference/packages) — which packages to reference --- --- url: /guide/typescript-client/server-methods.md description: >- Handle server-to-client calls in TypeScript with onServerMethod(): wire names, return values, throwing and async handlers, chaining, and cancellation via AbortSignal --- # Server Method Handlers The server can call methods on the TypeScript client. Use `onServerMethod()` to register handlers that respond to these calls. ## The name you register is the name on the wire ::: danger Register the full contract name, not the method name The name is matched **exactly**, and a handler that does not match is not an error — the call is dropped and nothing is logged. This is the single most common way for a TypeScript client to "silently do nothing". ::: When the server calls a client through a typed contract — `clientContext.GetTypedMethods().ReceiveMessage(...)` — the name that arrives is `interface|method`, not the bare method name: ```ts // Correct: the contract's wire name connection.onServerMethod('MyApp.Contracts.IChatClient|ReceiveMessage', (user: string, message: string) => { console.log(`${user}: ${message}`); }); // Wrong: never fires for a contract call, and fails silently connection.onServerMethod('ReceiveMessage', () => { /* … */ }); ``` By default the interface part is the C# interface's full name and the method part is the C# method name. Since 5.0 both can be declared explicitly on the contract, which is what you want as soon as a TypeScript or Swift client exists — see [Contract wire names](/guide/server/contracts-wire-names): ```csharp [SignalARRRContract] [MessageName("chat.client")] public interface IChatClient { [MessageName("received")] void ReceiveMessage(string user, string message); } ``` ```ts connection.onServerMethod('chat.client|received', (user: string, message: string) => { /* … */ }); ``` A bare name without `|` only matches a call the hub made with plain SignalR (`Clients.Client(id).SendAsync("Ping", …)`), not a contract call. ## Return values If the server expects a return value (`InvokeServerRequest`), return it from the handler: ```ts connection.onServerMethod('MyApp.Contracts.IChatClient|GetClientName', () => { return navigator.userAgent; }); connection.onServerMethod('MyApp.Contracts.IChatClient|GetClientTime', () => { return new Date().toISOString(); }); ``` The return value is sent back to the server automatically via SignalR's native client results feature. ## When a handler throws Whether the server finds out depends on how the server declared the member: * The server awaited a result (`InvokeServerRequest`) — the error travels back and surfaces at the caller as a `HubException`. * The server sent fire-and-forget (`InvokeServerMessage`) — the error is written to `console.error` as `[SignalARRR] Failed to handle server message ''` and goes no further. The server's send already completed; there is no caller left to tell. So for a browser client, a consistently failing push leaves no trace anywhere the operator can see. If that matters, ask the server side to give the contract member a return value. ## Async handlers Handlers can be async: ```ts connection.onServerMethod('MyApp.Contracts.IChatClient|FetchData', async (url: string) => { const response = await fetch(url); return await response.json(); }); ``` ## Chaining `onServerMethod()` returns `this`, so you can chain multiple registrations: ```ts const connection = HARRRConnection.create(builder => { builder.withUrl('https://localhost:5001/apphub'); }); connection .onServerMethod('chat.client|received', (user, msg) => console.log(`${user}: ${msg}`)) .onServerMethod('chat.client|name', () => navigator.userAgent) .onServerMethod('chat.client|ping', () => 'pong'); await connection.start(); ``` ## Cancellation support When the server passes a `CancellationToken` to a client method, SignalARRR converts it to an `AbortSignal` in the TypeScript handler: ```ts connection.onServerMethod('MyApp.Contracts.IWorkerClient|LongRunningTask', async (data: string, signal: AbortSignal) => { for (let i = 0; i < 100; i++) { if (signal.aborted) { throw new Error('Operation cancelled'); } await processChunk(data, i); } return 'done'; }); ``` The server can cancel the operation by calling `CancelTokenFromServer`. See [Cancellation Propagation](/guide/advanced/cancellation) for details. ## How it works The client registers handlers for four internal SignalR methods: | Internal Method | Behavior | |----------------|----------| | `InvokeServerRequest` | Calls handler, returns result via native SignalR client results | | `InvokeServerMessage` | Calls handler (fire-and-forget, no reply) | | `ChallengeAuthentication` | Automatic — calls token factory, sends token back | | `CancelTokenFromServer` | Triggers `AbortController.abort()` for the matching cancellation ID | Responses are transported automatically by SignalR's native client results feature -- no separate reply message is needed. ## Next steps * [Cancellation Propagation](/guide/advanced/cancellation) — server-initiated cancellation with AbortSignal * [Setup & Usage](/guide/typescript-client/setup) — TypeScript client basics * [Server Methods](/guide/server/server-methods) — how the server calls client methods --- --- url: /guide/server/server-methods.md description: >- Organize hub logic in ServerMethods classes: auto-discovery, several classes per hub, why every declared interface is a public RPC surface, method naming, DI and [FromServices], server-to-client calls, custom names --- # Server Methods `ServerMethods` classes let you organize hub logic into separate, focused classes instead of putting everything into a single hub. They are auto-discovered, support full dependency injection, and implement shared contract interfaces. ## Basic usage Create a class that extends `ServerMethods` where `T` is your hub type. Implement a shared contract interface: ```csharp [SignalARRRContract] public interface IChatHub { Task SendMessage(string user, string message); Task> GetHistory(); } public class ChatMethods : ServerMethods, IChatHub { public Task SendMessage(string user, string message) { var client = ClientContext.GetTypedMethods(); client.ReceiveMessage(user, message); return Task.CompletedTask; } public Task> GetHistory() => Task.FromResult(new List { "Hello", "World" }); } ``` No registration needed — `AddSignalARRR()` discovers all `ServerMethods` classes in the scanned assemblies. ## Multiple classes per hub Split your hub into domain-specific classes: ```csharp public class ChatMethods : ServerMethods, IChatHub { ... } public class UserMethods : ServerMethods, IUserHub { ... } public class AdminMethods : ServerMethods, IAdminHub { ... } ``` All three classes serve methods on the same `AppHub` endpoint. ## Every interface you declare is a public surface An interface on a `ServerMethods` class or on a hub is not just a shape — it is registered, and every member on it becomes callable from any client that can reach the hub: ```csharp // IOrderRepository was meant for DI and testing. It is now RPC. public class OrderMethods : ServerMethods, IOrderRepository { public Task DeleteAll() { ... } // any client: invoke("MyApp.IOrderRepository|DeleteAll") } ``` Registration does not distinguish an interface you intended as a contract from one you implement for other reasons. `[SignalARRRContract]` does not narrow this — it tells the source generator to emit a proxy and nothing else; interfaces without it are registered just the same, which is what lets the DynamicProxy, .NET Framework, TypeScript and Swift clients work without it. Authorization still applies: whatever `[Authorize]` guards the hub or the implementing class guards these members too. But if the hub is reachable, so are they. ::: tip Keep non-contract interfaces off the class Implement them on a separate collaborator the `ServerMethods` class holds, rather than on the class itself: ```csharp public class OrderMethods : ServerMethods, IOrderContract { private readonly IOrderRepository _orders; // injected, not implemented public OrderMethods(IOrderRepository orders) => _orders = orders; } ``` ::: ## Method naming Clients call methods using the pattern `ClassName.MethodName`: ```csharp // .NET client var chat = connection.GetTypedMethods(); // uses interface name mapping await chat.SendMessage("Alice", "Hello!"); ``` ```ts // TypeScript client await connection.invoke('ChatMethods.SendMessage', 'Alice', 'Hello!'); ``` When using typed proxies on the .NET client, the naming is handled automatically through the interface mapping. ## Available properties Every `ServerMethods` class has these properties auto-injected at invocation time: | Property | Type | Description | |----------|------|-------------| | `ClientContext` | `ClientContext` | Current client's context (ID, user, attributes) | | `Context` | `HubCallerContext` | Standard SignalR caller context | | `Clients` | `IHubCallerClients` | Send messages to other clients | | `Groups` | `IGroupManager` | Add/remove clients from groups | | `Logger` | `ILogger` | Logger instance | ## Dependency injection ServerMethods classes are resolved from DI as transient services. Inject dependencies through the constructor: ```csharp public class ChatMethods : ServerMethods, IChatHub { private readonly IChatRepository _repo; private readonly ILogger _logger; public ChatMethods(IChatRepository repo, ILogger logger) { _repo = repo; _logger = logger; } public async Task> GetHistory() { _logger.LogInformation("Client {Id} requested history", ClientContext.Id); return await _repo.GetRecentMessages(); } } ``` ## Parameter injection with \[FromServices] Individual method parameters can be injected from DI using `[FromServices]`: ```csharp public async Task ProcessData( string input, [FromServices] IDataProcessor processor) { await processor.Process(input); } ``` ## Server-to-client calls Inside a `ServerMethods` class, use `ClientContext.GetTypedMethods()` to call back the current client: ```csharp public async Task Greet() { var client = ClientContext.GetTypedMethods(); string name = await client.GetClientName(); // awaits client response return $"Hello, {name}!"; } ``` To call other clients, inject `ClientManager` and use the typed extension methods: ```csharp public class ChatMethods : ServerMethods, IChatHub { private readonly ClientManager _clients; public ChatMethods(ClientManager clients) => _clients = clients; public void BroadcastMessage(string message) { // GetTypedMethodsForHub returns (ClientContext, T) tuples foreach (var (ctx, methods) in _clients.GetTypedMethodsForHub()) { methods.ReceiveMessage("System", message); } } public async Task AskClient(string connectionId) { // GetTypedMethods extension combines GetClientById + GetTypedMethods var methods = _clients.GetTypedMethods(connectionId); return await methods.GetClientName(); } } ``` See [Client Manager](/guide/server/client-manager) for more details. ## Custom method names Use `[MessageName]` to override the default method name: ```csharp [MessageName("CustomName")] public Task MyMethod() { ... } ``` Clients call this as `ClassName.CustomName` instead of `ClassName.MyMethod`. ## Next steps * [Authorization](/guide/server/authorization) — protect methods with `[Authorize]` * [Client Manager](/guide/server/client-manager) — call clients from controllers and services * [Streaming](/guide/streaming/server-to-client) — return streams from server methods --- --- url: /guide/dotnet-client/server-to-client.md description: >- Handle server-to-client calls on the .NET client: define the contract, implement it, RegisterInterface before connecting, return values, why On() is for raw SignalR only, what happens when a handler throws --- # Server-to-Client Handlers The server can call methods on the client and optionally await a response. Register handlers on the client to respond to these calls. ## Typed handlers via interfaces The cleanest approach: define a shared contract interface, implement it on the client, and register it. ### 1. Define the contract (shared library) ```csharp [SignalARRRContract] public interface IChatClient { void ReceiveMessage(string user, string message); Task GetClientName(); } ``` ### 2. Implement on the client ```csharp public class ChatClientHandler : IChatClient { public void ReceiveMessage(string user, string message) { Console.WriteLine($"{user}: {message}"); } public Task GetClientName() { return Task.FromResult(Environment.MachineName); } } ``` ### 3. Register before connecting ```csharp var connection = HARRRConnection.Create(builder => { ... }); // Register with an instance connection.RegisterInterface(new ChatClientHandler()); // Or let SignalARRR create the instance (parameterless constructor) connection.RegisterInterface(); // Or with a factory (for dependency injection) connection.RegisterInterface(sp => new ChatClientHandler(sp.GetRequiredService())); await connection.StartAsync(); ``` ### 4. Server calls the client ```csharp // In a ServerMethods class or anywhere with access to ClientContext var client = clientContext.GetTypedMethods(); client.ReceiveMessage("System", "Welcome!"); // fire-and-forget var name = await client.GetClientName(); // awaits response ``` ::: warning Register before StartAsync `RegisterInterface` must be called **before** `StartAsync()`. The server may invoke client methods immediately after connection. ::: ::: danger Removed in 5.0: `OnServerRequest` Earlier versions documented `connection.OnServerRequest("MethodName", handler)` for registering handlers by bare method name. It never worked: the handlers went into a registry that nothing read, so a server call either failed with `Method 'X' not found!` or, on the fire-and-forget path, was swallowed into a client-side log line and did nothing at all. It is removed from both .NET clients in 5.0. There is no ad-hoc replacement — `RegisterInterface` with a contract interface, as shown above, is the way to receive server-to-client calls, and always was. ::: ## `On()` is for raw SignalR calls, not contracts `HARRRConnectionExtensions` provides `On()` overloads for up to 4 parameters. These register against **raw SignalR method names** — they pass straight through to `HubConnection.On`: ```csharp // Fires for Clients.Client(id).SendAsync("StatusChanged", "online") // — i.e. the plain SignalR API on the hub, bypassing SignalARRR connection.On("StatusChanged", status => { Console.WriteLine(status); }); ``` ::: warning `On()` does not receive contract calls A call made through a typed proxy — `clientContext.GetTypedMethods().ReceiveMessage(...)` — does **not** arrive as a SignalR message named `ReceiveMessage`. It arrives wrapped in an `InvokeServerRequest` or `InvokeServerMessage` envelope, with the contract name (`MyApp.IChatClient|ReceiveMessage`) *inside* the envelope. So `connection.On("ReceiveMessage", ...)` never fires for it, and does so silently. Use `RegisterInterface` for anything the server sends through a contract, and `On()` only for messages the hub sends with plain `SendAsync`. ::: ## What happens when a handler throws **The return type decides whether the server finds out.** This is the one thing to know before choosing a signature for a contract member. | Contract member | Sent as | A throwing handler | |---|---|---| | `Task`, `T` | `InvokeServerRequest` | reaches the server as a `HubException` carrying the client's message | | `void`, `Task` | `InvokeServerMessage` | is logged on the client and **nowhere else** | For the fire-and-forget case this is not a gap that could be closed by propagating harder: the server's `SendAsync` completes as soon as the message reaches the transport, long before the client runs the method. By the time the handler fails there is no caller left to tell. ::: warning A failing push is invisible to the server All three clients log the failure — .NET through `ILogger`, TypeScript to `console.error`, Swift through its logger — and all three use the same wording, `Failed to handle server message ''`. That is the only record. For a browser or mobile client that means the evidence sits on someone else's machine. **If the server needs to know that the call succeeded, give the contract member a return value.** Even `Task` turns a silent failure into a `HubException` at the call site. ::: ```csharp [SignalARRRContract] public interface IChatClient { // Fire-and-forget: fast, and the server cannot tell whether it worked void ReceiveMessage(string user, string message); // Awaited: a throwing handler surfaces at the caller Task ApplySettings(Settings settings); } ``` The same split applies to broadcasts, with one addition: `SendAsync` discards return values even for members that declare one, because there is no single caller to return them to. Use `InvokeAllAsync` when you need results — or errors — from a set of clients. ## How it works When the server calls a client method: 1. Server sends `InvokeServerRequest` (expects reply) or `InvokeServerMessage` (fire-and-forget) 2. Client receives the message and dispatches to the registered handler 3. If `InvokeServerRequest`, the handler's return value is sent back automatically via SignalR's native client results (no separate reply message needed) The `ChallengeAuthentication` message is handled automatically — the client's token factory is called and the token is sent back without developer intervention. ## Next steps * [Server Methods](/guide/server/server-methods) — how the server calls client methods * [Cancellation Propagation](/guide/advanced/cancellation) — server-initiated cancellation * [TypeScript Client](/guide/typescript-client/setup) — same pattern in TypeScript --- --- url: /guide/streaming/server-to-client.md description: >- Stream results from server methods with IAsyncEnumerable (recommended), IObservable or ChannelReader, consume them on the .NET and TypeScript clients, and cancel a stream --- # Server-to-Client Streaming SignalARRR supports streaming results from server methods to clients using `IAsyncEnumerable`, `IObservable`, or `ChannelReader`. ## IAsyncEnumerable (recommended) The simplest streaming pattern — use `yield return` in an async iterator: ```csharp [SignalARRRContract] public interface IDataHub { IAsyncEnumerable StreamMessages(CancellationToken ct); IAsyncEnumerable StreamPrices(string symbol, CancellationToken ct); } ``` ```csharp public class DataMethods : ServerMethods, IDataHub { public async IAsyncEnumerable StreamMessages( [EnumeratorCancellation] CancellationToken ct) { var i = 0; while (!ct.IsCancellationRequested) { yield return $"Message {i++}"; await Task.Delay(1000, ct); } } public async IAsyncEnumerable StreamPrices( string symbol, [EnumeratorCancellation] CancellationToken ct) { while (!ct.IsCancellationRequested) { yield return await _stockService.GetPrice(symbol); await Task.Delay(500, ct); } } } ``` ## Consume on the .NET client ```csharp var data = connection.GetTypedMethods(); await foreach (var msg in data.StreamMessages(cancellationToken)) { Console.WriteLine(msg); } ``` ## Consume in TypeScript ```ts connection.stream('DataMethods.StreamMessages').subscribe({ next: msg => console.log(msg), error: err => console.error('Stream error:', err), complete: () => console.log('Stream ended'), }); ``` ## IObservable Use Rx.NET observables for reactive streaming: ```csharp public IObservable ObservePrices(string symbol) { return Observable.Create(async (observer, ct) => { while (!ct.IsCancellationRequested) { var price = await _stockService.GetPrice(symbol); observer.OnNext(price); await Task.Delay(500, ct); } }); } ``` ## ChannelReader Use channels for producer/consumer patterns: ```csharp public ChannelReader StreamLogs() { var channel = Channel.CreateUnbounded(); _ = Task.Run(async () => { await foreach (var entry in _logService.WatchLogs()) { await channel.Writer.WriteAsync(entry); } channel.Writer.Complete(); }); return channel.Reader; } ``` ## Cancellation All streaming patterns support cancellation. The client disconnecting or explicitly cancelling the stream triggers the `CancellationToken`: ```csharp // .NET client — cancel after 10 seconds using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await foreach (var msg in data.StreamMessages(cts.Token)) { Console.WriteLine(msg); } ``` ```ts // TypeScript — cancel by disposing the subscription const sub = connection.stream('DataMethods.StreamMessages').subscribe({ next: msg => console.log(msg), }); // Later... sub.dispose(); ``` ## Next steps * [Client-to-Server Streaming](/guide/streaming/client-to-server) — stream data to the server * [Cancellation Propagation](/guide/advanced/cancellation) — remote cancellation * [Typed Methods](/guide/dotnet-client/typed-methods) — how return types map to protocol --- --- url: /guide/comparison.md description: >- SignalARRR against gRPC and REST: when each fits, and where SignalARRR has the edge — bidirectional RPC, shared interfaces instead of .proto files, push with a response, file transfer, auth on persistent connections --- # SignalARRR vs. gRPC vs. REST Choosing a communication framework is one of the first architectural decisions in a distributed system. This page compares SignalARRR with the two most common alternatives — gRPC and REST APIs — and explains when each shines and where SignalARRR has an edge. ## The short version | | SignalARRR | gRPC | REST (HTTP APIs) | |---|---|---|---| | **Transport** | WebSockets (+ SSE, Long Polling fallback) | HTTP/2 (end-to-end required) | HTTP/1.1 or HTTP/2 | | **Direction** | Full duplex — server and client call each other | Client → Server (+ server streaming) | Client → Server only | | **Real-time push** | Built-in | Server streaming only | Polling or separate WebSocket | | **Proxy/Firewall** | Works through any proxy (auto-fallback) | HTTP/2 E2E — breaks on many corporate proxies | Works everywhere | | **Browser support** | Full (WebSocket native) | Requires grpc-web proxy | Full | | **Type safety** | Shared interfaces + source generator | .proto files + codegen | OpenAPI/Swagger (optional) | | **Streaming** | Bidirectional item streaming + HTTP file transfer | Bidirectional streaming | Chunked transfer (manual) | | **Connection** | Persistent | Per-call or persistent (HTTP/2) | Per-call | | **Auth mid-connection** | Token challenge/refresh built-in | Per-call metadata | Per-call headers | | **Mobile (iOS/Swift)** | Native client with `@HubProxy` macro | grpc-swift | URLSession | | **JavaScript/TypeScript** | `@cocoar/signalarrr` npm package | grpc-web (limited) | fetch/axios | | **.NET Framework** | Full client (netstandard2.0) | Deprecated `Grpc.Core` only | HttpClient | | **.NET support** | net8.0 / net9.0 / net10.0 + .NET Framework 4.6.2+ | .NET Core 3.0+ only (modern client) | Any | ## When to use what ### Use REST when... * You have a simple request/response API with no real-time requirements * Your consumers are third parties who expect a standard HTTP API * You need maximum cacheability (CDN, HTTP caches) * You're building a public API that must be accessible from any language without client libraries REST is the right choice when you don't need real-time communication and want the broadest possible compatibility. ### Use gRPC when... * You have high-throughput service-to-service communication (microservices backend) * Both ends are server-side (no browser, no mobile) * You need strict contract-first development with `.proto` files * Maximum serialization performance matters more than developer ergonomics gRPC excels at backend-to-backend communication where both sides are under your control and you can use HTTP/2 natively. ### Use SignalARRR when... * Server and client need to **call each other** — not just request/response * You need **real-time push** from server to client (notifications, live updates, dashboards) * Your clients include **browsers**, **mobile apps**, and/or **.NET desktop apps** * You want **typed bidirectional RPC** without maintaining separate IDL files * You need **file transfer** alongside RPC (HTTP stream references) * You're building **enterprise line-of-business applications** with mixed client platforms * You need **authorization with token refresh** on long-lived connections ## Why SignalARRR is the better choice for most enterprise apps ### 1. True bidirectional RPC gRPC's "bidirectional streaming" lets both sides send messages on a stream — but it's not typed RPC. You define `stream` methods with a single message type, and both sides must interpret that stream manually. There's no concept of "server calls client method X and gets a typed response." SignalARRR gives you actual **typed method invocation in both directions**: ```csharp // Server calls client — typed, with return value var client = ClientContext.GetTypedMethods(); DeviceStatus status = await client.GetStatus(); // Client calls server — same shared interface var hub = connection.GetTypedMethods(); await hub.ReportMetrics(metrics); ``` With gRPC, you'd need to implement your own correlation layer on top of bidirectional streaming to achieve this. SignalARRR does it out of the box. ### 2. Works through any network infrastructure gRPC requires **HTTP/2 end-to-end**. That sounds simple, but in enterprise environments it's often a showstopper: * **Corporate proxies** frequently downgrade or block HTTP/2 * **Reverse proxies** (older nginx, HAProxy, IIS ARR) may not support HTTP/2 upstream or break bidirectional streaming * **Azure Application Gateway Classic**, many WAFs, and CDNs don't pass HTTP/2 streams through correctly * **grpc-web** is the workaround for browsers, but it requires a **separate proxy** (Envoy or ASP.NET Core middleware) that translates between HTTP/1.1 and HTTP/2 — an extra deployment, failure point, and security surface In practice, getting gRPC to work reliably through a typical enterprise network stack (client → corporate proxy → load balancer → WAF → reverse proxy → server) is a significant DevOps challenge. SignalARRR uses **WebSockets** as primary transport — supported by virtually every modern proxy, load balancer, and firewall. And if even WebSockets are blocked (rare, but it happens in locked-down environments), SignalR automatically falls back to **Server-Sent Events** or **Long Polling** — which work through literally any HTTP infrastructure: ``` WebSocket → blocked? → SSE → blocked? → Long Polling (best) (good) (always works) ``` No configuration, no proxy setup, no DevOps tickets. It just works. ### 3. Shared interfaces instead of .proto files gRPC requires you to maintain `.proto` files and run a separate code generation step. The generated code is verbose and framework-specific. SignalARRR uses **plain C# interfaces** as the contract. The Roslyn source generator produces proxies at build time — no separate tooling, no generated files to commit, no build pipeline step: ```csharp [SignalARRRContract] public interface IOrderHub { Task GetOrder(Guid id); Task PlaceOrder(OrderRequest request); IAsyncEnumerable StreamUpdates(CancellationToken ct); } ``` This interface is shared between server and all .NET clients. TypeScript and Swift clients use the same method names and types. ### 4. Server can push — and the client can respond REST has no push mechanism. gRPC has server streaming, but the client can't send responses back on that stream. SignalARRR supports **server-to-client RPC with return values** — the server calls a method on the client and awaits the response: ```csharp // From a background service, controller, or anywhere with ClientManager var client = clientManager.GetTypedMethods(connectionId); bool accepted = await client.ConfirmUpdate(updatePackage); if (accepted) { await client.InstallUpdate(updatePackage); } ``` This is a game-changer for enterprise scenarios: configuration pushes, remote management, client-driven workflows. ### 5. File transfer built into RPC With gRPC, file transfer means chunking bytes into stream messages and reassembling on the other side. With REST, you build upload/download endpoints. SignalARRR routes `System.IO.Stream` parameters through HTTP automatically — the RPC call looks normal: ```csharp // Server method accepts a file as a parameter public async Task ImportData(string format, Stream data) { // data is a regular Stream — read it normally } // Client calls it — SignalARRR handles the HTTP upload transparently await hub.ImportData("csv", fileStream); ``` ### 6. Enterprise-ready auth on persistent connections REST sends credentials per request. gRPC sends metadata per call. Both are simple. But persistent connections (WebSockets) are different — the initial token can expire while the connection is still alive. SignalARRR solves this with an **automatic challenge/refresh flow**: 1. Client connects with a token 2. Token expires after 30 minutes 3. Next server call detects expiry → challenges client for fresh token 4. Client provides new token → call continues 5. No disconnection, no retry, no manual refresh logic ### 7. .NET Framework support — bridge legacy and modern gRPC's modern .NET client (`Grpc.Net.Client`) requires **.NET Core 3.0+**. The old `Grpc.Core` package supports .NET Framework but is **deprecated since 2023** and no longer maintained. If you have .NET Framework 4.8 clients, gRPC is effectively a dead end. SignalARRR provides a **dedicated .NET Framework client** (`Cocoar.SignalARRR.Client.FullFramework`) targeting netstandard2.0 with near-full feature parity: typed proxies, streaming, server-to-client RPC, cancellation, file transfer, and MessagePack. This enables a pattern that's invaluable in enterprise environments: a **.NET 10 server** with modern frontend technology, connected to **legacy .NET Framework 4.8 clients** that load platform-specific DLLs (SCCM, SCSM, COM interop, legacy SDKs) — all communicating through the same typed RPC interface. Neither gRPC nor REST can offer this: typed bidirectional RPC between modern and legacy .NET runtimes. ### 8. Four client platforms with feature parity | | .NET | .NET Framework | TypeScript | Swift | |---|---|---|---|---| | Typed proxies | Source generator | DispatchProxy | Method name strings | `@HubProxy` macro | | Streaming | `IAsyncEnumerable` | `IAsyncEnumerable` (polyfill) | `subscribe()` | `AsyncThrowingStream` | | Server-to-client RPC | Full typed | Full typed | `onServerMethod()` | `onServerMethod()` | | File transfer | `Stream` parameters | `Stream` parameters | HTTP stream refs | HTTP stream refs | | MessagePack | Optional | Optional | Optional | Built-in (native impl) | | Cancellation | `CancellationToken` | `CancellationToken` | `AbortSignal` | Task cancellation | ## When gRPC still wins Be honest about trade-offs: * **Raw throughput**: gRPC with Protobuf is faster for high-volume service-to-service communication (binary serialization, HTTP/2 multiplexing). For 10,000 req/s between backend services, gRPC is the right tool. * **Language-agnostic contracts**: `.proto` files generate clients in 10+ languages. SignalARRR's typed experience is strongest in .NET, TypeScript, and Swift. * **Ecosystem maturity**: gRPC has wider adoption in the microservices/cloud-native space, with more tooling for observability, load balancing, and service mesh integration. ## When REST still wins * **Simplicity**: For a simple CRUD API, REST is less machinery. * **Cacheability**: HTTP caching, CDNs, and reverse proxies work out of the box. * **Third-party consumers**: External developers expect REST, not WebSockets. ## The verdict For **enterprise line-of-business applications** where you control both server and clients, need real-time push, bidirectional communication, and want type safety across platforms — **SignalARRR is the strongest choice**. It gives you the developer experience of a typed RPC framework with the real-time capabilities that gRPC and REST simply don't have. For backend-to-backend microservices at scale, use gRPC. For public APIs, use REST. For everything in between — especially when your UI needs live data and your server needs to talk back — use SignalARRR. ## Next steps * [Getting Started](/guide/getting-started) — set up your first SignalARRR hub * [Why SignalARRR?](/guide/why-signalarrr) — detailed comparison with raw SignalR * [Client Comparison](/reference/client-comparison) — feature matrix across .NET, TypeScript, Swift --- --- url: /guide/swift-client/setup.md description: >- The CocoarSignalARRR Swift package for iOS, macOS, tvOS and watchOS: installation, connection and options, authentication, invoke / send / stream, events, MessagePack, reconnection policy, transports, logging --- # Swift Client Setup The `CocoarSignalARRR` Swift package provides a native client for iOS, macOS, tvOS, and watchOS. It is a full SignalR client built from scratch — no dependency on Microsoft's `signalr-client-swift`. ::: info Requirements Swift 5.10+, iOS 14+ / macOS 11+ / tvOS 14+ / watchOS 7+. No external dependencies beyond Swift standard library and Foundation. ::: ## Installation Add the package to your `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/cocoar-dev/Cocoar.SignalARRR.git", from: "5.0.0"), ], targets: [ .target( name: "MyApp", dependencies: [ .product(name: "CocoarSignalARRR", package: "Cocoar.SignalARRR"), .product(name: "CocoarSignalARRRMacros", package: "Cocoar.SignalARRR"), ] ), ] ``` ## Create a connection ```swift import CocoarSignalARRR let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub" ) ``` ## Authentication A connection has two credentials. Usually they are the same token, so one option sets both: ```swift let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", options: HARRRConnectionOptions(credential: { await tokenStore.currentToken() }) ) ``` To give them different credentials — a single-use connection ticket, say, which has no business being resent with every message — set them separately: ```swift let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", options: HARRRConnectionOptions( connectionCredential: { await connectionTicket() }, messageCredential: { await tokenStore.currentToken() } ) ) ``` | Option | Authenticates | Travels as | Checked by | When it expires | |---|---|---|---|---| | `connectionCredential` | negotiate and the transport | `Authorization` header | `[Authorize]` on the hub class, `.RequireAuthorization()` on the mapping | fetched again on every connect and reconnect | | `messageCredential` | every message, the answer to a challenge, file transfers | the `Authorization` field of the message; a header on file transfers | `[Authorize]` on a method or a `ServerMethods` class | fetched on every use, so a refreshed token is sent from the next call on | | `credential` | both of the above | | | | The options are named the same in every SignalARRR client, and nothing is coupled implicitly: `messageCredential` alone leaves the connection anonymous, `connectionCredential` alone sends no message credential. A `connectionCredential` or `messageCredential` next to `credential` takes its part over. A value without a space is sent as a bearer token; one with a space carries its own scheme. See [Authorization](/guide/server/authorization#the-two-credentials) for the same table across all clients. The connection credential travels as a header on the negotiate request and on every request of the transport — the WebSocket upgrade, the SSE stream and its posts, every Long Polling request — never in a URL, where proxy logs and error reports would keep it. For a server that reads it only from the URL, set `transportCredential: .query`; see [where the connection token travels](/guide/server/authorization#where-the-connection-token-travels). Token challenges are handled automatically — while a stream is running the server may send a `ChallengeAuthentication` message, and the client calls the message credential to answer it. `HARRRConnection.create` does not throw, so a credential set in two places surfaces from `start()` as a `HARRRConfigurationError`: `accessTokenFactory` next to a credential in `options`, or a connection credential passed to `create(client:)`, whose `SignalRWebSocketClient` authenticates the connection with its own factory. ::: tip Former way `accessTokenFactory:` on `create(url:)` is the former way to set one factory for both credentials. It still works and keeps that behaviour; replace it with `options: HARRRConnectionOptions(credential: ...)`. ::: ## All options ```swift let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", hubProtocol: .json, // or .messagepack transportCredential: .header, // or .query: connection token in the transport URL headers: ["X-Api-Key": apiKey], // extra headers on negotiate and transport requests options: HARRRConnectionOptions(credential: { await getAuthToken() }), serverTimeout: 30, keepAliveInterval: 15, handshakeTimeout: 15, reconnectPolicy: .default, // immediate, 2s, 10s, 30s — then give up allowedTransports: [.webSockets, .serverSentEvents, .longPolling], logLevel: .info ) ``` ## Start and stop ```swift try await connection.start() // ... use the connection ... await connection.stop() ``` ## Invoke (call with return value) ```swift let history: [String] = try await connection.invoke("ChatMethods.GetHistory") let user: User = try await connection.invoke("UserMethods.GetUser", arguments: userId) ``` ## Send (fire-and-forget) ```swift try await connection.send("ChatMethods.SendMessage", arguments: "Alice", "Hello!") ``` ## Stream ```swift let stream: AsyncThrowingStream = try await connection.stream( "ChatMethods.StreamMessages" ) for try await msg in stream { print(msg) } ``` ## Errors A failed call throws; `parseHARRRError` turns the error into the server's structured error. Branch on `normalizedCode`, never on the message: ```swift do { let _: Bool = try await connection.invoke("RoomMethods.Join", arguments: roomId) } catch { let error = parseHARRRError(error) switch error.normalizedCode { case HARRRErrorCodes.unauthorized: promptLogin() case HARRRErrorCodes.methodNotFound: reportContractMismatch(error) default: if error.code == "room_full" { showRoomFull() } // application codes travel verbatim else { showGeneric(error.message) } } } ``` `normalizedCode` is the code folded to the set this client knows (unknown codes become `internal`); `code` is the raw wire value, which is where an application's own `HARRRException("room_full", ...)` codes appear. The codes are the same in every SignalARRR client. A connection the server rejects at negotiate — 401 or 403 for a missing or invalid connection credential — fails `start()` with `SignalRError.negotiationFailed`, whose `statusCode` carries the HTTP status: ```swift do { try await connection.start() } catch let error as SignalRError where error.statusCode == 401 { promptLogin() } ``` ## Connection events ```swift await connection.onClosed { error in print("Connection closed: \(error?.localizedDescription ?? "clean")") } await connection.onReconnecting { error in print("Reconnecting: \(error?.localizedDescription ?? "")") } await connection.onReconnected { print("Reconnected") } ``` ## Connection properties | Property | Type | Description | |----------|------|-------------| | `connectionId` | `String?` | Current connection ID | | `state` | `HubConnectionState` | Connection state (async) | | `serverTimeoutInterval` | `TimeInterval` | Server timeout | | `keepAliveIntervalValue` | `TimeInterval` | Keepalive interval | | `handshakeTimeoutValue` | `TimeInterval` | Handshake timeout | ## MessagePack protocol Use MessagePack instead of JSON for better performance and smaller payloads: ```swift let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", hubProtocol: .messagepack ) ``` No additional dependencies required — MessagePack is implemented natively in the client. ::: info Server setup The server needs `.AddMessagePackProtocol()`. See [MessagePack](/roadmap/messagepack) for details. ::: ## Reconnection policy ```swift // Default: immediate retry, then 2s, 10s, 30s — then give up let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", reconnectPolicy: .default ) // Custom delays let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", reconnectPolicy: ReconnectPolicy(retryDelays: [0, 1, 5, 10, 30]) ) // Disable reconnection let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", reconnectPolicy: .disabled ) ``` ## Transport selection The client tries transports in preference order and connects via the first one the server supports: ```swift // Default: WebSockets → SSE → Long Polling let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", allowedTransports: [.webSockets, .serverSentEvents, .longPolling] ) // Force WebSockets only let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", allowedTransports: [.webSockets] ) ``` ## Logging ```swift let connection = await HARRRConnection.create( url: "https://localhost:5001/apphub", logLevel: .debug // .debug, .info, .warning, .error, .none ) ``` Logs are emitted via `os_log` and visible in Xcode's console and macOS Console.app under the `com.cocoar.signalarrr` subsystem. ## Using SignalRWebSocketClient directly `HARRRConnection` is the SignalARRR-specific wrapper. If you want to connect to any standard SignalR hub (without the SignalARRR server library), use `SignalRWebSocketClient` directly: ```swift let client = SignalRWebSocketClient( url: "https://any-signalr-server.com/hub", hubProtocol: .messagepack ) try await client.start() let result: String = try await client.invoke(method: "MyMethod", arguments: ["param"]) client.on("OnMessage") { args in print(args) return nil } ``` ## Next steps * [Typed Proxies & Server Methods](/guide/swift-client/typed-proxies) — `@HubProxy` macro and server-to-client handlers * [Getting Started](/guide/getting-started) — full setup walkthrough * [Packages](/reference/packages) — all available packages --- --- url: /roadmap/test-coverage.md description: >- How the three client ecosystems are tested against one shared IntegrationTestServer on real Kestrel, and what each suite covers --- # Test Coverage All three client ecosystems (.NET, TypeScript, Swift) test against one shared `IntegrationTestServer` running real Kestrel. No mocking. ## Summary | Platform | Framework | Tests | |----------|-----------|------:| | .NET Unit (SourceGenerator) | xUnit | 3 | | .NET Unit (DynamicProxy) | xUnit | 12 | | .NET Integration (JSON) | xUnit | 50 | | .NET Integration (MessagePack) | xUnit | 5 | | TypeScript Integration (JSON) | vitest | 25 | | TypeScript Integration (MessagePack) | vitest | 5 | | Swift Unit | XCTest | 31 | | Swift Macro | XCTest | 6 | | Swift Integration | XCTest | 6 | ## What's Tested * **Client → Server:** invoke (sync/async, multiple return types), send (fire-and-forget), echo, streaming (ChannelReader, IAsyncEnumerable) * **Server → Client:** fire-and-forget, typed proxy calls, return values (string, list, guid), streaming (IAsyncEnumerable), cancellation * **Complex Types:** DateTime, Guid, List, Dictionary, multiple parameter types * **Multi-ServerMethods:** second class on same hub, `[MessageName]` attribute, hub method coexistence * **Authorization:** authenticated calls (sync/async), unauthenticated rejection, token challenge/refresh, `[AllowAnonymous]` override, second ServerMethods class with hub-level auth * **Error Handling:** structured error types (ArgumentException, InvalidOperationException), non-existent method * **File Transfer:** RequestUploadSlot, HTTP upload, automatic Stream argument preparation, Stream return values * **Advanced:** `[FromServices]` injection, ClientContext.Attributes from headers * **MessagePack:** invoke, send, echo, guid, multi-param — same scenarios as JSON * **Proxy Generation:** ModuleInitializer registration, all 7 return type categories, method name format, CancellationToken extraction, fallback factory ## Test Infrastructure * **IntegrationTestServer** — standalone .NET server, dynamic port, shared by all clients * **`scripts/test-server.sh`** — server lifecycle coordinator (acquire/release with ref counting) * **`scripts/run-integration-tests.sh`** — runs all available client tests in sequence * **.NET fixture** auto-starts server if no `SIGNALARRR_TEST_SERVER_URL` environment variable --- --- url: /guide/dotnet-client/typed-methods.md description: >- Call server methods through GetTypedMethods() proxies: return type mapping to invoke, send and stream, generic methods, proxy caching, and what the source generator needs --- # Typed Methods Call server methods through shared interfaces with full compile-time safety. The source generator produces proxy classes that handle serialization and method dispatch. ## Get a typed proxy Use `GetTypedMethods()` to get a proxy for a shared contract interface: ```csharp var connection = HARRRConnection.Create(builder => { builder.WithUrl("https://localhost:5001/apphub"); }); await connection.StartAsync(); var chat = connection.GetTypedMethods(); ``` ## Call methods The proxy maps interface method calls to the appropriate SignalARRR protocol message: ```csharp [SignalARRRContract] public interface IChatHub { Task SendMessage(string user, string message); // fire-and-forget Task> GetHistory(); // invoke with result IAsyncEnumerable StreamMessages(CancellationToken ct); // stream } ``` ```csharp // Fire-and-forget (sends InvokeMessage) await chat.SendMessage("Alice", "Hello!"); // Invoke with result (sends InvokeMessageResult, awaits response) List history = await chat.GetHistory(); // Stream (sends StreamMessage, returns IAsyncEnumerable) await foreach (var msg in chat.StreamMessages(cancellationToken)) { Console.WriteLine(msg); } ``` ## Return type mapping The proxy determines the protocol message based on the method's return type: | Return type | Protocol | Behavior | |-------------|----------|----------| | `void` | `SendMessage` | Fire-and-forget (blocking) | | `Task` | `SendMessage` | Fire-and-forget, awaitable | | `T` (sync) | `InvokeMessageResult` | Invoke, blocks for result | | `Task` | `InvokeMessageResult` | Invoke, await result | | `IAsyncEnumerable` | `StreamMessage` | Server-to-client stream | | `IObservable` | `StreamMessage` | Server-to-client stream (Rx) | | `ChannelReader` | `StreamMessage` | Server-to-client stream (channel) | ## Generic method support Proxies support generic method arguments. The type arguments are serialized as strings and resolved on the server: ```csharp [SignalARRRContract] public interface IDataHub { Task GetItem(string key); } ``` ```csharp var data = connection.GetTypedMethods(); var user = await data.GetItem("user-123"); ``` ## Proxy caching `GetTypedMethods()` caches proxy instances. Calling it multiple times with the same type returns the same proxy object. ## Source generator requirements For proxy generation to work, the shared interface project must reference `Cocoar.SignalARRR.Contracts`: ```xml ``` The `[SignalARRRContract]` attribute marks the interface for the Roslyn source generator, which comes with `Cocoar.SignalARRR.Client` and produces the proxy class at build time in your client project. See [Proxy Generation](/guide/advanced/proxy-generation) for details on how this works. ## Next steps * [Server-to-Client Handlers](/guide/dotnet-client/server-to-client) — handle callbacks from the server * [Streaming](/guide/streaming/server-to-client) — stream data from server methods * [Proxy Generation](/guide/advanced/proxy-generation) — how the source generator works --- --- url: /guide/kotlin-client/typed-proxies.md description: >- Kotlin typed proxies generated by KSP from @HubProxy interfaces (wire names, ProxyKind, @HubMethod), server-to-client handlers with onServerMethod and registerInterface, structured cancellation of handlers, streaming handlers, and HTTP stream references --- # Typed Proxies & Server Methods The Kotlin client generates typed proxies at build time with KSP, and answers server-to-client calls with handlers that run as ordinary coroutines. ## @HubProxy Mark an interface with `@HubProxy` and the `dev.cocoar:signalarrr-ksp` processor generates a `Proxy` class at build time: ```kotlin import dev.cocoar.signalarrr.HubProxy import kotlinx.coroutines.flow.Flow @HubProxy(name = "MyApp.Contracts.IChatHub") interface IChatHub { suspend fun sendMessage(user: String, message: String) suspend fun getHistory(): List fun streamMessages(): Flow } ``` The generated `IChatHubProxy` routes each member to the connection: | Member | Generated call | Protocol | |--------|----------------|----------| | `suspend fun` without result | `connection.send("MyApp.Contracts.IChatHub\|SendMessage", ...)` | `SendMessage` | | `suspend fun ...: T` | `connection.invoke("MyApp.Contracts.IChatHub\|GetHistory", ...)` | `InvokeMessageResult` | | `fun ...: Flow` | `connection.stream("MyApp.Contracts.IChatHub\|StreamMessages", ...)` | `StreamMessage` | ### Wire names A contract call travels as `Interface|Method` (see [Contract Wire Names](../server/contracts-wire-names)). The server matches both halves exactly, and it is case-sensitive: * **Interface half** — the `name` argument. Use the .NET full name of the contract (`MyApp.Contracts.IChatHub`), or the `[MessageName]` it declares. Without `name`, the simple Kotlin interface name is used, which only matches a contract in the global namespace. * **Method half** — the Kotlin function name with its first letter upper-cased, because .NET members are PascalCase: `getHistory` → `GetHistory`. Set `pascalCase = false` to send the name verbatim, or override one member with `@HubMethod("received")` where the contract declares `[MessageName("received")]`. ### Hub methods and ServerMethods classes Methods declared on the hub class or on a `ServerMethods` class are not addressed through an interface. `kind` selects the naming scheme: ```kotlin // ServerMethods class "ChatMethods" → "ChatMethods.SendMessage" @HubProxy(name = "ChatMethods", kind = ProxyKind.SERVER_METHODS) interface ChatMethods { suspend fun sendMessage(user: String, message: String) } // Methods on the hub class itself → bare "Echo" @HubProxy(kind = ProxyKind.HUB) interface AppHubMethods { suspend fun echo(text: String): String } ``` ### Generic methods A contract member with a type parameter needs the .NET type names of its type arguments on the wire. Declare them with `@GenericArguments`: ```kotlin @HubProxy(name = "MyApp.Contracts.IQueryHub") interface IQueryHub { @GenericArguments("MyApp.Models.Order") suspend fun load(id: String): Order } ``` ## Use typed proxies ```kotlin val chat = connection.getTypedMethods(IChatHubProxy) chat.sendMessage("Alice", "Hello!") val history = chat.getHistory() chat.streamMessages().collect { msg -> println(msg) } ``` `IChatHubProxy` is also a plain class — `IChatHubProxy(connection)` works — and its companion object is the `HubProxyFactory` that `getTypedMethods` takes. ## Server-to-client handlers Register handlers for methods the server calls on the client. The name is the contract's **wire name** — `Interface|Method` — and it is matched exactly; a call with no matching handler is not an error, it is logged and dropped. ```kotlin connection.onServerMethod("MyApp.Contracts.IChatClient|ReceiveMessage") { args -> val user = args.value(0) val message = args.value(1) println("$user: $message") } connection.onServerMethod("MyApp.Contracts.IChatClient|GetClientName") { Build.MODEL } ``` The handler receives a `ServerMethodArgs` — the arguments in contract order — and returns the result: `Unit` for void methods, any encodable value otherwise (primitives, collections, `@Serializable` classes). `args.value(index)` decodes an argument; `args.element(index)` gives the raw `JsonElement`. If the contract declares its own names with `[MessageName]`, use those instead — for `[MessageName("chat.client")]` on the interface and `[MessageName("received")]` on the member, the name is `"chat.client|received"`. ::: warning A throwing handler may go unnoticed If the server awaited a result, the error travels back to it. If the server sent fire-and-forget, the error is written to the client's logger as `Failed to handle server message ''` and goes no further — the server's send completed long before the handler ran. See [what happens when a handler throws](../dotnet-client/server-to-client#what-happens-when-a-handler-throws). ::: ## Cancellation When the contract declares a `CancellationToken`, the handler's coroutine is cancelled when the server cancels — nothing to wire up. `delay`, `withContext`, a `Flow`, an OkHttp call through `await()`: anything that cooperates with coroutine cancellation stops, and the handler sees a `ServerCancelledException`. ```kotlin // Contract: Task Wait(int seconds, CancellationToken ct) connection.onServerMethod("TestShared.ITestClientMethods|Wait") { args -> delay(args.value(0) * 1000L) // throws ServerCancelledException when the server cancels "done" } ``` The token's slot in the arguments holds a `ServerCancellationToken` (`args.cancellationToken(1)`), so argument positions stay aligned with the contract; use it to poll `isCancelled` or `awaitCancellation()` from non-suspending code. Handlers are also cancelled when the connection drops — their caller is gone. ## Streaming handlers For a contract member returning `IAsyncEnumerable`, register a `Flow`: ```kotlin connection.onServerStreamMethod("MyApp.Contracts.IChatClient|StreamData") { args -> val count = args.value(0) flow { repeat(count) { emit(it) } } } ``` Each item is sent to the server as it is emitted; completing the flow — or failing it — completes the server-side enumeration. ## Interface registration For structured registration under a prefix: ```kotlin connection.registerHandlers("MyApp.Contracts.IChatClient", mapOf( "ReceiveMessage" to { args -> showMessage(args.value(0), args.value(1)) }, "GetClientName" to { Build.MODEL }, )) ``` Or implement `ServerInterfaceHandler`: ```kotlin class ChatClient : ServerInterfaceHandler { override val interfaceName = "MyApp.Contracts.IChatClient" override fun handlers(): Map = mapOf( "ReceiveMessage" to { args -> showMessage(args.value(0), args.value(1)) }, "GetClientName" to { Build.MODEL }, ) } connection.registerInterface(ChatClient()) ``` ## HTTP stream references A `Stream` parameter on a server-to-client call arrives as its bytes: the client recognises the stream reference and downloads it before the handler runs, so `args.bytes(index)` is the content. A handler that returns a `ByteArray`, `File` or `InputStream` uploads it and hands the server a stream reference: ```kotlin // Contract: long FileLength(string id, Stream file) connection.onServerMethod("MyApp.Contracts.IFileClient|FileLength") { args -> args.bytes(1)!!.size.toLong() } // Contract: Stream GetFileStream(string name) connection.onServerMethod("MyApp.Contracts.IFileClient|GetFileStream") { args -> File(cacheDir, args.value(0)) } ``` The same applies to client-to-server calls: a `ByteArray`, `File`, `InputStream` or `UploadStream` argument to `invoke`/`send` is uploaded through `RequestUploadSlot` and `POST {hub}/upload/{id}`, and the server method receives a `Stream`. See [HTTP Stream References](../advanced/http-streams). ## Next steps * [Setup & Usage](./setup) — connection basics * [Cancellation Propagation](../advanced/cancellation) — how cancellation works across clients * [HTTP Stream References](../advanced/http-streams) — large file transfer --- --- url: /guide/swift-client/typed-proxies.md description: >- Swift typed proxies with the @HubProxy macro, server-to-client and streaming handlers, interface registration, cancellation support, and HTTP stream references --- # Typed Proxies & Server Methods The Swift client supports compile-time proxy generation via the `@HubProxy` macro and server-to-client method handling. ## @HubProxy Macro Mark a protocol with `@HubProxy` to generate a typed proxy class at build time: ```swift import CocoarSignalARRRMacros @HubProxy protocol IChatHub { func sendMessage(user: String, message: String) async throws func getHistory() async throws -> [String] func streamMessages() async throws -> AsyncThrowingStream } ``` The macro generates `IChatHubProxy` that routes each method to the correct connection call: | Return type | Generated call | Protocol | |-------------|----------------|----------| | `async throws` (void) | `connection.send("IChatHub\|method", ...)` | `SendMessage` | | `async throws -> T` | `connection.invoke("IChatHub\|method", ...)` | `InvokeMessageResult` | | `async throws -> AsyncThrowingStream` | `connection.stream("IChatHub\|method", ...)` | `StreamMessage` | ## Use typed proxies ```swift let chat = connection.getTypedMethods(IChatHubProxy.self) try await chat.sendMessage(user: "Alice", message: "Hello!") let history = try await chat.getHistory() for try await msg in try await chat.streamMessages() { print(msg) } ``` ## Server-to-client handlers Register handlers for methods the server can call on the client. The name is the contract's **wire name** — `interface|method` — and it is matched exactly; a handler that does not match is not an error, the call is simply dropped. See [Contract Wire Names](/guide/server/contracts-wire-names). ```swift await connection.onServerMethod("MyApp.Contracts.IChatClient|ReceiveMessage") { args in let user = args[0] as! String let message = args[1] as! String print("\(user): \(message)") return AnyCodable(nilLiteral: ()) } await connection.onServerMethod("MyApp.Contracts.IChatClient|GetClientName") { _ in return AnyCodable(stringLiteral: UIDevice.current.name) } ``` If the contract declares its own names with `[MessageName]`, use those instead — for `[MessageName("chat.client")]` on the interface and `[MessageName("received")]` on the member, the name is `"chat.client|received"`. Handlers return `AnyCodable` — a type-erased wrapper that supports all JSON-serializable types. ::: warning A throwing handler may go unnoticed If the server awaited a result, the error travels back to it. If the server sent fire-and-forget, the error is written to the client's logger as `Failed to handle server message ''` and goes no further — the server's send completed long before the handler ran. See [what happens when a handler throws](/guide/dotnet-client/server-to-client#what-happens-when-a-handler-throws). ::: ## Streaming handlers For server-to-client streaming methods, use `onServerStreamMethod`: ```swift await connection.onServerStreamMethod("StreamData") { args in AsyncThrowingStream { continuation in for i in 0..<10 { continuation.yield(AnyCodable(integerLiteral: i)) } continuation.finish() } } ``` ## Interface registration For structured handler registration with a prefix: ```swift await connection.registerHandlers(prefix: "ChatClient", handlers: [ "ReceiveMessage": { args in // handle message return AnyCodable(nilLiteral: ()) }, "GetClientName": { _ in return AnyCodable(stringLiteral: "SwiftClient") }, ]) ``` Or implement the `ServerInterfaceHandler` protocol: ```swift struct MyChatClient: ServerInterfaceHandler { static var interfaceName: String { "ChatClient" } func handlers() -> [String: @Sendable ([Any]) async throws -> AnyCodable] { [ "ReceiveMessage": { args in /* ... */ }, "GetClientName": { _ in AnyCodable(stringLiteral: "SwiftClient") }, ] } } await connection.registerInterface(MyChatClient()) ``` ## Cancellation support When the server passes a `CancellationToken` to a client method, the Swift client uses an actor-based `CancellationManager`. The handler's continuation is cancelled when the server sends `CancelTokenFromServer`. ## HTTP stream references The Swift client supports `StreamReference` resolution. When a server-to-client call includes a `Stream` parameter, the client detects the `StreamReference` marker and downloads the data via HTTP: ```swift let data = try await StreamReferenceResolver.resolve(streamRef) ``` ::: info Stream reference detection in server method dispatch is available. The client downloads via `URLSession`. ::: ## Next steps * [Setup & Usage](/guide/swift-client/setup) — connection basics * [Cancellation Propagation](/guide/advanced/cancellation) — how cancellation works across clients * [HTTP Stream References](/guide/advanced/http-streams) — large file transfer --- --- url: /guide/typescript-client/setup.md description: >- The @cocoar/signalarrr npm client: install, create a connection, invoke / send / stream, error handling, MessagePack, credential options (credential, connectionCredential, messageCredential), connection events and properties, method naming --- # TypeScript Client Setup The `@cocoar/signalarrr` npm package provides a TypeScript/JavaScript client for SignalARRR with support for `invoke`, `send`, `stream`, bidirectional streaming, and server-to-client method handling. ::: info Feature Parity The TypeScript client has full feature parity with the .NET client: core RPC, cancellation, bidirectional streaming, and HTTP stream references. ::: ## Installation ```bash npm install @cocoar/signalarrr @microsoft/signalr ``` The package ships as ESM and CJS with full TypeScript declarations. ## Create a connection Use the static `create()` factory with a builder callback: ```ts import { HARRRConnection } from '@cocoar/signalarrr'; import * as signalR from '@microsoft/signalr'; const connection = HARRRConnection.create(builder => { builder.withUrl('https://localhost:5001/apphub'); builder.withAutomaticReconnect(); }); ``` Or wrap an existing `HubConnection`: ```ts const hubConnection = new signalR.HubConnectionBuilder() .withUrl('https://localhost:5001/apphub') .build(); const connection = HARRRConnection.create(hubConnection); ``` ## Start and stop ```ts await connection.start(); // ... use the connection ... await connection.stop(); ``` ## Invoke (call with return value) `invoke()` calls a server method and awaits the result: ```ts const history = await connection.invoke('ChatMethods.GetHistory'); const user = await connection.invoke('UserMethods.GetUser', userId); ``` ## Send (fire-and-forget) `send()` calls a server method without waiting for a return value: ```ts await connection.send('ChatMethods.SendMessage', 'Alice', 'Hello!'); ``` ## Stream `stream()` opens a server-to-client stream: ```ts connection.stream('ChatMethods.StreamMessages').subscribe({ next: msg => console.log(msg), error: err => console.error(err), complete: () => console.log('Stream ended'), }); ``` ## Error handling When a server call fails, `invoke()` rejects with a `HARRRInvocationError`: the machine-readable code, the message, and the .NET exception type. Branch on `normalizedCode`, never on the message: ```ts import { HARRRErrorCodes, type HARRRInvocationError } from '@cocoar/signalarrr'; try { await connection.invoke('RoomMethods.Join', roomId); } catch (err) { const error = err as HARRRInvocationError; switch (error.normalizedCode) { case HARRRErrorCodes.Unauthorized: promptLogin(); break; case HARRRErrorCodes.MethodNotFound: reportContractMismatch(error); break; default: if (error.code === 'room_full') showRoomFull(); // application codes travel verbatim else showGeneric(error.message); } } ``` `normalizedCode` is the code folded to the set this client knows (unknown codes become `internal`); `code` is the raw wire value, which is where an application's own `HARRRException("room_full", ...)` codes appear. The codes are the same in every SignalARRR client. For an error that did not come through `invoke()` — a stream's error callback, say — `parseHARRRError()` reads the same envelope, and `normalizeErrorCode()` folds its `Code`: ```ts import { normalizeErrorCode, parseHARRRError } from '@cocoar/signalarrr'; const error = parseHARRRError(err); console.log(normalizeErrorCode(error.Code), error.Message); ``` A connection the server rejects at negotiate — 401 or 403 for a missing or invalid connection credential — makes `start()` reject with SignalR's error, which SignalARRR gives a `statusCode` with the HTTP status (SignalR itself mentions it only in the message): ```ts try { await connection.start(); } catch (err) { if ((err as { statusCode?: number }).statusCode === 401) promptLogin(); else throw err; } ``` ## MessagePack protocol For better performance with many clients, use MessagePack instead of JSON: ```bash npm install @microsoft/signalr-protocol-msgpack ``` ```ts import { MessagePackHubProtocol } from '@microsoft/signalr-protocol-msgpack'; const connection = HARRRConnection.create(builder => { builder.withUrl('https://localhost:5001/apphub'); builder.withHubProtocol(new MessagePackHubProtocol()); }); ``` The server must also have MessagePack enabled (`.AddMessagePackProtocol()`). Both JSON and MessagePack clients can connect to the same hub simultaneously. ## Authentication A connection has two credentials. Usually they are the same token, so one option sets both: ```ts const connection = HARRRConnection.create('https://localhost:5001/apphub', { credential: async () => await getAuthToken(), }); ``` To give them different credentials — a single-use connection ticket, say, which has no business being resent with every message — set them separately: ```ts const connection = HARRRConnection.create('https://localhost:5001/apphub', { connectionCredential: async () => await getConnectionTicket(), messageCredential: async () => await getAuthToken(), }); ``` | Option | Authenticates | Travels as | Checked by | When it expires | |---|---|---|---|---| | `connectionCredential` | negotiate and the transport | `Authorization` header; in a browser, the `access_token` URL parameter for WebSocket and SSE (Node: for SSE) | `[Authorize]` on the hub class, `.RequireAuthorization()` on the mapping | fetched again on every connect and reconnect | | `messageCredential` | every message, the answer to a challenge, file transfers | the `Authorization` field of the message; a header on file transfers | `[Authorize]` on a method or a `ServerMethods` class | fetched on every use, so a refreshed token is sent from the next call on | | `credential` | both of the above | | | | The options are named the same in every SignalARRR client, and nothing is coupled implicitly: `messageCredential` alone leaves the connection anonymous, `connectionCredential` alone sends no message credential. A `connectionCredential` or `messageCredential` next to `credential` takes its part over. Each may be synchronous, `async`, or a plain string. See [Authorization](/guide/server/authorization#the-two-credentials) for the same table across all clients. `HARRRConnection.create(url, options, configure?)` builds the SignalR connection itself and hands the connection credential to it as `accessTokenFactory`. SignalR's other connection options go into `options.httpConnectionOptions`; `configure` receives the builder after `withUrl`, for the protocol, automatic reconnect or logging: ```ts const connection = HARRRConnection.create( 'https://localhost:5001/apphub', { credential: async () => await getAuthToken(), httpConnectionOptions: { transport: signalR.HttpTransportType.WebSockets }, }, builder => builder.withAutomaticReconnect(), ); ``` With `create(builder => ...)` or an existing `HubConnection`, SignalR's own `accessTokenFactory` authenticates the connection, and only `messageCredential` applies — a connection credential there throws, as does `accessTokenFactory` in `httpConnectionOptions` next to `connectionCredential`. Token challenges are handled automatically — while a stream is running the server may send a `ChallengeAuthentication` message, and the client calls the message credential to answer it. A connection without a message credential is not cut off: once the server's auth cache lapses it falls back to the principal established at negotiate, the way plain SignalR would, and the expiry stated on that principal is still enforced. What you lose is the refresh — the server can no longer catch a revoked credential, and cannot ask you for a new one. A failing factory surfaces where the call does: `invoke()` and `send()` reject, and `stream()` reports the error to the subscriber — the stream is opened only once the credential is in hand. ::: tip Former names `authorization` is the message credential under its former name. It still works and is marked deprecated; replace it with `messageCredential`. Using it together with `messageCredential` or `credential` throws. ::: ::: warning Changed in 5.0.0 The client used to take the message credential from SignalR's `accessTokenFactory` automatically, by reading private fields off the connection. It no longer does. If your tokens are short-lived and refreshed — the usual reason for having them — set a message credential, or the connection will run on the identity it started with until that identity's stated expiry. ::: ## Connection events ```ts connection.onClose(error => { console.log('Connection closed', error); }); connection.onReconnecting(error => { console.log('Reconnecting...', error); }); connection.onReconnected(connectionId => { console.log('Reconnected as', connectionId); }); ``` ## Connection properties | Property | Type | Description | |----------|------|-------------| | `connectionId` | `string \| null` | Current connection ID | | `state` | `HubConnectionState` | `Disconnected`, `Connecting`, `Connected`, `Reconnecting` | | `baseUrl` | `string` | Hub URL (get/set) | | `serverTimeoutInMilliseconds` | `number` | Server timeout | | `keepAliveIntervalInMilliseconds` | `number` | Keepalive interval | ## Access the raw HubConnection ```ts const hubConnection = connection.asSignalRHubConnection(); ``` ## Method naming The TypeScript client uses string method names. The pattern is `ClassName.MethodName`: ```ts // Calls ChatMethods.SendMessage on the server await connection.send('ChatMethods.SendMessage', 'Alice', 'Hello!'); // Calls UserMethods.GetUser on the server const user = await connection.invoke('UserMethods.GetUser', userId); ``` ## Next steps * [Server Method Handlers](/guide/typescript-client/server-methods) — handle server-to-client calls * [Streaming](/guide/streaming/server-to-client) — stream data from the server * [Getting Started](/guide/getting-started) — full setup walkthrough --- --- url: /guide/why-signalarrr.md description: >- The production problems plain SignalR leaves open — magic strings, untyped server-to-client calls, growing hubs, token lifecycle, file transfer, item streaming — and what SignalARRR adds for each --- # Why SignalARRR? ASP.NET Core SignalR is excellent for real-time communication. SignalARRR builds on top of it to solve the problems that emerge in production applications with complex server-client interactions. ## The problems ### Magic strings everywhere With raw SignalR, every method call uses string identifiers. Rename a method and nothing warns you until runtime: ```csharp // Raw SignalR — no compile-time safety await Clients.All.SendAsync("ReceiveMessage", user, message); // string! await connection.InvokeAsync("GetHistory"); // string! ``` With SignalARRR, calls go through typed interfaces. Rename a method and the compiler catches it: ```csharp // SignalARRR — typed var client = ClientContext.GetTypedMethods(); client.ReceiveMessage(user, message); // compile-time checked var chat = connection.GetTypedMethods(); await chat.GetHistory(); // compile-time checked ``` ### Bidirectional RPC without type safety Since .NET 7, SignalR supports server-to-client calls with return values via `ISingleClientProxy.InvokeAsync()`. But these calls still rely on string method names: ```csharp // Raw SignalR — works, but no compile-time safety var result = await Clients.Single(connectionId) .InvokeAsync("GetClientName", cancellationToken); // string! ``` SignalARRR makes bidirectional RPC fully typed through shared interfaces: ```csharp // SignalARRR — typed bidirectional RPC var client = ClientContext.GetTypedMethods(); string name = await client.GetClientName(); // compile-time checked ``` ### Hub classes grow large As your application grows, a single hub class becomes unwieldy. SignalARRR lets you split methods across multiple `ServerMethods` classes that are auto-discovered and fully DI-enabled: ```csharp public class ChatMethods : ServerMethods, IChatHub { ... } public class AdminMethods : ServerMethods, IAdminHub { ... } public class NotificationMethods : ServerMethods, INotificationHub { ... } ``` ### Authorization without token lifecycle management Raw SignalR supports `[Authorize]` on hub methods, but there's no built-in mechanism for continuous token validation or automatic token refresh during a long-lived connection. SignalARRR adds a challenge/refresh flow and authorization inheritance across `ServerMethods` classes: ```csharp [Authorize] // inherited by all ServerMethods classes for this hub public class SecureHub : HARRR { ... } public class AdminMethods : ServerMethods, IAdminHub { [Authorize(Policy = "AdminOnly")] public Task DeleteUser(string userId) { ... } [AllowAnonymous] public Task GetPublicInfo() { ... } } ``` When a token expires mid-connection, SignalARRR automatically challenges the client for a fresh token instead of disconnecting. ### Large file transfer via HTTP (`Stream` parameters) WebSocket connections aren't built for multi-GB transfers — buffer limits, memory pressure, and blocking the shared connection for all clients. SignalARRR solves this transparently with **HTTP Stream References**: when a method has a `System.IO.Stream` parameter, the data is automatically routed through HTTP while the RPC call looks completely normal: ```csharp // Server sends a file to the client — looks like a normal call var client = ClientContext.GetTypedMethods(); long size = await client.ProcessFile("video.mp4", fileStream); // Client receives a regular Stream — no idea it came via HTTP long FileLength(string name, Stream fileStream) => fileStream.Length; ``` No chunking logic, no upload endpoints, no manual HTTP calls. Just a `Stream` parameter. ### Item streaming in both directions Separately from file transfer, SignalARRR supports **item streaming** — sending sequences of items over SignalR using `IAsyncEnumerable`, `IObservable`, and `ChannelReader` in both directions. ## What you get | Feature | Raw SignalR | SignalARRR | |---------|-------------|------------| | Typed method calls | No | Yes — shared interfaces | | Compile-time safety | No | Yes — source generator | | Server → Client RPC with return | Yes (string-based) | Yes — typed via interfaces | | Organized hub methods | Single class | Multiple ServerMethods classes | | Authorization inheritance across classes | No | Hub → ServerMethods inheritance | | Token auto-refresh on expiry | No | Built-in challenge flow | | Large file transfer via RPC | No | Automatic HTTP stream references | | CancellationToken propagation | Limited | Full — server can cancel client | | Client Manager (outside hub) | Manual | Built-in | | TypeScript client | Basic | Full protocol support | | Swift client (iOS/macOS) | No | Native client with `@HubProxy` macro | | .NET Framework 4.x client | Basic (string-based) | Typed RPC via DispatchProxy | ## Bridge between .NET Core and .NET Framework SignalARRR supports **.NET 8, .NET 9, .NET 10** on server and client — and **.NET Framework 4.6.2+** on the client side. This enables a powerful pattern in enterprise environments: **use a modern .NET server while keeping legacy .NET Framework clients**. Real-world example: Your server runs on .NET 10 with the latest frontend technology, while legacy clients on .NET Framework 4.8 connect to the same hub. Those legacy clients can load platform-specific DLLs (SCCM, SCSM, COM interop, legacy SDKs) that don't run on .NET Core — but they still get typed bidirectional RPC through SignalARRR. ``` ┌───────────────────────────────┐ │ .NET 10 Server │ │ Latest ASP.NET Core │ └───────────────────────────────┘ ▲ ▲ ▲ │ │ │ ┌────────┘ Same hub, └─────────┐ │ same typed RPC │ ▼ ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │ .NET 8 Client │ │ TypeScript │ │ .NET Framework 4.8 │ │ Cross-platform │ │ Browser UI │ │ SCCM/SCSM/COM DLLs │ └──────────────────┘ └──────────────────┘ └─────────────────────┘ ``` The FullFramework client (`Cocoar.SignalARRR.Client.FullFramework`) has near-full feature parity: typed proxies via `DispatchProxy`, streaming, server-to-client RPC, cancellation, file transfer, and MessagePack — all on netstandard2.0. ## How it works SignalARRR wraps SignalR's standard hub protocol. All communication flows through a small set of well-defined hub methods (`InvokeMessage`, `InvokeMessageResult`, `StreamMessage`, etc.) that carry typed payloads. The source generator produces proxy classes that serialize interface method calls into these messages — no runtime reflection needed. A hub therefore stays an ordinary SignalR hub, and a raw SignalR client can share it with SignalARRR ones. What a raw client can do is worth being precise about, because "compatible" is easy to over-read: **It can** connect — nothing is required of a client at connection time; call methods declared directly on the hub, as ordinary SignalR methods; and drive the SignalARRR protocol by hand, since the hub method names are constants and the payload is plain JSON with PascalCase fields: ```js connection.invoke("InvokeMessageResult", { Method: "Alerts.List", Arguments: [], Authorization: "" }); ``` **It has to bring its own** handling for everything the SignalARRR clients do for it: typed proxies, the `interface|method` dispatch names, cancellation token references (`__type: "cancellationToken"`), stream references over HTTP, and the four server-to-client handlers — `InvokeServerRequest`, `InvokeServerMessage`, `ChallengeAuthentication`, `CancelTokenFromServer`. Without the last one it cannot answer an authentication challenge, which a `[Authorize]`-gated stream will eventually send. Note also that a method reached the raw way is authorized by SignalR against the connection's principal, while the same method reached through `InvokeMessageResult` is authorized by SignalARRR against the message credential. Both checks apply; they simply consult different credentials. ## Next steps * [Getting Started](/guide/getting-started) — install and set up your first hub * [Hub Setup](/guide/server/hub-setup) — understand the HARRR base class * [Packages](/reference/packages) — choose the right NuGet/npm packages --- --- url: /reference/wire-protocol.md description: >- The SignalR hub methods and message types SignalARRR uses on the wire — InvokeMessage, InvokeServerMessage, ChallengeAuthentication, CancelTokenFromServer, streaming — with the protocol flows and method name resolution, for custom clients --- # Wire Protocol SignalARRR communicates over standard SignalR using a fixed set of hub methods. This page documents the protocol for advanced use cases and custom client implementations. ## Hub method names ### Client → Server | Hub method | Purpose | Payload | Returns | |------------|---------|---------|---------| | `InvokeMessage` | Fire-and-forget (void methods) | `ClientRequestMessage` | — | | `InvokeMessageResult` | Call with return value | `ClientRequestMessage` | `object` | | `SendMessage` | Fire-and-forget (Task methods) | `ClientRequestMessage` | — | | `StreamMessage` | Open a server-to-client stream | `ClientRequestMessage` | `IAsyncEnumerable` | | `StreamItemToServer` | Send a stream item | `Guid streamId, object item` | — | | `StreamCompleteToServer` | Complete a stream | `Guid streamId, string? error` | — | ### Server → Client | Message | Purpose | Payload | |---------|---------|---------| | `InvokeServerRequest` | Call client method, expect reply (native client result) | `ServerRequestMessage` | | `InvokeServerMessage` | Call client method, fire-and-forget | `ServerRequestMessage` | | `ChallengeAuthentication` | Request fresh auth token | `ServerRequestMessage` | | `CancelTokenFromServer` | Cancel a client operation | `ServerRequestMessage` (with `CancellationGuid`) | ## Message types ### ClientRequestMessage Sent from client to server with every RPC call. ```json { "Method": "ChatMethods.SendMessage", "Arguments": ["Alice", "Hello!"], "Authorization": "Bearer eyJ...", "GenericArguments": [] } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `Method` | string | Yes | `ClassName.MethodName` or `InterfaceName\|MethodName` | | `Arguments` | array | Yes | Serialized method arguments | | `Authorization` | string | No | Bearer token for authenticated methods | | `GenericArguments` | string\[] | No | Type names for generic methods | ### ServerRequestMessage Sent from server to client for bidirectional RPC. ```json { "Id": "550e8400-e29b-41d4-a716-446655440000", "Method": "GetClientName", "Arguments": [], "CancellationGuid": null, "StreamId": null } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `Id` | GUID | Yes | Correlation ID — used internally by SignalR's native client results | | `Method` | string | Yes | Method name to invoke on client | | `Arguments` | array | No | Method arguments | | `GenericArguments` | string\[] | No | Generic type arguments | | `CancellationGuid` | GUID | No | Links to `CancelTokenFromServer` for remote cancellation | | `StreamId` | GUID | No | Stream correlation for client-to-server streams | ### CancellationTokenReference Marker object placed in `Arguments` where a `CancellationToken` parameter exists. The client detects this and substitutes an `AbortSignal` (TypeScript) or `CancellationToken` (.NET). ```json { "Id": "550e8400-e29b-41d4-a716-446655440001" } ``` ## Protocol flows ### Client calls server (with result) ```mermaid sequenceDiagram participant Client participant Server Client->>Server: InvokeMessageResult(ClientRequestMessage) Server->>Server: Resolve method, authorize, execute Server-->>Client: Return result ``` ### Server calls client (with result) ```mermaid sequenceDiagram participant Server participant Client Server->>Client: InvokeServerRequest(ServerRequestMessage) Client->>Client: Dispatch to registered handler Note over Client: Handler returns result (native SignalR client result) Server-->>Server: InvokeCoreAsync completes with result ``` ### Token challenge flow ```mermaid sequenceDiagram participant Client participant Server Client->>Server: InvokeMessageResult (expired token) Server->>Client: ChallengeAuthentication(ServerRequestMessage) Client->>Client: Call the message credential Note over Client: Handler returns new token (native SignalR client result) Server-->>Server: InvokeCoreAsync completes with token Server->>Server: Validate new token, continue request Server-->>Client: Return result ``` ### Client-to-server streaming ```mermaid sequenceDiagram participant Client participant Server Client->>Server: StreamItemToServer(streamId, item1) Client->>Server: StreamItemToServer(streamId, item2) Client->>Server: StreamCompleteToServer(streamId, null) ``` ## Method name resolution The server resolves method names in two formats: | Format | Example | Resolution | |--------|---------|------------| | `ClassName.MethodName` | `ChatMethods.SendMessage` | Direct lookup in method collection | | `InterfaceName\|MethodName` | `IChatHub\|SendMessage` | Interface-based lookup (used by typed proxies) | ## Next steps * [API Overview](/reference/api) — public API surface * [Packages](/reference/packages) — package guide * [Cancellation Propagation](/guide/advanced/cancellation) — cancellation protocol