Server-to-client calls
Register callbacks for hub pushes, decode typed arguments, answer client-result calls, and avoid keeping a client alive by accident.
A hub calls clients with Clients.All.SendAsync("Target", args...) or a strongly typed hub interface. The client handles those calls by registering a callback for the target name.
#Register a callback
use dxn_signalr::{CallbackHandler, SignalRClient};
use serde::Deserialize;
#[derive(Deserialize)]
struct ChatMessage {
user: String,
text: String,
}
fn listen(client: &mut SignalRClient) {
let registration = client.register("ReceiveMessage".to_string(), |ctx| {
match ctx.argument::<ChatMessage>(0) {
Ok(message) => println!("{}: {}", message.user, message.text),
Err(error) => eprintln!("unexpected payload: {error}"),
}
});
// Later, to stop receiving the calls:
registration.unregister();
}
register(target, callback)takes a target (impl Into<String>) and a callbackFn(InvocationContext). Natively the callback must beSend + Sync + 'static; in the browser only'static.- The target name matches exactly, case-sensitively, the name the server passes to
SendAsync. - It returns an
impl CallbackHandler. Import theCallbackHandlertrait to callunregister(self), which removes this callback only.client.remove("ReceiveMessage")removes every callback of a target. - Dropping the returned handle does not unregister the callback; only
unregister()does, solet _on = client.register(...)keeps it active for the client’s lifetime. - A target can have several callbacks. They run in registration order for every call, and registering or unregistering takes effect at once.
- A call for a target without a callback is dropped and logged as a warning. If it expects a result, it is answered with an error.
- Callbacks belong to the client, not to a physical connection, so they survive reconnects.
#Which registration method
| Method | Arguments | Callback may await | Use when |
|---|---|---|---|
register | ctx.argument::<T>(i) per argument | no | quick handlers that read a few arguments |
register_typed | decoded into a tuple, up to 12 elements | no | the argument types are known up front |
register_async | ctx.argument::<T>(i) | yes | the handler awaits (a call back to the hub, a client result) |
#Typed callbacks
register_typed decodes the server’s arguments into a tuple before the callback runs, like the .NET client’s On<T1, T2>:
use dxn_signalr::{CallbackHandler, SignalRClient};
fn prices(client: &mut SignalRClient) {
let registration = client.register_typed("PriceChanged", |_ctx, (symbol, price): (String, f64)| {
println!("{symbol} = {price}");
});
registration.unregister();
}
When the arguments do not bind (another count, or a value that does not decode), the callback is not called and the failure is logged. A call that expects a result is answered with the .NET client’s Client failed to parse argument(s). Use () for calls without arguments.
#Asynchronous callbacks
register_async takes a callback that returns a future. The dispatch task awaits it before the next callback or call, as the .NET client awaits asynchronous On handlers. Awaiting an invoke inside is fine: completions are processed while the callback waits.
use dxn_signalr::{CallbackHandler, SignalRClient};
fn refresh(client: &mut SignalRClient) {
let registration = client.register_async("Refresh".to_string(), |ctx| async move {
let mut hub = ctx.client.clone();
if let Ok(latest) = hub.invoke::<String>("Latest".to_string()).await {
println!("latest: {latest}");
}
});
registration.unregister();
}
#Where callbacks run
Server calls run on the client’s dispatch task, separate from message processing: natively on the connection’s runtime, in the browser on the event loop. Calls run one at a time in arrival order. A slow callback delays the server calls behind it but never completions, stream items or keep-alives, so an invoke still completes while a callback runs.
A call that expects a result is the exception, as in .NET: once one of its asynchronous callbacks waits, the rest continues on its own task and later calls go on. A synchronous callback that blocks still holds later calls. A panicking callback is caught and logged natively, and the connection keeps working.
#Client results
ASP.NET Core 7 and later can wait for a value from one client, with await Clients.Client(id).InvokeAsync<T>("Target", args, cancellationToken). The client answers with InvocationContext::complete(result):
// Hub method; needs MaximumParallelInvocationsPerClient > 1 (see Server setup).
public async Task<bool> Checkout(string connectionId, decimal amount) =>
await Clients.Client(connectionId)
.InvokeAsync<bool>("ConfirmPurchase", amount, CancellationToken.None);
use dxn_signalr::{CallbackHandler, SignalRClient};
fn confirm(client: &mut SignalRClient) {
let registration = client.register_async("ConfirmPurchase".to_string(), |mut ctx| async move {
let amount: f64 = ctx.argument(0).unwrap_or_default();
let approved = amount < 100.0;
if let Err(error) = ctx.complete(approved).await {
eprintln!("cannot answer the server: {error}");
}
});
registration.unregister();
}
complete is async and needs &mut self. With a synchronous register callback, move the context into a task with InvocationContext::spawn; with register_async you can await it directly, as above.
fail(message)answers with an error instead; the server’sInvokeAsyncthrows aHubExceptionwithmessage.- A call gets exactly one answer: the first
completeorfailof any of its callbacks. A second returns anErrorKind::Protocolerror and sends nothing. - When every context of a call is dropped without an answer, or no callback is registered, the client sends the .NET client’s
Client didn't provide a result., so the server never waits for a client that will not answer. expects_result()tells whether the server waits. A plainSendAsynccall has no invocation id, socompleteandfailreturn anErrorKind::Protocolerror.- The answer goes to the logical connection the call arrived on. With stateful reconnect it survives a resume; after an ordinary reconnect the server has forgotten the call and
completereturns anErrorKind::Transporterror.
#InvocationContext
| Member | Description |
|---|---|
argument::<T>(index) | Deserializes one positional argument; ErrorKind::Serialization when it is missing or does not match T. |
argument_count() | The number of arguments the server sent. |
expects_result() | Whether the server waits for an answer (InvokeAsync). |
complete(result).await | Sends a client result; the first answer wins. |
fail(message).await | Answers with an error. |
client (public field) | A clone of the SignalRClient, to call the hub from inside the callback. |
InvocationContext::spawn(future) | tokio::spawn natively (the future must be Send), spawn_local in the browser. |
MessagePack arguments are decoded from the server’s original values, so binary data, timestamps and integer map keys survive. See Hub protocols.
#Registrations do not keep a client alive
Registrations hold the client only weakly. Dropping the last SignalRClient handle stops the connection even while callbacks are registered, and a stopped client keeps its registrations for a later start.
use dxn_signalr::{CallbackHandler, SignalRClient};
fn tidy(client: &mut SignalRClient) {
let on_message = client.register_typed("ReceiveMessage", |_ctx, (text,): (String,)| {
println!("{text}");
});
// Later, when the application is done with the hub:
on_message.unregister();
client.disconnect();
}
#Next
Lifecycle covers hooks, keep-alive and pause. The cookbook has a complete client-result flow.