Browser and WebAssembly
Build the client for wasm32-unknown-unknown, wire up logging, authenticate WebSockets, configure CORS, and run in Web Workers and multithreaded builds.
The browser build uses fetch for negotiation, Long Polling and Server-Sent Events sends, EventSource for the Server-Sent Events stream and the browser’s WebSocket for WebSockets. It is event-driven, timers are cancellable gloo-timers futures, and nothing touches window, so it runs on the main thread, in Web Workers and in multithreaded (atomics) builds.
#Set up a project
A browser crate is a cdylib that depends on dxn-signalr and wasm-bindgen.
[lib]
crate-type = ["cdylib"]
[dependencies]
dxn-signalr = { git = "https://github.com/dXnSix/dXn.SignalR", tag = "v1.1.0" }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
console_error_panic_hook = "0.1"
console_log = "1"
log = "0.4"
serde = { version = "1", features = ["derive"] }
Install the target and build. The wasm-bindgen CLI version must equal the wasm-bindgen crate version in your Cargo.lock, or the generated glue fails to load.
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release
wasm-bindgen --target web --out-dir pkg target/wasm32-unknown-unknown/release/my_app.wasm
wasm-pack build --target web does both steps. Then load the module from a page:
<script type="module">
import init from "./pkg/my_app.js";
await init();
</script>
#A client in the browser
Drive the client from wasm_bindgen_futures::spawn_local. The client is !Send, so keep it on the thread or worker that created it and use one client per thread or worker.
use dxn_signalr::{CallbackHandler, SignalRClient};
use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn start() {
console_error_panic_hook::set_once();
console_log::init_with_level(log::Level::Info).expect("logger");
wasm_bindgen_futures::spawn_local(async {
let mut client = match SignalRClient::connect_with("chat.example.com", "chat", |c| {
c.authenticate_bearer("<access token>".to_string());
})
.await
{
Ok(client) => client,
Err(error) => {
log::error!("cannot connect: {error}");
return;
}
};
let _messages = client.register_typed("ReceiveMessage", |_ctx, (user, text): (String, String)| {
log::info!("{user}: {text}");
});
match client.invoke::<i32>("Total".to_string()).await {
Ok(total) => log::info!("total: {total}"),
Err(error) => log::warn!("Total failed: {error}"),
}
});
}
The crate logs through the log facade under the dxn_signalr target. console_log sends it to the browser console. InvocationContext::spawn maps to spawn_local in the browser.
#Authentication
Browsers cannot set headers on a WebSocket or an EventSource. Bearer tokens go on the WebSocket and event-stream URLs as the access_token query parameter, and on fetch requests as Authorization. Basic credentials reach fetch requests only, so a standard ASP.NET Core server cannot authenticate the WebSocket from them. Use authenticate_bearer or with_access_token_factory, and read access_token on the server (see Server setup).
#CORS
The page and the hub usually have different origins, so the server must allow the page’s origin for the negotiate request and, over Long Polling, the GET polls, POST sends and closing DELETE (over Server-Sent Events, the stream’s GET and the POST sends). See Server setup. Native clients are not subject to CORS.
fetch requests use the default same-origin credentials mode, so cookies (a sticky-session cookie, say) are not sent to a hub on another origin, and there is no option to change it.
#What differs from native
- TLS and proxies. The browser’s TLS, trust store and proxy settings apply. The TLS features,
with_proxy,with_cookie_jar,with_client_certificateandwith_http_versiondo not exist there. - Headers.
with_headerandwith_request_hookreachfetchrequests only.User-Agentis sent asX-SignalR-User-Agent. - Pause. A browser
pause()always sends a WebSocket Close, which ends a stateful logical connection;resumenegotiates a new one. - Hidden tabs and frozen pages. Browsers throttle timers in hidden tabs and freeze or discard pages. Suspension detection notices late timers once the page runs again; pause on
visibilitychangeandfreezefor the best behavior, as in the cookbook.
#Web Workers
The same code runs in a dedicated Web Worker: start the worker with your wasm-bindgen module and connect from inside it. Nothing in the client needs the DOM. The client belongs to the worker that created it.
#Multithreaded WASM
Builds with WebAssembly threads (atomics) work too. They need:
- a cross-origin isolated page:
Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp; - a nightly toolchain with
rust-srcand-Z build-std=std,panic_abort; - shared, imported memory and the thread-local-storage exports that wasm-bindgen’s thread support expects. The repository’s CI builds with:
RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals
-C link-arg=--shared-memory -C link-arg=--import-memory -C link-arg=--max-memory=2147483648
-C link-arg=--export=__heap_base -C link-arg=--export=__wasm_init_tls
-C link-arg=--export=__tls_size -C link-arg=--export=__tls_align -C link-arg=--export=__tls_base"
cargo +nightly build --target wasm32-unknown-unknown -Z build-std=std,panic_abort
Binary frames are copied out of shared memory before they are sent, because WebSocket.send does not accept views of a SharedArrayBuffer. Keep each client on the thread that connected it.
#Next
Platforms covers native runtimes, Android and iOS. The cookbook wires the browser lifecycle to pause and resume.