SignalR for Rust

A real SignalR client, native and in the browser.

dXn.SignalR connects Rust programs to ASP.NET Core SignalR hubs from Windows, Linux, macOS, Android, iOS and the browser. One API covers invoke, send, server and client streams, server calls with client results, and reconnection, modelled on the official .NET client so its lifecycle and options carry over.

cargo add dxn-signalr --git https://github.com/dXnSix/dXn.SignalR --tag v1.1.0
main.rs
use dxn_signalr::SignalRClient;
use futures::StreamExt;

#[tokio::main]
async fn main() -> Result<(), dxn_signalr::Error> {
    let mut client = SignalRClient::connect_with("localhost", "chat", |c| {
        c.unsecure();
        c.with_port(5000);
    })
    .await?;

    let _on = client.register_typed("ReceiveMessage", |_ctx, (user, text): (String, String)| {
        println!("{user}: {text}");
    });

    client
        .send_with_args("Send".to_string(), |a| { a.argument("rust").argument("hi"); })
        .await?;
    let sum: i32 = client
        .invoke_with_args("Add".to_string(), |a| { a.argument(2).argument(3); })
        .await?;

    let mut prices = client.enumerate::<f64>("Prices".to_string()).await;
    while let Some(price) = prices.next().await {
        println!("{sum} {price}");
    }
    Ok(())
}
Rust
1.97+ (edition 2021)
Targets
Tokio native + browser WASM
Protocols
JSON, MessagePack
Transports
WebSockets, SSE, Long Polling
Server
ASP.NET Core SignalR
Release
git tag v1.1.0

Quick tour

#Call, stream and listen

Method names are strings and arguments are positional, so a call reads like the hub method it targets. Server streams are futures::Streams; dropping one sends CancelInvocation, so the hub stops producing.

use dxn_signalr::SignalRClient;
use futures::StreamExt;

async fn tour(mut client: SignalRClient) -> Result<(), dxn_signalr::Error> {
    let total: i32 = client.invoke("Total".to_string()).await?; // typed result
    client.send("Ping".to_string()).await?; // fire and forget

    let mut ticks = client.enumerate::<f64>("Prices".to_string()).await;
    let first_ten: Vec<f64> = (&mut ticks).take(10).collect().await;
    drop(ticks); // cancels the server-side enumeration
    println!("{total} {first_ten:?}");
    Ok(())
}

Calling the hub covers arguments, client streams, cancellation and timeouts.

#Reconnect without losing in-flight calls

Reconnection is opt-in, as in .NET. With ASP.NET Core 8 stateful reconnect, a short network drop resumes the same logical connection: pending calls stay pending and unacknowledged messages are replayed.

use std::sync::Arc;
use dxn_signalr::{DefaultRetryPolicy, ReconnectionConfig, SignalRClient, StatefulReconnectConfig};

async fn resilient() -> Result<SignalRClient, dxn_signalr::Error> {
    let stateful = StatefulReconnectConfig::new(256 * 1024)?; // replay buffer in bytes
    SignalRClient::connect_with("chat.example.com", "chat", move |c| {
        c.with_messagepack_protocol();
        c.with_stateful_reconnect(stateful);
        c.with_reconnection_policy(ReconnectionConfig {
            policy: Arc::new(DefaultRetryPolicy), // 0, 2, 10, 30 s
        });
    })
    .await
}

Reconnection explains policies, manual control and what a resume guarantees.

#The same code in the browser

The browser build uses fetch, EventSource and WebSocket and runs on the JavaScript event loop. Spawn the client with spawn_local; everything else is the API you used natively.

use dxn_signalr::SignalRClient;

pub fn start() {
    wasm_bindgen_futures::spawn_local(async {
        let result = SignalRClient::connect_with("chat.example.com", "chat", |c| {
            c.authenticate_bearer("<access token>".to_string());
        })
        .await;
        match result {
            Ok(mut client) => {
                let _ = client.send("Ping".to_string()).await;
            }
            Err(error) => log::error!("cannot connect: {error}"),
        }
    });
}

Browser and WebAssembly covers the build, CORS and WebSocket authentication.

#Errors you can branch on

Every failure is one Error type with an ErrorKind and structured fields. Never match on message text.

use dxn_signalr::{ErrorKind, SignalRClient};

async fn total(client: &mut SignalRClient) -> Option<i32> {
    match client.invoke::<i32>("Total".to_string()).await {
        Ok(total) => Some(total),
        Err(error) if error.kind() == ErrorKind::Hub => {
            eprintln!("the hub failed: {}", error.server_message().unwrap_or(""));
            None
        }
        Err(error) if error.is_transient() => {
            eprintln!("try again later: {error}");
            None
        }
        Err(error) => {
            eprintln!("{error}");
            None
        }
    }
}

Errors and logging lists every kind, and Troubleshooting works backwards from the symptom.