crates/nornir-viz-web/src/main.rs
//! **nornir-viz-web** entry points.
//!
//! - **wasm** (`wasm32-unknown-unknown`): `wasm_start` mounts [`NornirVizApp`] on
//! the `<canvas id="nornir_viz">` in `index.html` via eframe's glow/WebGL
//! `WebRunner`. `window.__nornir_viz_state()` returns the live `state_json`.
//! - **native** (dev + the serve path):
//! - `nornir-viz-web` (no args) — open the viz in a native eframe window.
//! - `nornir-viz-web serve --dir <dist> [--addr 127.0.0.1:8080]` — "nornir viz
//! --> server nornir": serve the built wasm bundle as a static website over
//! axum (feature `serve`). This is the nornir server that hosts the viz site.
//!
//! Build the wasm bundle with `./build-wasm.sh` (wasm-bindgen), then serve `dist/`.
// ─────────────────────────── native ───────────────────────────
/// The pure-Rust git-hash build identity — `v<pkg> (<git-sha>)`, mirroring
/// `nornir::VERSION_LINE`. `NORNIR_VIZ_WEB_GIT_HASH` is stamped by this crate's
/// own `build.rs` (this crate is not a member of the main `nornir` crate, so it
/// cannot read `nornir::VERSION_LINE`).
#[cfg(not(target_arch = "wasm32"))]
const VERSION_LINE: &str = concat!(
"v",
env!("CARGO_PKG_VERSION"),
" (",
env!("NORNIR_VIZ_WEB_GIT_HASH"),
")"
);
#[cfg(not(target_arch = "wasm32"))]
fn main() {
let args: Vec<String> = std::env::args().collect();
match args.get(1).map(String::as_str) {
// Build-version LAW: `--version`/`-V` prints the git-hash identity and
// exits (CLI parity with the rest of the suite; never a build without it).
Some("--version") | Some("-V") => {
println!("nornir-viz-web {VERSION_LINE}");
}
Some("serve") => {
#[cfg(feature = "serve")]
{
serve::run(&args[2..]);
}
#[cfg(not(feature = "serve"))]
{
eprintln!(
"the `serve` subcommand needs the `serve` feature:\n \
cargo run -p nornir-viz-web --features serve -- serve --dir dist"
);
std::process::exit(2);
}
}
_ => native_viz(),
}
}
/// Open the viz in a native eframe (wgpu) window — the dev counterpart of the
/// browser bundle (same [`NornirVizApp`], so what you see natively is what ships).
#[cfg(not(target_arch = "wasm32"))]
fn native_viz() {
let native_options = eframe::NativeOptions {
// LAW #4 — WebGPU, never OpenGL. Explicit so the native preview and the wasm
// bundle are provably the same backend family: "what you see natively is what
// ships" is only true if both name their renderer.
renderer: eframe::Renderer::Wgpu,
viewport: eframe::egui::ViewportBuilder::default()
.with_inner_size([1200.0, 780.0])
.with_title("nornir · viz (web preview)"),
..Default::default()
};
let _ = eframe::run_native(
"nornir-viz-web",
native_options,
Box::new(|_cc| Ok(Box::new(nornir_viz_web::NornirVizApp::new()))),
);
}
/// The static-file server: "nornir viz --> server nornir" — axum + tower-http
/// `ServeDir` hosting the built wasm bundle (`index.html` + `.js` + `.wasm`).
#[cfg(all(not(target_arch = "wasm32"), feature = "serve"))]
mod serve {
use std::net::SocketAddr;
use std::path::PathBuf;
use axum::Router;
use tower_http::services::ServeDir;
/// Parse `--dir <path>` (default `dist`) and `--addr <ip:port>` (default
/// `127.0.0.1:8080`) and serve the directory as a website until Ctrl-C.
pub fn run(args: &[String]) {
let mut dir = PathBuf::from("dist");
let mut addr: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--dir" => {
if let Some(v) = it.next() {
dir = PathBuf::from(v);
}
}
"--addr" => {
if let Some(v) = it.next() {
addr = v.parse().unwrap_or(addr);
}
}
_ => {}
}
}
if !dir.join("index.html").exists() {
eprintln!(
"no `{}` — build the wasm bundle first:\n ./crates/nornir-viz-web/build-wasm.sh",
dir.join("index.html").display()
);
std::process::exit(2);
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
// `ServeDir` serves the bundle; the fallback re-serves index.html so any
// deep-link path lands on the SPA. Correct MIME for `.wasm` is set by
// tower-http from the extension (application/wasm).
let app = Router::new()
.fallback_service(ServeDir::new(&dir).append_index_html_on_directories(true));
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind");
eprintln!(
"nornir viz website serving {} at http://{addr}/",
dir.display()
);
axum::serve(listener, app).await.expect("serve");
});
}
}
// On wasm the bin must still link a `main`, but eframe drives the canvas from
// `wasm_start`, so `main` does nothing.
#[cfg(target_arch = "wasm32")]
fn main() {}
// ─────────────────────────── wasm ───────────────────────────
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(start)]
pub fn wasm_start() {
use wasm_bindgen::JsCast as _;
// Pretty panics in the browser console (opaque otherwise).
console_error_panic_hook::set_once();
// LAW #4 — WebGPU, never WebGL. `WebOptions` carries the same `renderer` field
// `NativeOptions` does, and leaving it to `Renderer::default()` means the browser
// backend is decided by whichever eframe feature the manifest happens to carry. On
// wasm32 `glow` means WebGL2, so naming this is what makes the page's backend a
// statement in the source rather than a side effect of feature resolution.
let web_options = eframe::WebOptions {
renderer: eframe::Renderer::Wgpu,
..Default::default()
};
wasm_bindgen_futures::spawn_local(async {
let document = web_sys::window()
.expect("no `window`")
.document()
.expect("no `document`");
let canvas = document
.get_element_by_id("nornir_viz")
.expect("missing <canvas id=\"nornir_viz\">")
.dyn_into::<web_sys::HtmlCanvasElement>()
.expect("#nornir_viz is not a <canvas>");
let start_result = eframe::WebRunner::new()
.start(
canvas,
web_options,
Box::new(|_cc| Ok(Box::new(nornir_viz_web::NornirVizApp::new()))),
)
.await;
if let Err(e) = start_result {
log::error!("eframe WebRunner failed to start: {e:?}");
}
});
}
/// `window.__nornir_viz_state()` — return the live app's whole `state_json` as a
/// string, so a headless browser drive / the console can read what actually
/// rendered in the shipped wasm bundle.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(js_name = __nornir_viz_state)]
pub fn nornir_viz_state() -> String {
nornir_viz_web::wasm_state::latest()
}