facet-wrapped/src/wasm.rs
//! The **wasm boundary** (compiled only for `wasm32`) — a thin `wasm-bindgen` layer
//! over the [`Registry`](crate::registry::Registry).
//!
//! Two kinds of export:
//!
//! * **discovery + pre-flight**, canvas-free: enumerate components, read a fixture's
//! state, and ask whether a payload is renderable *before* mounting anything;
//! * **the mount**, which starts an eframe/egui canvas driving the chosen component —
//! on the **wgpu/WebGPU** path, so what appears is the real component and not a
//! picture of one.
//!
//! ## WebGPU, and why there is no quiet fallback
//! The canvas is driven by eframe's `wgpu` backend, which on wasm32 renders through
//! the browser's WebGPU (`navigator.gpu`). WebGL is gone from this crate.
//!
//! WebGPU can be *absent* in a way WebGL rarely was: it needs a secure context
//! (`https:` or a `localhost` origin) and, headless, a working adapter. So the mount
//! **pre-flights the adapter** and, on failure, records the workspace's typed
//! `facet-gpu-1` [`GpuUnavailable`](facett_core::render::GpuUnavailable) in
//! [`facet_error`] instead of leaving a blank canvas. That matters for the robots: a
//! silent failure would hang a driver waiting for [`facet_mounted`] forever, and a
//! silent *fallback* would let a green robot row claim a WebGPU port that never
//! happened. [`facet_adapter`] reports the **physical device** the canvas opened — its
//! name, its vendor and whether it is a software rasteriser — so a test can assert on
//! the renderer rather than trust this comment. [`facet_backend`] answers the narrower
//! question of what kind of surface this is, and cannot tell a Radeon from SwiftShader.
//!
//! The backend choice itself is NOT made here: `facett_core::render::gpu`'s
//! `preferred_backends()` / `facett_wgpu_options()` own it for the whole workspace,
//! and on wasm32 that is `Backends::BROWSER_WEBGPU` — WebGPU only, no WebGL.
//!
//! [`facet_state`] builds the mounted component's `state_json` when a host asks for
//! it (not on every frame), so a headless driver reads exactly what the running
//! canvas rendered and a frame does not pay for a readout nobody takes. That is the seam robot
//! tests drive: assert on real state, never on a screenshot or a substring.
//!
//! A live mount that cannot render the caller's data **refuses**: nothing is mounted
//! and [`facet_error`] carries the typed reason. The host shows that text instead of
//! a canvas. A fixture is never substituted for the caller's data.
use std::sync::Mutex;
use wasm_bindgen::prelude::*;
use facett_core::{Facet, LookTheme};
use crate::registry::Registry;
/// The name of the component currently on the canvas.
static MOUNTED: Mutex<String> = Mutex::new(String::new());
/// The last mount refusal (empty when the last mount succeeded).
static LAST_ERROR: Mutex<String> = Mutex::new(String::new());
thread_local! {
/// The LIVE mounted component, shared by the running canvas and [`facet_state`].
///
/// The state used to be serialised on EVERY frame into a string slot, whether or
/// not anyone read it. MEASURED 2026-09-28 (oden, native, 10 000-node graph):
/// graphnav's `state_json` cost 9.3 s (its node table computed betweenness), so a
/// 10k graph never painted a second frame in the browser. The state is now built
/// when a host ASKS for it, and a frame pays nothing for it.
static LIVE: std::cell::RefCell<Option<std::rc::Rc<std::cell::RefCell<Box<dyn Facet>>>>> =
const { std::cell::RefCell::new(None) };
/// The component [`facet_check`] just built, with the name and payload it was built
/// from. The loader always checks and then mounts the SAME payload, which used to
/// parse and build it twice — for graph3d at 10 000 nodes that is two CPU force
/// layouts. A mount (or a live update) of an identical payload takes this one.
static CHECKED: std::cell::RefCell<Option<(String, String, Box<dyn Facet>)>> =
const { std::cell::RefCell::new(None) };
/// Timing counters for [`facet_perf`].
static PERF: std::cell::RefCell<Perf> = std::cell::RefCell::new(Perf::default());
/// A replacement component built by [`facet_update_data`], waiting for the running
/// canvas to swap it in on its next frame. `Box<dyn Facet>` is not `Send`, and on
/// wasm32 there is one thread, so a thread-local is the honest home for it.
static PENDING: std::cell::RefCell<Option<(String, Box<dyn Facet>)>> =
const { std::cell::RefCell::new(None) };
/// The running canvas's egui context, so an update can request the frame that
/// consumes it (a component with no animation would otherwise wait for the next
/// pointer event to show new data).
static CTX: std::cell::RefCell<Option<egui::Context>> = const { std::cell::RefCell::new(None) };
}
fn set(slot: &Mutex<String>, v: &str) {
if let Ok(mut g) = slot.lock() {
g.clear();
g.push_str(v);
}
}
fn get(slot: &Mutex<String>) -> String {
slot.lock().map(|g| g.clone()).unwrap_or_default()
}
/// What [`facet_perf`] reports: where the time of a mount and of each frame went.
#[derive(Default, serde::Serialize)]
struct Perf {
/// Frames the mounted component has drawn.
frames: u64,
/// The component's own `ui` time (not egui's tessellation, not the GPU submit).
ui_ms_last: f64,
ui_ms_total: f64,
ui_ms_max: f64,
/// The last `facet_check`: payload parse and component build.
check_parse_ms: f64,
check_build_ms: f64,
/// The last mount or update: parse and build, or `reused_check` when the checked
/// component was taken instead.
mount_parse_ms: f64,
mount_build_ms: f64,
reused_check: bool,
/// The last `facet_state()` read: `state_json` + serialisation.
state_ms_last: f64,
state_bytes_last: usize,
}
/// `performance.now()`, or 0 where there is no window.
fn now_ms() -> f64 {
web_sys::window().and_then(|w| w.performance()).map_or(0.0, |p| p.now())
}
/// Parse `data_json` and build `component` from it, timing both halves into `perf`.
fn parse_and_build(component: &str, data_json: &str) -> (Result<Box<dyn Facet>, String>, f64, f64) {
let t0 = now_ms();
let data = match serde_json::from_str::<serde_json::Value>(data_json) {
Ok(v) => v,
Err(e) => return (Err(format!("malformed data payload for `{component}`: {e}")), now_ms() - t0, 0.0),
};
let t1 = now_ms();
let built = Registry::with_builtins().build_with(component, &data).map_err(|e| e.to_string());
(built, t1 - t0, now_ms() - t1)
}
/// Take the component [`facet_check`] built, if it was built from exactly this
/// `component` + `data_json`; otherwise parse and build now. Records the timings.
fn checked_or_build(component: &str, data_json: &str) -> Result<Box<dyn Facet>, String> {
let reused = CHECKED.with(|c| {
let mut c = c.borrow_mut();
match c.take() {
Some((n, p, f)) if n == component && p == data_json => Some(f),
other => {
*c = other;
None
}
}
});
if let Some(f) = reused {
PERF.with(|p| {
let mut p = p.borrow_mut();
p.reused_check = true;
p.mount_parse_ms = 0.0;
p.mount_build_ms = 0.0;
});
return Ok(f);
}
let (built, parse, build) = parse_and_build(component, data_json);
PERF.with(|p| {
let mut p = p.borrow_mut();
p.reused_check = false;
p.mount_parse_ms = parse;
p.mount_build_ms = build;
});
built
}
// ── discovery + pre-flight (no canvas) ──────────────────────────────────────────
/// The component manifest as JSON: `[{ name, description, group, title, data_driven }]`.
#[wasm_bindgen]
#[must_use]
pub fn facet_manifest() -> String {
Registry::with_builtins().manifest_json().to_string()
}
/// How many components are wrapped.
#[wasm_bindgen]
#[must_use]
pub fn facet_count() -> usize {
Registry::with_builtins().len()
}
/// The names that accept caller data, as a JSON array string.
#[wasm_bindgen]
#[must_use]
pub fn facet_data_driven() -> String {
serde_json::to_string(&Registry::with_builtins().data_driven_names())
.unwrap_or_else(|_| "[]".into())
}
/// A component's **fixture** `state_json` (`"null"` if the name is unknown).
#[wasm_bindgen]
#[must_use]
pub fn facet_fixture_state(name: &str) -> String {
Registry::with_builtins()
.component_state_json(name)
.map(|v| v.to_string())
.unwrap_or_else(|| "null".to_string())
}
/// **Pre-flight**: `""` when `name` can render `data_json`, else the typed refusal
/// message. A host calls this before mounting so it can show the reason in place of a
/// canvas rather than mounting something that merely looks right.
#[wasm_bindgen]
#[must_use]
pub fn facet_check(name: &str, data_json: &str) -> String {
let (built, parse, build) = parse_and_build(name, data_json);
PERF.with(|p| {
let mut p = p.borrow_mut();
p.check_parse_ms = parse;
p.check_build_ms = build;
});
match built {
Ok(f) => {
// Kept for the mount that follows (see `CHECKED`).
CHECKED.with(|c| *c.borrow_mut() = Some((name.to_string(), data_json.to_string(), f)));
String::new()
}
Err(e) => e,
}
}
/// A component's `state_json` **built from caller data**, without a canvas — the
/// headless read a robot test asserts on. `"null"` on ANY refusal; no fixture is ever
/// substituted. Use [`facet_check`] for the reason.
#[wasm_bindgen]
#[must_use]
pub fn facet_state_with_data(name: &str, data_json: &str) -> String {
let Ok(data) = serde_json::from_str::<serde_json::Value>(data_json) else {
return "null".to_string();
};
Registry::with_builtins()
.build_with(name, &data)
.map(|f| f.state_json().to_string())
.unwrap_or_else(|_| "null".to_string())
}
// ── the canvas mount ────────────────────────────────────────────────────────────
/// The eframe app hosting ONE wrapped component. Its `ui` is the component's own —
/// the whole interactive surface — plus the per-frame state republish.
struct MountApp {
name: String,
/// Shared with [`LIVE`], so [`facet_state`] can read the component between frames.
facet: std::rc::Rc<std::cell::RefCell<Box<dyn Facet>>>,
}
impl eframe::App for MountApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if let Some(render_state) = frame.wgpu_render_state() {
facett_flow::gpu::evict_idle(render_state, ui.ctx().cumulative_frame_nr());
}
let t0 = now_ms();
// A live update swaps the component IN PLACE: same runner, same canvas, same
// camera — no second eframe on the element, no teardown per frame of data.
if let Some((name, facet)) = PENDING.with(|p| p.borrow_mut().take()) {
self.name = name;
*self.facet.borrow_mut() = facet;
}
// `facet.ui(ui)` — wrapped in the navgraph spine when the host asked for dev
// mode (`facet_dev`), and exactly `facet.ui(ui)` otherwise. State is read on
// demand through `LIVE` (`facet_state`), never serialised per frame.
crate::devmode::draw(ui, &self.name, &mut **self.facet.borrow_mut());
set(&MOUNTED, &self.name);
let dt = now_ms() - t0;
PERF.with(|p| {
let mut p = p.borrow_mut();
p.frames += 1;
p.ui_ms_last = dt;
p.ui_ms_total += dt;
p.ui_ms_max = p.ui_ms_max.max(dt);
});
}
}
/// **Dev mode for this page** (navgraph numbers on the canvas). The loader calls it
/// before the first mount when the host passes `dev`; `nav` is the overlay mode
/// (`labels` / `next` / `all`). Returns whether dev mode is on. `Ctrl+Shift+G` cycles
/// the overlay and `Ctrl+G <number> Enter` focuses a node, as in every facett app.
#[wasm_bindgen]
pub fn facet_dev(nav: &str) -> bool {
facett_core::navgraph::web::prevent_browser_ctrl_g();
crate::devmode::request(nav)
}
/// The mounted canvas's navigation graph as JSON (`null` before the first frame or
/// outside dev mode) — what a robot asserts the numbers on.
#[wasm_bindgen]
#[must_use]
pub fn facet_navgraph() -> String {
CTX.with(|c| c.borrow().as_ref().map(|ctx| crate::devmode::graph_json(ctx).to_string()))
.unwrap_or_else(|| "null".to_string())
}
/// Mount component `component`'s **fixture** onto `<canvas id=canvas_id>` (the
/// zero-config showcase path).
#[wasm_bindgen]
pub fn facet_mount(canvas_id: &str, component: &str) {
mount_inner(canvas_id, component, "");
}
/// Mount component `component` onto the canvas **with caller data**.
///
/// An empty `data_json` takes the fixture path. Otherwise the payload must render:
/// a malformed document, an unknown component, a fixture-only component or a payload
/// with nothing in it mounts NOTHING and records the reason in [`facet_error`].
#[wasm_bindgen]
pub fn facet_mount_with_data(canvas_id: &str, component: &str, data_json: &str) {
mount_inner(canvas_id, component, data_json);
}
/// **Replace the mounted component's data** without remounting the canvas.
///
/// The live-feed path (row 6): a host streaming snapshots — knut's `knutpipe web`
/// sends one `PipelineModel` per Spark run event — calls this per frame of data. The
/// new component is built through the same registry seam as a mount and swapped into
/// the RUNNING eframe app on its next frame, so the renderer, the canvas and the
/// user's camera survive. Calling `facet_mount_with_data` per snapshot instead would
/// start a second `WebRunner` on the same element every time.
///
/// Returns `""` on success, else the typed refusal (also recorded in
/// [`facet_error`]); on a refusal the canvas keeps showing the previous data. There
/// must already be a mounted canvas for the swap to land — a host calls
/// `facet_mount_with_data` for the first frame and this for the rest.
#[wasm_bindgen]
#[must_use]
pub fn facet_update_data(component: &str, data_json: &str) -> String {
match checked_or_build(component, data_json) {
Ok(f) => {
set(&LAST_ERROR, "");
PENDING.with(|p| *p.borrow_mut() = Some((component.to_string(), f)));
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
String::new()
}
Err(msg) => {
log::error!("facet-wrapped: {msg}");
set(&LAST_ERROR, &msg);
msg
}
}
}
/// **Put a renderer on every GPU lane this bundle SELECTS** — the second half of the
/// GPU path, and the half this crate was missing.
///
/// # The defect this closes (measured on oden's RTX 4090, 2026-09-06)
///
/// The bundle mounted `osm3d` inside a Streamlit iframe and reported, off the running
/// page: `facet_canvas_api: "webgpu"` · `gpu_lane_selected: true` ·
/// `backend: "wgpu-vello"` · **`lane_flag: "cpu"`**. Not `pending` — so a frame really
/// had painted, and it had painted on the CPU painter, **identically on the 4090 and on
/// SwiftShader**, which is the signature of something that is not a property of the
/// adapter at all.
///
/// It was a missing call, right here. Selecting a lane and installing its renderer are
/// two different acts:
///
/// * `with_gpu(true)` (`crate::map::osm3d`) swaps the backend. That is what
/// `gpu_lane_selected` and `backend` report, and it is answerable before any frame.
/// * `install_renderer` puts a `Map3dRenderer` into the `RenderState`'s
/// `callback_resources`. **Without it the paint callback simply no-ops** and
/// `facett_map3d::gpu::renderer_installed()` stays `false`, so
/// `WgpuVelloBackend::paint` reports `used_gpu = false`
/// (`facett-map3d/src/backend.rs`, the `let installed = …` line), `last_used_gpu`
/// becomes `Some(false)`, and `lane_flag()` answers a perfectly honest `Cpu`.
///
/// `crate::map::osm3d`'s own comment predicted this in the same breath as selecting the
/// lane — *"if the host has not installed a `Map3dRenderer` into its `RenderState`, the
/// callback no-ops and the CPU path still draws"*. **facet-wrapped is that host**, and
/// until this function existed the crate contained zero occurrences of
/// `install_renderer` in `src/` or `tests/`.
///
/// # Why it is a function and not two lines in the closure
///
/// The set of lanes to install is derived from ONE fact — which GPU features
/// `facet-wrapped/Cargo.toml` turns on — and that fact already has a guard reading it
/// out of cargo's own manifest parse (`xtask/tests/wasm_bundle_has_gpu_features.rs`).
/// Keeping the installs in one named place means the manifest edge and the install have
/// a single seam between them instead of being restated inside a `WebRunner` callback
/// (LAW 5). `tests/gpu_lanes_are_installed.rs` holds the two sides together.
///
/// # What is installed, and how observable each one is — said plainly
///
/// * **`facett-map3d/wgpu` → [`facett_map3d::gpu::install_renderer`]. PROVEN.** It is
/// the one lane with a public runtime readout (`lane_flag`/`used_gpu` in
/// `state_json`), so the browser can be asked whether this line took effect, and it
/// was asked before and after.
/// * **`facett-graph3d/wgpu` and `facett-graphview/gpu` →
/// `graphcloud::cloud::install_cloud_renderer`.** Both graph surfaces route their node/edge
/// cloud through facett-core's ONE `graphcloud::cloud` entry point, and
/// `cloud_renderer_installed()` is *"what unlocks `Graph3D`'s instanced fast path"* —
/// so this was the same missing install. **NOT PROVEN from the browser**, and that is
/// a limit worth stating rather than glossing: `facett-graph3d` publishes no lane in
/// its `state_json` and has no public `use_gpu` getter (`crate::graph::graph3d`
/// records the same gap), so nothing outside that crate can read the selection back.
/// This installs it because the defect is identical and the call is correct; the
/// readout that would let a test *assert* it is graph3d's own row, not this one.
///
/// `facett-map`'s 2-D GPU lane is deliberately NOT installed: the manifest takes
/// `facett-map` on default features, so `facett_map::gpu` is not in this binary at all.
/// Installing a lane the bundle does not select is how the reverse lie gets built.
///
/// * **`facett-graphnav/gpu` → [`facett_graphview::gpu_layout::install_host_device`].**
/// Not a renderer — a LOAN. The force layout is a compute job, and in a browser it
/// cannot make its own device (`pollster::block_on` on the main thread is a
/// deadlock), so the host lends the one eframe already created. Without this line
/// the selector finds no device and every large graph converges on wasm32's single
/// CPU thread. **Observable:** graphnav's `state_json().canvas.layout_lane` reads
/// `"gpu-job"` when the lane ran, so this install — unlike the cloud one above — can
/// be asserted from the browser.
#[cfg(target_arch = "wasm32")]
fn install_gpu_lanes(render_state: &eframe::egui_wgpu::RenderState) {
facett_flow::gpu::install_renderer(render_state);
facett_map3d::gpu::install_renderer(render_state);
// The 2-D map lane (`facett-map/wgpu` + `facett-geomap/map-wgpu`), used by `osm2d`
// and `map`. Without it their GPU paint callback no-ops and the CPU painter draws.
facett_map::gpu::install_renderer(render_state);
facett_core::render::gpu::graphcloud::cloud::install_cloud_renderer(render_state);
facett_graphview::gpu_layout::install_host_device(&render_state.device, &render_state.queue);
}
fn mount_inner(canvas_id: &str, component: &str, data_json: &str) {
console_error_panic_hook::set_once(); #[allow(unexpected_cfgs)] #[cfg(feature = "group-only")] if let Some(msg) = crate::groups::wrong_bundle(component) { log::error!("facet-wrapped: {msg}"); set(&LAST_ERROR, &msg); return; }
let registry = Registry::with_builtins();
let facet: Box<dyn Facet> = if data_json.trim().is_empty() {
// Showcase path: an unknown name falls back to the first entry so a bare load
// still shows something live. No caller data is at stake here.
let name = if registry.get(component).is_some() {
component.to_string()
} else {
registry
.names()
.first()
.copied()
.unwrap_or("table")
.to_string()
};
let Some(f) = registry.build(&name) else {
log::error!("facet-wrapped: empty registry");
return;
};
set(&LAST_ERROR, "");
let again = name.clone();
return start(canvas_id, name, f, Box::new(move || Registry::with_builtins().build(&again)));
} else {
// Live path: the caller's data or an honest refusal — never a fixture.
match checked_or_build(component, data_json) {
Ok(f) => {
set(&LAST_ERROR, "");
f
}
Err(msg) => {
log::error!("facet-wrapped: {msg}");
set(&LAST_ERROR, &msg);
return;
}
}
};
let (c, d) = (component.to_string(), data_json.to_string());
start(canvas_id, component.to_string(), facet, Box::new(move || checked_or_build(&c, &d).ok()));
}
/// The backend this build renders **through** — a constant, and deliberately still one.
///
/// It answers "what kind of surface is this", and the honest reading is that it can be
/// trusted for exactly that: the port off WebGL is real, and a driver reading `webgpu`
/// back out of the running wasm knows no GL path was compiled in.
///
/// **It is not evidence about the renderer, and this crate has twice been caught
/// reading it as though it were.** `Cargo.toml`'s map paragraph records row 3, where
/// the GPU lanes were compiled OUT of the bundle while this function and
/// `facet_canvas_api` both answered `webgpu` truthfully; and a canvas on
/// `--use-angle=swiftshader` answers `webgpu` here while a CPU rasteriser paints every
/// pixel. [`facet_adapter`] is the one that can tell those apart, by naming the device.
#[wasm_bindgen]
#[must_use]
pub fn facet_backend() -> String {
"webgpu".to_string()
}
/// **The physical device the running canvas actually opened** — the JSON
/// `facett_core::render::selected_adapter_json()` publishes, which is the SAME shape
/// every native facett surface reports. `"null"` before any canvas has mounted.
///
/// This is the answer [`facet_backend`] cannot give. `--use-angle=swiftshader` hands
/// Chrome a real WebGPU adapter that is a CPU rasteriser: `facet_backend()` says
/// `webgpu`, the loader's `facet_canvas_api` probe says `webgpu`, and
/// `tests/gpu_lane_is_observable.rs` proves the wgpu lane is wired — all three true,
/// all three true of software too. The fields here are not: `software` is a boolean,
/// `kind` is `cpu` / `integrated` / `discrete`, and `name` is the device a person
/// recognises (`AMD Radeon 890M Graphics (RADV GFX1150)`).
///
/// A driver should assert on `kind` or `software`, never on the presence of an adapter.
#[wasm_bindgen]
#[must_use]
pub fn facet_adapter() -> String {
crate::gpu::adapter_json()
}
/// Why WebGPU is unavailable, or `None` when `navigator.gpu` is present.
///
/// Returns the workspace's TYPED [`GpuUnavailable`] rather than a bespoke string, so
/// this canvas reports a missing adapter with the same `facet-gpu-1` code, banner and
/// remedy as every other facett surface — one vocabulary for one failure.
///
/// `navigator.gpu` is read reflectively: web-sys's own WebGPU bindings sit behind
/// `--cfg=web_sys_unstable_apis`, and only the property's EXISTENCE is needed here.
///
/// Note what the absence actually means. WebGPU is **secure-context only**, so a page
/// served from `file://` or plain `http:` on a non-loopback host has no
/// `navigator.gpu` at all — which is indistinguishable, at this level, from a browser
/// too old to have it. The extra `isSecureContext` line below exists to tell those
/// two apart, because their remedies are completely different.
/// The two synchronous facts that decide whether a page may ask for an adapter at all:
/// `navigator.gpu` present, and `isSecureContext`.
fn browser_preflight() -> Option<crate::adapter_retry::Preflight> {
let window = web_sys::window()?;
let has_gpu = js_sys::Reflect::get(window.navigator().as_ref(), &JsValue::from_str("gpu"))
.map(|v| !v.is_undefined() && !v.is_null())
.unwrap_or(false);
let secure = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("isSecureContext"))
.ok()
.and_then(|v| v.as_bool())
.unwrap_or(false);
crate::adapter_retry::preflight(has_gpu, secure)
}
fn webgpu_unavailable() -> Option<facett_core::render::GpuUnavailable> {
use facett_core::render::{GpuUnavailable, gpu::preferred_backends};
let window = web_sys::window()?;
let navigator = window.navigator();
let has_gpu = js_sys::Reflect::get(navigator.as_ref(), &JsValue::from_str("gpu"))
.map(|v| !v.is_undefined() && !v.is_null())
.unwrap_or(false);
if has_gpu {
return None;
}
let secure = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("isSecureContext"))
.ok()
.and_then(|v| v.as_bool())
.unwrap_or(false);
// The mask comes from the shared policy so the message names the backends that
// were actually asked for (`BROWSER_WEBGPU`) rather than a hardcoded guess.
let requested = if secure {
format!("{:?}", preferred_backends())
} else {
// Say WHY it is missing here; `remedy()` alone would send the operator off to
// upgrade a browser that is already new enough.
format!(
"{:?} (page is NOT a secure context — serve over https:// or localhost)",
preferred_backends()
)
};
Some(GpuUnavailable::NothingEnumerated { requested })
}
/// Boot eframe's `WebRunner` on the canvas with `facet` (async; returns immediately).
///
/// Every failure path writes [`LAST_ERROR`] as well as logging. On the WebGL backend
/// a failed start merely left a blank canvas; on WebGPU "no adapter" is a realistic
/// outcome, and a blank canvas would strand a headless driver waiting on
/// [`facet_mounted`] until its timeout with nothing to report.
fn start(canvas_id: &str, name: String, facet: Box<dyn Facet>, rebuild: Rebuild) {
let canvas_id = canvas_id.to_string();
wasm_bindgen_futures::spawn_local(async move {
// Pre-flight BEFORE touching the canvas: a typed reason beats a blank frame.
// navigator.gpu missing or an insecure page cannot be fixed by asking again, so it
// fails at once, in plain words (`adapter_retry::preflight`); only a real null
// adapter with navigator.gpu present is retried below.
crate::adapter_retry::record_reason("");
crate::adapter_retry::record_attempts(0);
if let Some(p) = browser_preflight() {
let msg = crate::adapter_retry::preflight_message(p);
log::error!("facet-wrapped: {msg}");
set(&LAST_ERROR, msg);
crate::adapter_retry::record_reason(msg);
if let Some(why) = webgpu_unavailable() {
facett_core::render::record_gpu_unavailable(why);
}
return;
}
if let Some(why) = webgpu_unavailable() {
log::error!("facet-wrapped: {why}");
// Record it in the shared slot too, so `gpu_unavailable_json()` reports
// this canvas's failure exactly like any other facett surface's.
set(&LAST_ERROR, &why.to_string());
facett_core::render::record_gpu_unavailable(why);
return;
}
// A SOFTWARE ADAPTER IS NOT A GPU (owner, 2026-10-02): SwiftShader answers
// requestAdapter() on a Chrome that cannot reach the card, and the lanes installed
// on it panicked in the first resident upload. Refused before eframe or any lane
// exists, with facett-core's predicate (the demo and the probes use the same one).
// A null adapter falls through to the bounded retry below.
if let Some(what) = browser_software_adapter().await {
let msg = crate::adapter_retry::software_adapter_message(&what);
log::error!("facet-wrapped: {msg}");
set(&LAST_ERROR, &msg);
crate::adapter_retry::record_reason(&msg);
return;
}
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("facet-wrapped: no document");
set(&LAST_ERROR, "no document");
return;
};
let Some(canvas) = document
.get_element_by_id(&canvas_id)
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
let msg = format!("missing <canvas id=\"{canvas_id}\">");
log::error!("facet-wrapped: {msg}");
set(&LAST_ERROR, &msg);
return;
};
// BOUNDED RETRY on "no adapter" (`crate::adapter_retry`): the first mount in a
// fresh Chrome can be refused an adapter while the GPU process comes up. The
// facet is consumed by an attempt, so a retry rebuilds it from the same inputs.
let mut first = Some(facet);
let mut attempt = 0;
loop {
attempt += 1;
crate::adapter_retry::record_attempts(attempt);
let Some(facet) = first.take().or_else(|| rebuild()) else {
set(&LAST_ERROR, "the component could not be rebuilt for a retry");
return;
};
let name = name.clone();
// The renderer configuration is the workspace policy's, not this crate's:
// `facett_wgpu_options()` sets the backend mask to `BROWSER_WEBGPU`, so wgpu
// cannot silently pick a WebGL2 device even if that backend were compiled in.
let web_options = eframe::WebOptions {
wgpu_options: facett_core::render::gpu::facett_wgpu_options(),
..Default::default()
};
let started = eframe::WebRunner::new()
.start(
canvas.clone(),
web_options,
Box::new(move |cc| {
// NAME the device before anything paints. `--use-angle=swiftshader`
// yields a working WebGPU adapter that is a CPU rasteriser, and
// every other observable this crate has answers `webgpu` on it —
// so the one fact that separates the 890M from SwiftShader is the
// adapter's own `AdapterInfo`, and this is the only place it is
// reachable. Read off eframe's REAL `RenderState`, never inferred.
if let Some(rs) = cc.wgpu_render_state.as_ref() {
crate::gpu::record_adapter(
facett_core::render::gpu::adapter_wgpu::facts_of(
&rs.adapter.get_info(),
),
);
// AND PUT SOMETHING ON THE LANES THIS BUNDLE SELECTS. Naming
// the adapter used to be the only thing done with this
// `RenderState`, and that is the whole of the defect below.
install_gpu_lanes(rs);
}
LookTheme::from_os(cc.egui_ctx.os()).apply(&cc.egui_ctx);
// Kept so `facet_update_data` can wake this canvas for the frame
// that swaps new data in.
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
let facet = std::rc::Rc::new(std::cell::RefCell::new(facet));
LIVE.with(|l| *l.borrow_mut() = Some(facet.clone()));
Ok(Box::new(MountApp { name, facet }))
}),
)
.await;
match started {
Ok(()) => {
name_the_device_from_the_browser().await;
return;
}
// Requesting the adapter/device happens inside `start`, so this is where
// "WebGPU exists but gave us no usable device" surfaces.
Err(e) => match crate::adapter_retry::after_failure(attempt, &format!("{e:?}")) {
crate::adapter_retry::Next::RetryAfter(ms) => {
log::warn!("facet-wrapped: no WebGPU adapter on attempt {attempt}; retrying in {ms} ms");
sleep_ms(ms).await;
}
crate::adapter_retry::Next::Fail(msg) => {
log::error!("facet-wrapped: {msg}");
set(&LAST_ERROR, &msg);
crate::adapter_retry::record_reason(&msg);
return;
}
},
}
}
});
}
/// Rebuilds a mount's component for an adapter retry (the first build was consumed).
type Rebuild = Box<dyn Fn() -> Option<Box<dyn Facet>>>;
/// `setTimeout` as a future (the retry backoff).
async fn sleep_ms(ms: u32) {
let p = js_sys::Promise::new(&mut |resolve, _| {
if let Some(w) = web_sys::window() {
let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms as i32);
}
});
let _ = wasm_bindgen_futures::JsFuture::from(p).await;
}
/// Ask the BROWSER what device it opened, when wgpu could not say.
///
/// # Why this exists — measured on the 890M, in a real window, 2026-09-06
///
/// The `record_adapter` call above reads `rs.adapter.get_info()`, which is the right
/// source on every native backend. On `BrowserWebGpu` it comes back **masked**: on
/// genuine hardware, painting a real frame, with the vulkan flags, `facet_adapter()`
/// answered `"name":""`, `"vendor":"0x0000"`, `"driver":""`, `"kind":"other"`. So the
/// loader printed `GPU: (other, BrowserWebGpu)` — an empty name on a Radeon.
///
/// **That is not cosmetic.** `software` — the field
/// `the_canvas_names_its_adapter.rs` tells drivers to assert on — is decided by
/// `is_software_rasteriser`, which looks for `swiftshader` / `llvmpipe` / `lavapipe`
/// **in the name and driver**. Against two empty strings it can only ever answer
/// `false`. **The SwiftShader trap that file exists to close was, in practice, still
/// open: the discriminator could not have fired.**
///
/// The browser has the identity and hands it over — in the same window, in the same
/// arm, `(await navigator.gpu.requestAdapter()).info` gave `vendor: "amd"`,
/// `architecture: "rdna-3"` — so this asks for it and re-records ONLY when wgpu left
/// the name empty. A backend that reports a real driver string keeps it: the browser's
/// pair is coarser, and replacing a good name with a coarse one is a regression
/// wearing a fix's clothes.
///
/// Everything here is reflective (`js_sys::Reflect`) for the reason `webgpu_unavailable`
/// already gives: web-sys's WebGPU bindings sit behind `--cfg=web_sys_unstable_apis`,
/// and only these four string properties are needed. **Every step is fallible and every
/// failure is silent on purpose** — this is an enrichment of a record that already
/// exists, so a browser that refuses leaves the honest masked reading in place rather
/// than replacing it with an error.
async fn name_the_device_from_the_browser() {
if !crate::gpu::recorded_name_is_empty() {
return;
}
let Some(info) = browser_adapter_info().await else {
return;
};
let get = |k: &str| {
js_sys::Reflect::get(&info, &JsValue::from_str(k))
.ok()
.and_then(|v| v.as_string())
.unwrap_or_default()
};
let facts = crate::gpu::facts_from_browser_info(
&get("vendor"),
&get("architecture"),
&get("device"),
&get("description"),
"BrowserWebGpu",
);
// Empty in, empty out: if the browser masked everything too, re-recording would
// only churn the record without adding a fact.
if facts.name.trim().is_empty() {
return;
}
crate::gpu::record_adapter(facts);
}
/// `(await navigator.gpu.requestAdapter()).info`, or `None` at the first step that
/// does not answer.
///
/// This is a SECOND `requestAdapter` and not the one the canvas is running on. That is
/// deliberate and it is the honest limit of this enrichment: there is no supported way
/// to reach the underlying `GPUAdapter` through wgpu's handle, and a browser hands out
/// one device per set of flags, so the identity is the same in every arm that matters.
/// It is asked once, after mount, off the paint path.
async fn browser_adapter_info() -> Option<js_sys::Object> {
let adapter = browser_adapter().await?;
let info = js_sys::Reflect::get(&adapter, &JsValue::from_str("info")).ok()?;
info.dyn_into().ok()
}
/// `Some(what)` when the browser's adapter is a CPU rasteriser — its `info` strings and
/// `isFallbackAdapter` (on `info` in current Chrome, on the adapter in older ones)
/// through `facett_core::adapter_retry::software_adapter`. `None` for a hardware
/// adapter, a masked one, or no adapter (the retry handles that).
async fn browser_software_adapter() -> Option<String> {
let adapter = browser_adapter().await?;
let info = js_sys::Reflect::get(&adapter, &JsValue::from_str("info")).unwrap_or(JsValue::UNDEFINED);
let s = |k: &str| js_sys::Reflect::get(&info, &JsValue::from_str(k)).ok().and_then(|v| v.as_string()).unwrap_or_default();
let flag = |o: &JsValue| js_sys::Reflect::get(o, &JsValue::from_str("isFallbackAdapter")).ok().and_then(|v| v.as_bool());
let fallback = flag(&info).or_else(|| flag(&adapter)).unwrap_or(false);
crate::adapter_retry::software_adapter(&s("vendor"), &s("architecture"), &s("device"), &s("description"), fallback)
}
/// `await navigator.gpu.requestAdapter()`, or `None` at the first step that does not answer.
async fn browser_adapter() -> Option<JsValue> {
let navigator = web_sys::window()?.navigator();
let gpu = js_sys::Reflect::get(navigator.as_ref(), &JsValue::from_str("gpu")).ok()?;
if gpu.is_undefined() || gpu.is_null() {
return None;
}
let req = js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")).ok()?;
let req: js_sys::Function = req.dyn_into().ok()?;
let promise = req.call0(&gpu).ok()?;
let promise: js_sys::Promise = promise.dyn_into().ok()?;
let adapter = wasm_bindgen_futures::JsFuture::from(promise).await.ok()?;
if adapter.is_undefined() || adapter.is_null() {
return None;
}
Some(adapter)
}
/// The live mounted component's `state_json`, built on this call. `"null"` before
/// the first frame. This is what a headless drive reads back.
#[wasm_bindgen]
#[must_use]
pub fn facet_state() -> String {
// "null" until the first frame has drawn, exactly as before.
if get(&MOUNTED).is_empty() {
return "null".to_string();
}
let t0 = now_ms();
let state = LIVE.with(|l| {
let l = l.borrow();
let facet = l.as_ref()?.try_borrow().ok()?;
let mut v = facet.state_json();
stamp_lanes(&mut v);
serde_json::to_string(&v).ok()
});
let s = state.unwrap_or_else(|| "null".to_string());
PERF.with(|p| {
let mut p = p.borrow_mut();
p.state_ms_last = now_ms() - t0;
p.state_bytes_last = s.len();
});
s
}
/// Add the bundle's own lane facts to a component's state, never overwriting a key
/// the component already publishes:
///
/// * `gpu` — `"discrete"`, `"integrated"`, … when the canvas opened a hardware
/// adapter, `"none"` otherwise, with `gpu_reason` saying why. A CPU rasteriser
/// (SwiftShader, llvmpipe) is `"none"`: the GPU lanes need a GPU, and a software
/// adapter is a CPU drawing pixels.
/// * `threads` / `threads_reason` — this bundle is single-threaded, so `1` and
/// `"no-bundle"` (no shared-memory bundle is built; see `.nornir/wasm-threads-plan.md`).
fn stamp_lanes(v: &mut serde_json::Value) {
let Some(o) = v.as_object_mut() else { return };
let adapter: serde_json::Value = serde_json::from_str(&crate::gpu::adapter_json()).unwrap_or_default();
let (gpu, why) = if adapter.is_null() {
let r = crate::adapter_retry::reason();
("none".to_string(), if r.is_empty() { "no adapter was opened".to_string() } else { r })
} else if crate::gpu::is_software() {
(
"none".to_string(),
format!("software rasteriser: {}", adapter.get("name").and_then(|n| n.as_str()).unwrap_or("")),
)
} else {
(adapter.get("kind").and_then(|k| k.as_str()).unwrap_or("other").to_string(), String::new())
};
o.entry("gpu").or_insert_with(|| serde_json::json!(gpu));
o.entry("gpu_reason").or_insert_with(|| serde_json::json!(why));
// How many adapter requests the mount made (1 = first time; see `adapter_retry`).
o.entry("adapter_attempts").or_insert_with(|| serde_json::json!(crate::adapter_retry::attempts()));
// A GPU resource the device refused (the lane drew nothing and said why, no panic).
if let Some(e) = facett_core::render::lane::gpu_error() {
o.entry("gpu_error").or_insert_with(|| serde_json::json!(e));
}
o.entry("threads").or_insert_with(|| serde_json::json!(1));
o.entry("threads_reason").or_insert_with(|| serde_json::json!("no-bundle"));
}
/// Where the time went: frame count and per-frame `ui` time, the last check/mount's
/// parse and build, and the last `facet_state()` read — as JSON.
#[wasm_bindgen]
#[must_use]
pub fn facet_perf() -> String {
PERF.with(|p| serde_json::to_string(&*p.borrow()).unwrap_or_else(|_| "null".into()))
}
/// The name of the component currently mounted (empty before any mount).
#[wasm_bindgen]
#[must_use]
pub fn facet_mounted() -> String {
get(&MOUNTED)
}
/// The last mount **refusal**, or `""` when the last mount succeeded. A host shows
/// this instead of a canvas, so "we cannot render your data" is always visible rather
/// than being papered over with demo data.
#[wasm_bindgen]
#[must_use]
pub fn facet_error() -> String {
get(&LAST_ERROR)
}