Keeps the shared counter and serves the page and Remoc WebSocket endpoint.
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>