The browser client is written in Rust and compiled to WebAssembly. It uses the browser's WebSocket API as a framed Remoc transport and exposes the generated RTC client to a small JavaScript interface with wasm-bindgen.

The Axum server provides the page, WebAssembly module and WebSocket endpoint on one port. Its counter state is shared between connections, so an update from one browser reaches the watch receiver in every other browser.

Running it

Clone the repository and install the WebAssembly build prerequisites:

git clone https://github.com/remoc-rs/remoc
cd remoc
rustup target add wasm32-unknown-unknown
cargo install wasm-pack

Build the client, embed it in the server and start the server:

./examples/rtc-web/run.sh

Open http://127.0.0.1:9872 in two browser windows to see the shared updates.

The program

The build script runs wasm-pack before compiling the server. The remaining listings show the shared trait, server, HTML and Rust browser client.

build

Builds the WebAssembly client before compiling the server that embeds it.

Cargo.toml

Uses a size-oriented release profile for the WebAssembly build.

[workspace]
resolver = "2"
members = ["counter", "counter-client-web", "counter-server-web"]

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

build.sh

Runs the two target-specific builds in the required order.

#!/usr/bin/env bash
set -euo pipefail

example_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
assets_dir="${example_dir}/target/web"

# Compile the Rust browser client and generate the JavaScript loader for it.
wasm-pack build \
    "${example_dir}/counter-client-web" \
    --target web \
    --release \
    --no-pack \
    --no-typescript \
    --out-name counter_web \
    --out-dir "${assets_dir}"

# Compile the native server, which embeds the generated browser files.
cargo build \
    --manifest-path "${example_dir}/Cargo.toml" \
    --package counter-server-web

counter

Defines the remote trait shared by the browser client and server.

counter/Cargo.toml

Its dependencies.

[package]
name = "counter-web"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
remoc = { version = "0.20" }
serde = { version = "1", features = ["derive"] }

counter/src/lib.rs

Defines the increment, decrement and watch methods on the shared counter.

//! Remote trait shared by the web counter client and server.
#![warn(missing_docs)]

use remoc::prelude::*;
use std::{error::Error, fmt};

/// HTTP and WebSocket port used by the server.
pub const HTTP_PORT: u16 = 9872;

/// Changing the counter failed.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub enum ChangeError {
    /// The value cannot be increased any further.
    Maximum,
    /// The value cannot be decreased any further.
    Minimum,
    /// The RTC call failed.
    Call(rtc::CallError),
}

impl From<rtc::CallError> for ChangeError {
    fn from(error: rtc::CallError) -> Self {
        Self::Call(error)
    }
}

impl fmt::Display for ChangeError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Maximum => formatter.write_str("the counter reached its maximum value"),
            Self::Minimum => formatter.write_str("the counter cannot be decreased below zero"),
            Self::Call(error) => error.fmt(formatter),
        }
    }
}

impl Error for ChangeError {}

/// A counter shared by all connected clients.
// The macro generates the CounterClient and CounterServerSharedMut types.
#[rtc::remote(server(SharedMut))]
pub trait Counter {
    /// Increase the value by one.
    async fn increment(&mut self) -> Result<(), ChangeError>;

    /// Decrease the value by one.
    async fn decrement(&mut self) -> Result<(), ChangeError>;

    /// Subscribe to the current value and subsequent changes.
    async fn watch(&self) -> Result<rch::watch::Receiver<u32>, rtc::CallError>;
}

counter-server-web

Keeps the shared counter and serves the page and Remoc WebSocket endpoint.

counter-server-web/Cargo.toml

Its dependencies.

[package]
name = "counter-server-web"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
counter-web = { path = "../counter" }
remoc = { version = "0.20" }

anyhow = "1"
axum = { version = "0.8", features = ["ws"] }
bytes = "1"
futures = "0.3"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
tracing = "0.1"
tracing-subscriber = "0.3"

counter-server-web/src/main.rs

Adapts an Axum WebSocket for Remoc and embeds the generated browser assets.

//! Axum server for the shared web counter.

use anyhow::Context;
use axum::{
    Router,
    body::Bytes,
    extract::{
        State,
        ws::{Message, WebSocket, WebSocketUpgrade},
    },
    http::{HeaderValue, header},
    response::{Html, IntoResponse, Response},
    routing::{any, get},
};
use counter_web::{ChangeError, Counter, CounterServerSharedMut, HTTP_PORT};
use futures::{SinkExt, StreamExt, future};
use remoc::{codec, prelude::*};
use std::{net::Ipv4Addr, sync::Arc};
use tokio::sync::RwLock;

// These files become part of the server executable at compile time.
const INDEX_HTML: &str = include_str!("index.html");
const CLIENT_JS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../target/web/counter_web.js"));
const CLIENT_WASM: &[u8] =
    include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../target/web/counter_web_bg.wasm"));

type SharedCounter = Arc<RwLock<CounterObj>>;

/// The shared counter state.
struct CounterObj {
    value: rch::watch::Sender<u32>,
}

impl Default for CounterObj {
    fn default() -> Self {
        let (value, _) = rch::watch::channel(0);
        Self { value }
    }
}

impl Counter for CounterObj {
    async fn increment(&mut self) -> Result<(), ChangeError> {
        let current = *self.value.borrow();
        let value = current.checked_add(1).ok_or(ChangeError::Maximum)?;
        self.value.send_replace(value);
        Ok(())
    }

    async fn decrement(&mut self) -> Result<(), ChangeError> {
        let current = *self.value.borrow();
        let value = current.checked_sub(1).ok_or(ChangeError::Minimum)?;
        self.value.send_replace(value);
        Ok(())
    }

    async fn watch(&self) -> Result<rch::watch::Receiver<u32>, rtc::CallError> {
        Ok(self.value.subscribe())
    }
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt::init();

    let counter = Arc::new(RwLock::new(CounterObj::default()));
    let app = Router::new()
        .route("/", get(index))
        .route("/counter_web.js", get(client_js))
        .route("/counter_web_bg.wasm", get(client_wasm))
        .route("/remoc", any(websocket))
        .with_state(counter);

    let address = (Ipv4Addr::LOCALHOST, HTTP_PORT);
    let listener = tokio::net::TcpListener::bind(address)
        .await
        .with_context(|| format!("failed to listen on {}:{}", address.0, address.1))?;

    println!("Open http://{}:{} in a web browser.", address.0, address.1);
    axum::serve(listener, app).await.context("web server failed")
}

async fn index() -> Html<&'static str> {
    Html(INDEX_HTML)
}

async fn client_js() -> impl IntoResponse {
    ([(header::CONTENT_TYPE, HeaderValue::from_static("text/javascript; charset=utf-8"))], CLIENT_JS)
}

async fn client_wasm() -> impl IntoResponse {
    ([(header::CONTENT_TYPE, HeaderValue::from_static("application/wasm"))], Bytes::from_static(CLIENT_WASM))
}

async fn websocket(State(counter): State<SharedCounter>, upgrade: WebSocketUpgrade) -> Response {
    // Axum hands the upgraded WebSocket to this future.
    upgrade.on_upgrade(move |socket| async move {
        if let Err(error) = serve_client(socket, counter).await {
            tracing::warn!(%error, "Remoc client connection failed");
        }
    })
}

async fn serve_client(socket: WebSocket, counter: SharedCounter) -> anyhow::Result<()> {
    let (websocket_tx, websocket_rx) = socket.split();

    // Adapt Axum's binary WebSocket messages to the packet sink and stream Remoc expects.
    let transport_tx =
        websocket_tx.with(|packet: Bytes| future::ready(Ok::<_, axum::Error>(Message::Binary(packet))));
    let transport_rx = websocket_rx.filter_map(|message| {
        future::ready(match message {
            Ok(Message::Binary(packet)) => Some(Ok(packet)),
            Ok(_) => None,
            Err(error) => Some(Err(error)),
        })
    });

    // The macro-generated server executes calls on the counter shared by all connections.
    let (server, client) = CounterServerSharedMut::<_, codec::Default>::new(counter);

    // Send its client proxy to the browser, then serve calls arriving through that proxy.
    remoc::Connect::framed(remoc::Cfg::default(), transport_tx, transport_rx)
        .provide(client)
        .await
        .context("failed to establish Remoc connection")?;
    server.serve().await.map_err(|error| anyhow::anyhow!("failed to serve counter: {error}"))
}

counter-server-web/src/index.html

Provides the counter controls and calls the Rust client from JavaScript.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Remoc shared counter</title>
  <style>
    body {
      max-width: 24rem;
      margin: 4rem auto;
      padding: 0 1rem;
      font-family: system-ui, sans-serif;
      text-align: center;
    }

    output {
      display: block;
      margin: 2rem;
      font: 4rem/1 ui-monospace, monospace;
    }

    button {
      margin: 0 0.25rem;
      padding: 0.6rem 1rem;
      font: inherit;
      cursor: pointer;
    }

    button:disabled {
      opacity: 0.45;
    }

    #status {
      min-height: 1.5em;
    }

    #status.error {
      color: #b00020;
    }
  </style>
</head>
<body>
  <main>
    <h1>Shared Remoc counter</h1>
    <output id="value" aria-live="polite">--</output>
    <div>
      <button id="decrement" type="button" disabled>Decrease</button>
      <button id="increment" type="button" disabled>Increase</button>
    </div>
    <p id="status">Connecting...</p>
  </main>

  <script type="module">
    import init, { WebCounter } from "./counter_web.js";

    const value = document.querySelector("#value");
    const status = document.querySelector("#status");
    const decrement = document.querySelector("#decrement");
    const increment = document.querySelector("#increment");
    const buttons = [decrement, increment];
    let counter;
    let connected = false;

    function setButtonsEnabled(enabled) {
      for (const button of buttons) {
        button.disabled = !enabled;
      }
    }

    function showError(error) {
      connected = false;
      setButtonsEnabled(false);
      status.textContent = String(error);
      status.classList.add("error");
    }

    async function change(operation) {
      setButtonsEnabled(false);
      status.textContent = "Updating...";
      status.classList.remove("error");

      try {
        await operation();
        status.textContent = "Connected";
      } catch (error) {
        status.textContent = String(error);
        status.classList.add("error");
      } finally {
        setButtonsEnabled(connected);
      }
    }

    decrement.addEventListener("click", () => change(() => counter.decrement()));
    increment.addEventListener("click", () => change(() => counter.increment()));

    try {
      // Load the WebAssembly module before using the Rust WebCounter type.
      await init();
      const websocketProtocol = location.protocol === "https:" ? "wss:" : "ws:";

      // Rust calls these functions when a watched value arrives or the channel closes.
      counter = await WebCounter.connect(
        `${websocketProtocol}//${location.host}/remoc`,
        newValue => {
          value.value = newValue;
        },
        error => {
          showError(`Disconnected: ${error}`);
        },
      );
      connected = true;
      status.textContent = "Connected";
      setButtonsEnabled(true);
    } catch (error) {
      showError(`Connection failed: ${error}`);
    }
  </script>
</body>
</html>

counter-client-web

Runs the generated counter client in the browser.

counter-client-web/Cargo.toml

Its dependencies.

[package]
name = "counter-client-web"
version = "0.1.0"
edition = "2024"
publish = false

[lib]
crate-type = ["cdylib"]

[dependencies]
counter-web = { path = "../counter" }
remoc = { version = "0.20", features = ["js"] }

bytes = "1"
futures = "0.3"
js-sys = "0.3"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console"] }
websocket-web = "0.1"

counter-client-web/src/lib.rs

Connects through the browser WebSocket API and exposes the counter to JavaScript with wasm-bindgen.

//! Browser client for the shared RTC counter.

use bytes::Bytes;
use counter_web::{Counter, CounterClient};
use futures::{SinkExt, StreamExt, future, lock::Mutex};
use js_sys::Function;
use remoc::prelude::*;
use std::{fmt, io, rc::Rc};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use websocket_web::{Msg, WebSocket};

/// A browser-facing handle to the remote counter.
// wasm-bindgen makes this type and its public methods available to JavaScript.
#[wasm_bindgen]
pub struct WebCounter {
    client: Rc<Mutex<CounterClient>>,
}

#[wasm_bindgen]
impl WebCounter {
    /// Connects to the counter and starts forwarding watched values to JavaScript.
    #[wasm_bindgen(js_name = connect)]
    pub async fn connect(
        url: String, on_value: Function, on_disconnect: Function,
    ) -> Result<WebCounter, JsValue> {
        let websocket = WebSocket::connect(&url).await.map_err(js_error)?;
        let (websocket_tx, websocket_rx) = websocket.into_split();

        // Adapt binary WebSocket messages to the packet sink and stream Remoc expects.
        let transport_tx =
            websocket_tx.with(|packet: Bytes| future::ready(Ok::<_, io::Error>(Msg::Binary(packet.into()))));
        let transport_rx = websocket_rx.filter_map(|message| {
            future::ready(match message {
                Ok(Msg::Binary(packet)) => Some(Ok(Bytes::from(packet))),
                Ok(Msg::Text(_)) => None,
                Err(error) => Some(Err(error)),
            })
        });

        // Start Remoc on the WebSocket and receive the CounterClient sent by the server.
        let client: CounterClient = remoc::Connect::framed(remoc::Cfg::default(), transport_tx, transport_rx)
            .consume()
            .await
            .map_err(js_error)?;

        // Send the current value to JavaScript before waiting for later changes.
        let mut watch = client.watch().await.map_err(js_error)?;
        let initial_value = *watch.borrow_and_update().map_err(js_error)?;
        call_with_value(&on_value, initial_value)?;

        // Browser futures run on the current JavaScript event-loop thread.
        spawn_local(async move {
            if let Err(error) = forward_values(watch, on_value).await {
                report_disconnect(&on_disconnect, error);
            }
        });

        Ok(Self { client: Rc::new(Mutex::new(client)) })
    }

    /// Increases the shared counter by one.
    pub async fn increment(&self) -> Result<(), JsValue> {
        self.client.lock().await.increment().await.map_err(js_error)
    }

    /// Decreases the shared counter by one.
    pub async fn decrement(&self) -> Result<(), JsValue> {
        self.client.lock().await.decrement().await.map_err(js_error)
    }
}

/// Forwards watched counter values to JavaScript until the channel closes.
async fn forward_values(
    mut watch: rch::watch::Receiver<u32>, on_value: Function,
) -> Result<(), rch::watch::ChangedError> {
    loop {
        watch.changed().await?;
        let value = *watch.borrow_and_update()?;
        if let Err(error) = call_with_value(&on_value, value) {
            web_sys::console::error_2(&"The value callback failed:".into(), &error);
        }
    }
}

fn call_with_value(callback: &Function, value: u32) -> Result<(), JsValue> {
    callback.call1(&JsValue::NULL, &JsValue::from(value))?;
    Ok(())
}

fn report_disconnect(error_callback: &Function, error: impl fmt::Display) {
    if let Err(callback_error) = error_callback.call1(&JsValue::NULL, &JsValue::from_str(&error.to_string())) {
        web_sys::console::error_2(&"The disconnection callback failed:".into(), &callback_error);
    }
}

fn js_error(error: impl fmt::Display) -> JsValue {
    js_sys::Error::new(&error.to_string()).into()
}

Browse it on GitHub