SignalR Hubs
The toolkit adds strongly typed contracts, session integration and typed error reporting to SignalR hubs. This guide covers declaring a hub contract, using it on the server and the client, streaming, and authorizing connections.
Packages
The contract definitions live in Singulink.Net.Http.Api so a data contracts assembly can declare them without depending on ASP.NET Core. The server extensions are in Singulink.Net.Http.Api.Service and the client connection is in Singulink.Net.Http.Api.Client.SignalR.
Declaring a Hub Contract
A contract is a static class of definitions. Each definition captures the message name and the argument types, so both sides get compile-time checking of both. The name is taken from the declaring member automatically through CallerMemberNameAttribute, so a definition is just = new():
public static class DocumentHub
{
// Client to server
public static HubMessage<Guid?, bool> UpdatePresence { get; } = new();
public static HubMethod<long, DocumentSnapshot> Subscribe { get; } = new();
public static HubStream<long, DocumentChange> Changes { get; } = new();
// Server to client
public static HubMessage<IReadOnlyList<PresenceInfo>> PresenceUpdated { get; } = new();
public static HubMethod<string, string> RequestDraft { get; } = new();
}
There are three kinds:
- HubMessage (and the generic variants up to four arguments) is fire-and-forget in either direction.
- HubMethod<TResult> (and variants with up to three arguments plus the result) is invoked and returns a result, in either direction.
- HubStream<TItem> (and variants with up to three arguments plus the item type) streams items from the server to the client.
A definition does not record its direction. A message sent from a client is handled by a hub method with the same name; a message sent from the server is handled by a client handler registered for the definition. Pass an explicit name to the constructor when the member name must differ from the wire name, for example to keep compatibility with existing clients.
Tip
Group definitions per hub and use nameof on the hub method side, so a renamed definition fails to compile on the server too.
Using the Contract on the Server
Hub methods are named after the client-to-server definitions and take the same argument types. Server-to-client messages are sent with SendAsync, and methods are invoked on a single client with InvokeAsync:
public sealed class DocumentEditHub : Hub
{
public async Task UpdatePresence(Guid? itemId, bool hasUnsavedEdits)
{
var snapshot = PresenceTracker.Update(Context.ConnectionId, itemId, hasUnsavedEdits);
await Clients.Group(GroupName).SendAsync(DocumentHub.PresenceUpdated, snapshot);
}
public Task<DocumentSnapshot> Subscribe(long documentId) => ...;
public async IAsyncEnumerable<DocumentChange> Changes(long documentId, [EnumeratorCancellation] CancellationToken cancellationToken) { ... }
public Task<string> GetDraftFromEditor(string editorConnectionId) =>
Clients.Client(editorConnectionId).InvokeAsync(DocumentHub.RequestDraft, "latest", Context.ConnectionAborted);
}
A HubStream<TItem> is implemented by a hub method returning IAsyncEnumerable<T> of the item type. Client-to-server streaming needs no definition of its own: an IAsyncEnumerable<T> argument on a message or method is streamed to the server automatically by SignalR.
Reporting hub errors as API errors
Register ApiExceptionHubFilter so that exceptions thrown by hub methods, during streaming, and while connecting are reported to clients as the same typed errors as HTTP requests, mapped by the registered IApiExceptionHandler:
services.AddSignalR(options => options.AddApiExceptionFilter());
Without the filter, SignalR masks exception details and the client receives a generic HubException. With it, an ApiException thrown from a hub method arrives on the client as the same exception type, and unexpected exceptions are logged with a reference ID by the handler. See Exception Handling.
Authorizing connections with the session
GetRequiredSessionTokenAsync retrieves the session token for the connection. Call it in OnConnectedAsync to authorize the connection and join the appropriate groups:
public override async Task OnConnectedAsync()
{
var token = await Context.GetRequiredSessionTokenAsync<SessionToken>();
long documentId = long.Parse(Context.GetHttpContext()!.Request.RouteValues["documentId"]!.ToString()!);
if (!await CanReadAsync(token, documentId))
throw new ForbiddenApiException("You do not have access to this document.");
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(documentId));
}
With the hub filter registered, a connection rejected this way surfaces on the client as the typed exception through the connection's closed event.
Note
The session token is validated when the connection is established. Hub methods called later on the same connection see the token as it was at that time, so long-lived connections should authorize at the granularity that makes sense for the hub, typically per document or resource in OnConnectedAsync.
Using the Contract on the Client
A client deriving from SignalRApiClientBase creates connections with CreateHubConnection, which returns an ApiHubConnection that shares the client's session and default query parameters:
public ApiHubConnection CreateDocumentEditConnection(long documentId) =>
CreateHubConnection($"documents/{documentId}/edit-hub");
The connection exposes only the typed API. Handlers are registered with On, messages are sent with SendAsync (no wait) or InvokeAsync (waits for the server, and returns the result for methods), and streams are read with StreamAsync:
await using var connection = client.CreateDocumentEditConnection(documentId);
connection.On(DocumentHub.PresenceUpdated, presence => UpdatePresenceList(presence));
connection.On(DocumentHub.RequestDraft, (string kind) => Editor.GetDraftText());
connection.Closed += error => { ShowDisconnected(error); return Task.CompletedTask; };
await connection.StartAsync();
var snapshot = await connection.InvokeAsync(DocumentHub.Subscribe, documentId);
await connection.SendAsync(DocumentHub.UpdatePresence, selectedItemId, hasUnsavedEdits: false);
await foreach (var change in connection.StreamAsync(DocumentHub.Changes, documentId, cancellationToken))
ApplyChange(change);
Errors reported by the hub filter are thrown as the matching ApiException from invocations and stream enumeration, and delivered through Closed when a connection is rejected. Other hub errors remain HubException instances.
The connection automatically reconnects by default (override CreateHubConnectionBuilder to change the policy) and always presents the client's current session token when connecting or reconnecting, so a session refreshed by an HTTP request between reconnects does not invalidate the session.
UnderlyingConnection exposes the SignalR HubConnection for anything the typed API does not cover. Errors from direct use of it are not translated.
Next Steps
Continue with these related articles:
- Exception Handling - The handler that maps hub exceptions.
- Session Handling - The session the hub authorizes against.
- Error Response Format - How hub errors are encoded.